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,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
## Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
## Env / secrets
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
## Node / frontend
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
|
||||
## Editors / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
## Test / coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
# Chat service — architecture document
|
||||
|
||||
## 1. Overview
|
||||
|
||||
A web-based team chat service, similar in spirit to Mattermost, with no threaded
|
||||
conversations. Core features:
|
||||
|
||||
- Chat rooms that are either **open** (anyone can join) or **invite-only** (private)
|
||||
- A **PWA** client — single codebase serves desktop, mobile web, and an installable
|
||||
app experience
|
||||
- **Push notifications** for offline/backgrounded users
|
||||
- A **full admin portal** for site administration
|
||||
- An **extension system** for bots and AI agents (webhooks, scoped API tokens,
|
||||
live WebSocket access)
|
||||
|
||||
Deployment target: two plain Linux servers, no containers. One server runs the
|
||||
database and Redis; the other runs the application and serves the frontend.
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
|---|---|---|
|
||||
| Backend | Python, FastAPI (async) | Async-native, fits many concurrent WebSocket connections without extra layers |
|
||||
| ORM / migrations | SQLAlchemy 2.0 (async) + Alembic | Mature async ORM, explicit schema migrations |
|
||||
| Database | PostgreSQL | Relational structure fits users/rooms/memberships/messages well |
|
||||
| Cross-instance broadcast | Redis (pub/sub) | Lets multiple app server processes fan out messages to all connected clients |
|
||||
| Push notifications | pywebpush + VAPID | Standard Web Push, works on Android and iOS 16.4+ (PWA must be installed to home screen on iOS) |
|
||||
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
|
||||
| Reverse proxy / TLS | Nginx + Let's Encrypt (certbot) | Terminates TLS, serves static assets, proxies REST + WebSocket traffic |
|
||||
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
|
||||
| Auth | Session cookies (httpOnly, secure) | Simplest to carry through a WebSocket handshake automatically |
|
||||
|
||||
## 3. System architecture
|
||||
|
||||
### 3.1 Core message flow
|
||||
|
||||
```
|
||||
PWA client (browser + service worker)
|
||||
| REST + WebSocket
|
||||
v
|
||||
FastAPI app server (N instances behind Nginx)
|
||||
| | |
|
||||
v v v
|
||||
PostgreSQL Redis pub/sub Push service (pywebpush)
|
||||
(persistence) (fan-out across (delivers to offline
|
||||
app instances) clients via Web Push)
|
||||
```
|
||||
|
||||
A message is persisted to Postgres, published to a Redis channel scoped to its
|
||||
room, and every app server instance subscribed to that channel forwards it over
|
||||
WebSocket to its own connected clients who are members. Members who are not
|
||||
currently connected get a Web Push notification instead, looked up from their
|
||||
stored push subscription.
|
||||
|
||||
Redis only matters once more than one app server process is running. A single
|
||||
instance can skip it entirely and add it later without changing anything else.
|
||||
|
||||
### 3.2 Admin and extension layer
|
||||
|
||||
```
|
||||
Admin portal Bots / AI agents
|
||||
| |
|
||||
v v
|
||||
API gateway
|
||||
(scoped tokens + admin role checks)
|
||||
|
|
||||
v
|
||||
Core chat server
|
||||
(rooms, messages, permissions)
|
||||
|
|
||||
v (WebSocket events, dashed = async)
|
||||
back out to bots/agents
|
||||
```
|
||||
|
||||
Admin portal and bots/agents are both just API consumers, differentiated by the
|
||||
credentials they carry: session + `is_site_admin` flag for the portal, scoped API
|
||||
tokens for bots. Bots can also hold a live WebSocket connection to receive room
|
||||
events in real time and post message updates — the same mechanism a human client
|
||||
uses.
|
||||
|
||||
## 4. Data model
|
||||
|
||||
```
|
||||
users
|
||||
id, username, email, password_hash, is_bot, is_site_admin, created_at
|
||||
|
||||
rooms
|
||||
id, name, description, is_private, owner_id, created_at
|
||||
|
||||
room_memberships
|
||||
room_id, user_id, role (owner | admin | member), joined_at
|
||||
|
||||
room_invites
|
||||
id, room_id, invited_by, token, target_user_id or target_email,
|
||||
expires_at, status (pending | accepted | revoked)
|
||||
|
||||
messages
|
||||
id, room_id, user_id, content, created_at, edited_at, deleted_at
|
||||
|
||||
push_subscriptions
|
||||
id, user_id, endpoint, p256dh_key, auth_key, created_at
|
||||
|
||||
api_tokens
|
||||
id, owner_id (user or bot), token_hash, scopes[], last_used_at, created_at
|
||||
|
||||
webhooks_incoming
|
||||
id, room_id, token, created_by, description
|
||||
|
||||
event_subscriptions
|
||||
id, room_id (nullable = global), event_types[], target_url,
|
||||
signing_secret, created_by
|
||||
|
||||
admin_audit_log
|
||||
id, actor_id, action, target_type, target_id, metadata, created_at
|
||||
```
|
||||
|
||||
## 5. Permission model
|
||||
|
||||
- **Room visibility**: `open` (any authenticated user can find and join) or
|
||||
`private` (visible only to members, joinable only via invite).
|
||||
- **Room roles**: `owner` (delete room, transfer ownership), `admin` (invite/remove
|
||||
members, edit settings), `member` (post, leave).
|
||||
- **Site-level**: `is_site_admin` on the user record, checked for every admin
|
||||
portal route.
|
||||
- Every room action is authorized server-side against `room_memberships` — never
|
||||
trust a client's claim about its own role or membership.
|
||||
|
||||
## 6. Real-time and push notification flow
|
||||
|
||||
1. Client sends a message over its open WebSocket.
|
||||
2. Server checks the sender is a member of the room, persists the message.
|
||||
3. Server publishes the message to the room's Redis pub/sub channel.
|
||||
4. Every app instance subscribed to that channel forwards it to its own connected
|
||||
members over WebSocket.
|
||||
5. For members with no active connection, the server looks up
|
||||
`push_subscriptions` and sends a Web Push notification via `pywebpush`.
|
||||
|
||||
## 7. Extension system: bots and AI agents
|
||||
|
||||
Extensions run **outside** the server process and talk to it over the network —
|
||||
no in-process plugin runtime, no sandboxing to build. This is deliberately the
|
||||
lighter-weight option, and it matches how AI agents naturally integrate: as an
|
||||
HTTP/WebSocket client.
|
||||
|
||||
- **Bot accounts**: a row in `users` with `is_bot = true`. Can be added to rooms
|
||||
and post messages exactly like a human account.
|
||||
- **Scoped API tokens**: e.g. `read:messages`, `write:messages`, `manage:rooms`,
|
||||
issued per bot from the admin portal, hashed at rest, shown once at creation.
|
||||
- **Incoming webhooks**: a room-scoped URL an external service can POST a message
|
||||
to. No auth flow beyond the URL being a secret.
|
||||
- **Outgoing webhooks / event subscriptions**: the server POSTs to a registered
|
||||
URL when matching events happen, signed with `signing_secret` so the receiver
|
||||
can verify authenticity.
|
||||
- **Live WebSocket access for bots**: same connection type the PWA client uses,
|
||||
authenticated with a bot token. Lets a bot or AI agent see messages as they
|
||||
arrive and reply without polling.
|
||||
- **Message update events**: beyond create/edit/delete, support patching an
|
||||
existing message's content. This lets an AI agent post a placeholder and stream
|
||||
tokens into it live, the same pattern Slack/Discord bots use.
|
||||
|
||||
Security notes: validate outgoing webhook target URLs to block requests into
|
||||
internal network ranges (SSRF), rate-limit bot API calls the same as human ones,
|
||||
and log bot actions to `admin_audit_log`.
|
||||
|
||||
## 8. Admin portal
|
||||
|
||||
Built as protected routes inside the same React PWA (`/admin/*`), gated by
|
||||
`is_site_admin` on the session — no separate app or deployment to maintain.
|
||||
|
||||
Features:
|
||||
- User management: list, deactivate, reset password, promote to site admin
|
||||
- Room management: view all rooms (including private), transfer ownership,
|
||||
force-archive
|
||||
- Bot/integration management: create bots, generate/revoke tokens, set scopes,
|
||||
view registered webhooks
|
||||
- System settings: open vs invite-only registration, file size limits, branding
|
||||
- Audit log viewer
|
||||
|
||||
For fast internal CRUD scaffolding on top of the SQLAlchemy models, consider
|
||||
[SQLAdmin](https://aminalaee.dev/sqladmin/) mounted on an internal-only path —
|
||||
useful for raw table management while custom logic (moderation, bot tokens,
|
||||
audit views) gets built separately.
|
||||
|
||||
## 9. Deployment architecture — two Linux servers, no Docker
|
||||
|
||||
### 9.1 Data server
|
||||
|
||||
Runs PostgreSQL and Redis.
|
||||
|
||||
- Bind Postgres and Redis to the private network interface only, never `0.0.0.0`
|
||||
on a public interface.
|
||||
- Firewall (`ufw` or `iptables`): allow port 5432 (Postgres) and 6379 (Redis)
|
||||
only from the app server's IP address.
|
||||
- If the hosting provider doesn't offer a private network between the two
|
||||
servers, put a WireGuard tunnel between them and bind services to the tunnel
|
||||
interface instead of trusting a firewall rule alone over the public internet.
|
||||
- Backups: nightly `pg_dump` via a cron job, rotated and shipped off-box.
|
||||
|
||||
### 9.2 App server
|
||||
|
||||
Runs the FastAPI app and Nginx; serves the built PWA static files.
|
||||
|
||||
- Python virtualenv, application installed via `pip install -e .` or similar.
|
||||
- App run via Gunicorn with Uvicorn workers, one process per CPU core as a
|
||||
starting point, managed by a systemd unit:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/chatapp.service
|
||||
[Unit]
|
||||
Description=Chat service app server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=chatapp
|
||||
WorkingDirectory=/srv/chatapp
|
||||
EnvironmentFile=/etc/chatapp/env
|
||||
ExecStart=/srv/chatapp/venv/bin/gunicorn app.main:app \
|
||||
-k uvicorn.workers.UvicornWorker \
|
||||
--workers 4 \
|
||||
--bind unix:/run/chatapp/chatapp.sock
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
- Nginx terminates TLS (certbot-managed certificate), serves the built frontend
|
||||
assets directly, and reverse-proxies API and WebSocket traffic to the Unix
|
||||
socket:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name chat.example.com;
|
||||
|
||||
root /srv/chatapp/frontend/dist;
|
||||
try_files $uri /index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://unix:/run/chatapp/chatapp.sock;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://unix:/run/chatapp/chatapp.sock;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Secrets (database URL pointing at the data server's private IP, Redis URL,
|
||||
VAPID keys, session secret) live in `/etc/chatapp/env`, loaded via
|
||||
`EnvironmentFile=`, never committed to the repository.
|
||||
- Deploy process: `git pull`, install/update dependencies, `alembic upgrade
|
||||
head`, build the frontend, `systemctl restart chatapp`, `nginx -s reload` if
|
||||
the Nginx config changed.
|
||||
- Logs: `journalctl -u chatapp`, rotated by systemd/journald defaults; add
|
||||
`logrotate` if the app also writes its own log files.
|
||||
|
||||
## 10. Security considerations
|
||||
|
||||
- Server-side authorization on every room and message action — never trust
|
||||
client-supplied role/membership claims.
|
||||
- Database bound to the private network only, firewalled to the app server's IP.
|
||||
- API tokens and webhook secrets hashed/stored securely, shown once at creation.
|
||||
- Outgoing webhook URLs validated against internal IP ranges to prevent SSRF.
|
||||
- Rate limiting on both human and bot API traffic.
|
||||
- TLS everywhere in transit (Nginx-terminated for clients; a WireGuard tunnel or
|
||||
equivalent for cross-server DB traffic if not on a trusted private network).
|
||||
|
||||
## 11. Phased build plan
|
||||
|
||||
1. Auth, room CRUD, open rooms, single-instance WebSocket messaging
|
||||
2. Private rooms, invites, roles
|
||||
3. PWA shell — manifest, service worker, offline caching
|
||||
4. Push notification subscription + delivery
|
||||
5. Redis pub/sub for horizontal scaling across app server instances
|
||||
6. Admin portal
|
||||
7. Bot/extension system: tokens, webhooks, bot WebSocket access, message updates
|
||||
@@ -0,0 +1,52 @@
|
||||
# KeepItTalking
|
||||
|
||||
A web-based team chat service (Mattermost-style, no threaded conversations),
|
||||
invite-only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design
|
||||
and phased build plan.
|
||||
|
||||
**Phase 1** (this state of the repo): auth, open-room CRUD, and single-instance
|
||||
WebSocket chat, backend + a minimal frontend. Later phases (private rooms,
|
||||
push notifications, Redis fan-out, the admin portal, the bot/extension
|
||||
system, and production deployment) are tracked as issues in the repo's issue
|
||||
tracker, prioritized.
|
||||
|
||||
## Structure
|
||||
|
||||
- [`backend/`](backend/) — FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. See
|
||||
[`backend/README.md`](backend/README.md) for local setup, migrations, and
|
||||
how to create a user (registration is invite-only — there's no public
|
||||
sign-up endpoint).
|
||||
- [`frontend/`](frontend/) — React + Vite PWA (login, room list, chat view).
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# 1. Postgres (see backend/README.md for details)
|
||||
docker run -d --name chatapp-postgres \
|
||||
-e POSTGRES_USER=chatapp -e POSTGRES_PASSWORD=chatapp -e POSTGRES_DB=chatapp \
|
||||
-p 5432:5432 postgres:16-alpine
|
||||
|
||||
# 2. Backend
|
||||
cd backend
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e ".[dev]"
|
||||
cp .env.example .env # then set SESSION_SECRET
|
||||
.venv/bin/alembic upgrade head
|
||||
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
|
||||
.venv/bin/uvicorn app.main:app --reload &
|
||||
|
||||
# 3. Frontend (in another shell)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open http://localhost:5173 and log in with the account created above.
|
||||
The Vite dev server proxies `/api` and `/ws` to the backend on `:8000`, so no
|
||||
CORS configuration is needed in development.
|
||||
|
||||
## Deployment
|
||||
|
||||
Not part of Phase 1. The target is two plain Linux servers with no
|
||||
containers — see [ARCHITECTURE.md §9](ARCHITECTURE.md#9-deployment-architecture--two-linux-servers-no-docker)
|
||||
and the corresponding "Production deployment" issue in the tracker.
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# KeepItTalking frontend (Phase 1)
|
||||
|
||||
React + Vite PWA. Login, room list, and chat views wired to the backend's
|
||||
REST API and `/ws/chat` WebSocket endpoint. See [`../README.md`](../README.md)
|
||||
and [`../backend/README.md`](../backend/README.md) for full local setup.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server proxies `/api` and `/ws` to `http://localhost:8000` (see
|
||||
`vite.config.ts`), so the backend must be running for anything beyond the
|
||||
login page to work.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Generates the PWA manifest and service worker via `vite-plugin-pwa` into `dist/`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.tsx, App.tsx routes: /login, /rooms, /rooms/:roomId
|
||||
api/ fetch wrappers (client, auth, rooms)
|
||||
ws/useChatSocket.ts WebSocket hook (join/send/receive)
|
||||
context/AuthContext.tsx current-user state, hydrated via GET /api/auth/me
|
||||
components/ ProtectedRoute, RoomListItem, MessageList, MessageInput
|
||||
pages/ LoginPage, RoomListPage, ChatRoomPage
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KeepItTalking</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,35 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { RoomListPage } from './pages/RoomListPage'
|
||||
import { ChatRoomPage } from './pages/ChatRoomPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/rooms"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RoomListPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rooms/:roomId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ChatRoomPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/rooms" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
// No register() here: this is an invite-only site. Accounts are created by
|
||||
// an operator via the backend CLI (`python -m app.cli create-user`), not
|
||||
// through a public endpoint.
|
||||
|
||||
export function login(usernameOrEmail: string, password: string): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username_or_email: usernameOrEmail, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function me(): Promise<User> {
|
||||
return apiFetch<User>('/api/auth/me')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText
|
||||
try {
|
||||
const body = await response.json()
|
||||
detail = body.detail ?? detail
|
||||
} catch {
|
||||
// response had no JSON body
|
||||
}
|
||||
throw new ApiError(response.status, detail)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { Message, Room, RoomListItem } from '../types'
|
||||
|
||||
export function listRooms(): Promise<RoomListItem[]> {
|
||||
return apiFetch<RoomListItem[]>('/api/rooms')
|
||||
}
|
||||
|
||||
export function createRoom(name: string, description?: string): Promise<Room> {
|
||||
return apiFetch<Room>('/api/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description: description || null }),
|
||||
})
|
||||
}
|
||||
|
||||
export function joinRoom(roomId: string): Promise<Room> {
|
||||
return apiFetch<Room>(`/api/rooms/${roomId}/join`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getRoomMessages(roomId: string): Promise<Message[]> {
|
||||
return apiFetch<Message[]>(`/api/rooms/${roomId}/messages`)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
interface MessageInputProps {
|
||||
disabled?: boolean
|
||||
onSend: (content: string) => void
|
||||
}
|
||||
|
||||
export function MessageInput({ disabled, onSend }: MessageInputProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
onSend(trimmed)
|
||||
setValue('')
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Message..."
|
||||
/>
|
||||
<button type="submit" disabled={disabled || !value.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ChatMessageEnvelope, Message } from '../types'
|
||||
|
||||
interface DisplayMessage {
|
||||
id: string
|
||||
username: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: (Message | ChatMessageEnvelope)[]
|
||||
usernames: Record<string, string>
|
||||
}
|
||||
|
||||
export function MessageList({ messages, usernames }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: 'end' })
|
||||
}, [messages.length])
|
||||
|
||||
const display: DisplayMessage[] = messages.map((m) => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
username: 'username' in m ? m.username : usernames[m.user_id] ?? m.user_id,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
|
||||
{display.map((m) => (
|
||||
<div key={m.id} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>{m.username}</strong>{' '}
|
||||
<span style={{ color: '#888', fontSize: '0.8em' }}>
|
||||
{new Date(m.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
<div>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) return <p>Loading...</p>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { RoomListItem as RoomListItemType } from '../types'
|
||||
|
||||
interface RoomListItemProps {
|
||||
room: RoomListItemType
|
||||
onJoin: (roomId: string) => void
|
||||
joining: boolean
|
||||
}
|
||||
|
||||
export function RoomListItem({ room, onJoin, joining }: RoomListItemProps) {
|
||||
return (
|
||||
<li style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 0' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong>{room.name}</strong>
|
||||
{room.description && <div style={{ color: '#666' }}>{room.description}</div>}
|
||||
</div>
|
||||
{room.is_member ? (
|
||||
<Link to={`/rooms/${room.id}`}>Open</Link>
|
||||
) : (
|
||||
<button disabled={joining} onClick={() => onJoin(room.id)}>
|
||||
Join
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import * as authApi from '../api/auth'
|
||||
import { ApiError } from '../api/client'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (usernameOrEmail: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch((err) => {
|
||||
if (!(err instanceof ApiError && err.status === 401)) {
|
||||
console.error('Failed to load current user', err)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function login(usernameOrEmail: string, password: string) {
|
||||
setUser(await authApi.login(usernameOrEmail, password))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authApi.logout()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within an AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
:root {
|
||||
font-family: system-ui, sans-serif;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { getRoomMessages } from '../api/rooms'
|
||||
import { MessageInput } from '../components/MessageInput'
|
||||
import { MessageList } from '../components/MessageList'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useChatSocket } from '../ws/useChatSocket'
|
||||
import type { ChatMessageEnvelope, Message, ServerEnvelope } from '../types'
|
||||
|
||||
export function ChatRoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>()
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [history, setHistory] = useState<Message[]>([])
|
||||
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
|
||||
const [wsError, setWsError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return
|
||||
setHistory([])
|
||||
setLive([])
|
||||
getRoomMessages(roomId).then(setHistory).catch((err) => setWsError(String(err)))
|
||||
}, [roomId])
|
||||
|
||||
const onMessage = useCallback((envelope: ServerEnvelope) => {
|
||||
if (envelope.type === 'message') {
|
||||
setLive((prev) => [...prev, envelope])
|
||||
} else if (envelope.type === 'error') {
|
||||
setWsError(envelope.detail)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
|
||||
|
||||
const { connected, send } = useChatSocket({
|
||||
roomId: roomId ?? '',
|
||||
onMessage,
|
||||
onUnauthenticated,
|
||||
})
|
||||
|
||||
if (!roomId) return null
|
||||
|
||||
const usernames = user ? { [user.id]: user.username } : {}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: 640, margin: '0 auto' }}>
|
||||
<header style={{ padding: '0.5rem', borderBottom: '1px solid #ddd' }}>
|
||||
<button onClick={() => navigate('/rooms')}>← Rooms</button>
|
||||
{!connected && <span style={{ marginLeft: '1rem', color: '#888' }}>Connecting...</span>}
|
||||
</header>
|
||||
|
||||
{wsError && <p style={{ color: 'red', padding: '0 0.5rem' }}>{wsError}</p>}
|
||||
|
||||
<MessageList messages={[...history, ...live]} usernames={usernames} />
|
||||
<MessageInput disabled={!connected} onSend={send} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { ApiError } from '../api/client'
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (user) return <Navigate to="/rooms" replace />
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/rooms')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 360, margin: '4rem auto' }}>
|
||||
<h1>KeepItTalking</h1>
|
||||
<p style={{ color: '#888' }}>This is an invite-only site. Ask an admin for an account.</p>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<label>
|
||||
Username or email
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button type="submit" disabled={submitting}>
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createRoom, joinRoom, listRooms } from '../api/rooms'
|
||||
import { RoomListItem } from '../components/RoomListItem'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { RoomListItem as RoomListItemType } from '../types'
|
||||
|
||||
export function RoomListPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [rooms, setRooms] = useState<RoomListItemType[]>([])
|
||||
const [newRoomName, setNewRoomName] = useState('')
|
||||
const [joiningId, setJoiningId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
setRooms(await listRooms())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err) => setError(String(err)))
|
||||
}, [])
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = newRoomName.trim()
|
||||
if (!name) return
|
||||
setError(null)
|
||||
try {
|
||||
await createRoom(name)
|
||||
setNewRoomName('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin(roomId: string) {
|
||||
setJoiningId(roomId)
|
||||
setError(null)
|
||||
try {
|
||||
await joinRoom(roomId)
|
||||
await refresh()
|
||||
navigate(`/rooms/${roomId}`)
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setJoiningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480, margin: '2rem auto' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1>Rooms</h1>
|
||||
<div>
|
||||
<span style={{ marginRight: '1rem' }}>{user?.username}</span>
|
||||
<button onClick={() => logout()}>Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleCreate} style={{ display: 'flex', gap: '0.5rem', margin: '1rem 0' }}>
|
||||
<input
|
||||
value={newRoomName}
|
||||
onChange={(e) => setNewRoomName(e.target.value)}
|
||||
placeholder="New room name"
|
||||
/>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{rooms.map((room) => (
|
||||
<RoomListItem
|
||||
key={room.id}
|
||||
room={room}
|
||||
onJoin={handleJoin}
|
||||
joining={joiningId === room.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
is_bot: boolean
|
||||
is_site_admin: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
is_private: boolean
|
||||
owner_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RoomListItem extends Room {
|
||||
is_member: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
room_id: string
|
||||
user_id: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatMessageEnvelope {
|
||||
type: 'message'
|
||||
id: string
|
||||
room_id: string
|
||||
user_id: string
|
||||
username: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChatJoinedEnvelope {
|
||||
type: 'joined'
|
||||
room_id: string
|
||||
}
|
||||
|
||||
export interface ChatErrorEnvelope {
|
||||
type: 'error'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ServerEnvelope } from '../types'
|
||||
|
||||
interface UseChatSocketOptions {
|
||||
roomId: string
|
||||
onMessage: (envelope: ServerEnvelope) => void
|
||||
onUnauthenticated: () => void
|
||||
}
|
||||
|
||||
export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatSocketOptions) {
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const onMessageRef = useRef(onMessage)
|
||||
onMessageRef.current = onMessage
|
||||
const onUnauthenticatedRef = useRef(onUnauthenticated)
|
||||
onUnauthenticatedRef.current = onUnauthenticated
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/chat`)
|
||||
socketRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true)
|
||||
ws.send(JSON.stringify({ type: 'join', room_id: roomId }))
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
onMessageRef.current(JSON.parse(event.data) as ServerEnvelope)
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnected(false)
|
||||
if (event.code === 4401) {
|
||||
onUnauthenticatedRef.current()
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
const send = useCallback((content: string) => {
|
||||
const ws = socketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
|
||||
}, [roomId])
|
||||
|
||||
return { connected, send }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'KeepItTalking',
|
||||
short_name: 'Talking',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#111111',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{
|
||||
src: '/icons/icon-512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user