diff --git a/README.md b/README.md index ef7f00f..a2adb64 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A web-based tool for a German language teacher to provide clients with customize | Layer | Technology | |-------|-----------| | Backend | Python 3.12 + FastAPI | -| Database | PostgreSQL (async via SQLAlchemy + asyncpg) | +| Database | MySQL (async via SQLAlchemy + aiomysql) | | Auth | JWT + bcrypt | | LLM | LiteLLM (provider-agnostic) | | Voice | OpenAI Whisper/TTS or Web Speech API (feature flag) | @@ -47,12 +47,26 @@ uvicorn app.main:app --reload ## Production Deployment (Debian) ```bash -# Prerequisites: PostgreSQL and nginx already installed +# Prerequisites: MySQL and nginx already installed sudo bash deploy/setup.sh +``` -# Then edit the .env file: +On a **fresh install** the script generates the database password and writes it +into `.env`, then installs the service *without starting it* — so the app never +goes live with the template's LLM key and default admin password: + +```bash +# 1. Set LLM_API_KEY and ADMIN_PASSWORD (leave DATABASE_URL alone): sudo nano /opt/fluentgerman/backend/.env +# 2. Start it: +sudo systemctl start fluentgerman +``` + +Re-running `setup.sh` on an **existing install** keeps `.env` and the database +password untouched, and restarts the service. + +```bash # Restart after config changes: sudo systemctl restart fluentgerman ``` @@ -64,7 +78,7 @@ sudo systemctl restart fluentgerman │ ├── app/ │ │ ├── main.py # FastAPI entry point │ │ ├── config.py # Environment-based settings -│ │ ├── database.py # Async PostgreSQL setup +│ │ ├── database.py # Async MySQL setup │ │ ├── models.py # User & Instruction models │ │ ├── schemas.py # Pydantic request/response │ │ ├── auth.py # JWT + bcrypt + dependencies @@ -97,7 +111,7 @@ All settings via `.env` file (see `.env.example`): | Variable | Description | |----------|-------------| | `SECRET_KEY` | JWT signing key (generate a strong random one) | -| `DATABASE_URL` | PostgreSQL connection string | +| `DATABASE_URL` | MySQL connection string | | `LLM_API_KEY` | Your LLM provider API key | | `LLM_MODEL` | Model to use (e.g. `gpt-4o-mini`, `claude-3-haiku-20240307`) | | `VOICE_MODE` | `api` (OpenAI Whisper/TTS) or `browser` (Web Speech API) | diff --git a/backend/app/main.py b/backend/app/main.py index 0fbc309..c6f665f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ """FluentGerman.ai — FastAPI application entry point.""" +import asyncio import logging import time from contextlib import asynccontextmanager @@ -7,7 +8,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from sqlalchemy import select +from sqlalchemy import inspect, select +from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError from app.auth import hash_password from app.config import get_settings @@ -30,11 +32,68 @@ logging.basicConfig( logger = logging.getLogger("fluentgerman") +# A worker that loses the create_all race has to wait for the winner to finish +# creating the remaining tables before it can judge the schema complete. +SCHEMA_RETRIES = 10 +SCHEMA_RETRY_DELAY = 0.5 + + +def _is_table_already_exists(exc: Exception) -> bool: + """True only for the specific DDL error that means another worker won. + + Anything else — bad credentials, missing privileges, an unreachable + server — must not be mistaken for a race. + """ + orig = getattr(exc, "orig", None) + args = getattr(orig, "args", ()) + if args and args[0] == 1050: # MySQL ER_TABLE_EXISTS_ERROR + return True + return "already exists" in str(orig or exc).lower() + + +async def _ensure_schema() -> None: + """Create the tables and refuse to continue on a half-built schema. + + An 'already exists' error aborts create_all part-way, so the losing worker + may still be missing tables the winner has not created yet. Retry rather + than treating that moment as a broken database. + """ + missing: list[str] = [] + + for attempt in range(SCHEMA_RETRIES): + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + except (IntegrityError, OperationalError, ProgrammingError) as exc: + if not _is_table_already_exists(exc): + raise + logger.warning("Schema creation raced with another worker: %s", exc) + + async with engine.connect() as conn: + present = await conn.run_sync(lambda c: set(inspect(c).get_table_names())) + + missing = sorted(set(Base.metadata.tables) - present) + if not missing: + return + + logger.info( + "Waiting for another worker to finish creating %s (attempt %d/%d)", + missing, attempt + 1, SCHEMA_RETRIES, + ) + await asyncio.sleep(SCHEMA_RETRY_DELAY) + + raise RuntimeError(f"Database schema is incomplete — missing tables: {missing}") + + @asynccontextmanager async def lifespan(app: FastAPI): - """Create tables and bootstrap admin user on startup.""" - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + """Create tables and bootstrap admin user on startup. + + uvicorn runs this once per worker and the unit file starts two, so on an + empty database both can reach the same check at the same moment. Both + steps below are written to tolerate losing that race. + """ + await _ensure_schema() # Bootstrap admin if not exists settings = get_settings() @@ -48,8 +107,21 @@ async def lifespan(app: FastAPI): is_admin=True, ) db.add(admin) - await db.commit() - logger.info("Admin user created: %s", settings.admin_username) + try: + await db.commit() + logger.info("Admin user created: %s", settings.admin_username) + except IntegrityError: + # Either another worker inserted the same admin, or the + # configured username/email is taken by a non-admin account. + await db.rollback() + result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712 + if result.scalar_one_or_none() is None: + logger.error( + "Cannot create admin '%s': the username or email already " + "belongs to a non-admin account.", settings.admin_username, + ) + raise + logger.info("Admin user already created by another worker") logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s", settings.llm_provider, settings.llm_model, settings.voice_mode) diff --git a/backend/app/routers/voice.py b/backend/app/routers/voice.py index 2a87bca..e49639e 100644 --- a/backend/app/routers/voice.py +++ b/backend/app/routers/voice.py @@ -7,7 +7,7 @@ from fastapi.responses import Response from app.auth import require_admin, get_current_user from app.config import get_settings from app.models import User -from app.schemas import VoiceConfigOut, VoiceInstructionRequest +from app.schemas import SynthesizeRequest, VoiceConfigOut, VoiceInstructionRequest from app.services.llm_service import summarize_instruction from app.services.voice_service import synthesize, transcribe @@ -48,12 +48,12 @@ async def transcribe_audio( @router.post("/synthesize") async def synthesize_text( - text: str, + body: SynthesizeRequest, user: User = Depends(get_current_user), ): """Convert text to speech audio (API mode only).""" try: - audio_bytes = await synthesize(text) + audio_bytes = await synthesize(body.text) return Response(content=audio_bytes, media_type="audio/mpeg") except Exception as e: logger.error(f"Synthesis failed: {str(e)}", exc_info=True) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0541a59..dbb2ee9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -88,6 +88,10 @@ class VoiceInstructionRequest(BaseModel): raw_text: str +class SynthesizeRequest(BaseModel): + text: str + + class VoiceConfigOut(BaseModel): voice_mode: str # "api" | "browser" voice_api_available: bool = False # True if API STT (Whisper) is configured diff --git a/backend/tests/test_startup.py b/backend/tests/test_startup.py new file mode 100644 index 0000000..f849ce8 --- /dev/null +++ b/backend/tests/test_startup.py @@ -0,0 +1,166 @@ +"""FluentGerman.ai — Startup / lifespan tests. + +The API tests drive the app through httpx's ASGITransport, which does NOT run +lifespan events — so table creation and the admin bootstrap in main.lifespan +were never exercised. The suite could stay green while the app failed to boot. +""" + +import asyncio + +import pytest +from sqlalchemy import func, inspect as sa_inspect, select +from sqlalchemy.exc import IntegrityError, OperationalError + +from app import main +from app.config import get_settings +from app.database import Base +from app.models import User +from tests.conftest import test_engine as engine, test_session as session_factory + + +@pytest.fixture +def startup_app(monkeypatch): + """Point main's module-level engine/session at the test database.""" + monkeypatch.setattr(main, "engine", engine) + monkeypatch.setattr(main, "async_session", session_factory) + return main.app + + +@pytest.mark.asyncio +async def test_lifespan_creates_schema_and_bootstraps_admin(startup_app): + """Starting from an empty database, startup must create tables and an admin.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + async with main.lifespan(startup_app): + pass + + async with session_factory() as db: + result = await db.execute(select(User).where(User.is_admin.is_(True))) + admin = result.scalar_one_or_none() + + assert admin is not None, "startup did not create an admin user" + assert admin.username == get_settings().admin_username + assert admin.hashed_password != get_settings().admin_password, "password stored in clear" + + +@pytest.mark.asyncio +async def test_lifespan_is_idempotent(startup_app): + """A restart must not create a second admin.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + for _ in range(2): + async with main.lifespan(startup_app): + pass + + async with session_factory() as db: + result = await db.execute( + select(func.count()).select_from(User).where(User.is_admin.is_(True)) + ) + assert result.scalar_one() == 1 + + +@pytest.mark.asyncio +async def test_lifespan_survives_concurrent_workers(startup_app): + """deploy/fluentgerman.service starts uvicorn with --workers 2, and each + worker runs this lifespan. On an empty database they race on the same + check-then-insert; neither worker may fail.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + async def boot(): + async with main.lifespan(startup_app): + pass + + await asyncio.gather(boot(), boot()) + + async with session_factory() as db: + result = await db.execute( + select(func.count()).select_from(User).where(User.is_admin.is_(True)) + ) + assert result.scalar_one() == 1, "concurrent startup created a duplicate admin" + + +@pytest.mark.asyncio +async def test_lifespan_reraises_real_ddl_failures(startup_app, monkeypatch): + """Only a genuine 'table already exists' race may be swallowed — a + permission or connection failure has to stop the worker.""" + def explode(*args, **kwargs): + raise OperationalError( + "CREATE TABLE users (...)", {}, + Exception("(1142, \"CREATE command denied to user 'fluentgerman'\")"), + ) + + monkeypatch.setattr(main.Base.metadata, "create_all", explode) + + with pytest.raises(OperationalError): + async with main.lifespan(startup_app): + pass + + +@pytest.mark.asyncio +async def test_lifespan_refuses_to_start_on_incomplete_schema(startup_app, monkeypatch): + """If some tables are missing, starting anyway would leave the app broken + in a way the admin query alone would not notice.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0) + monkeypatch.setattr(main.Base.metadata, "create_all", lambda *a, **k: None) + + with pytest.raises(RuntimeError, match="schema is incomplete"): + async with main.lifespan(startup_app): + pass + + +@pytest.mark.asyncio +async def test_lifespan_waits_out_a_partial_schema_race(startup_app, monkeypatch): + """An 'already exists' error aborts create_all part-way, so the losing + worker can briefly see a schema the winner is still finishing. That is a + moment to retry, not a reason to refuse to start.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + real_create_all = main.Base.metadata.create_all + calls = {"n": 0} + + def racing_create_all(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + # the winner got there first and we bailed out having created nothing + raise OperationalError( + "CREATE TABLE users (...)", {}, + Exception("table 'users' already exists"), + ) + return real_create_all(*args, **kwargs) + + monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0) + monkeypatch.setattr(main.Base.metadata, "create_all", racing_create_all) + + async with main.lifespan(startup_app): + pass + + assert calls["n"] >= 2, "should have retried after the race" + + async with engine.connect() as conn: + present = await conn.run_sync(lambda c: set(sa_inspect(c).get_table_names())) + assert set(Base.metadata.tables) <= present + + +@pytest.mark.asyncio +async def test_lifespan_fails_loudly_when_admin_name_is_taken(startup_app): + """A non-admin already holding the configured username is not a lost race — + starting with no administrator at all would be worse than refusing.""" + async with session_factory() as db: + db.add(User( + username=get_settings().admin_username, + email="someone-else@example.com", + hashed_password="x", + is_admin=False, + )) + await db.commit() + + with pytest.raises(IntegrityError): + async with main.lifespan(startup_app): + pass diff --git a/deploy/fluentgerman.service b/deploy/fluentgerman.service index 74048e8..5d3e162 100644 --- a/deploy/fluentgerman.service +++ b/deploy/fluentgerman.service @@ -1,6 +1,6 @@ [Unit] Description=FluentGerman.ai — Personalized LLM Language Learning -After=network.target postgresql.service +After=network.target mysql.service [Service] Type=simple diff --git a/deploy/setup.sh b/deploy/setup.sh index b28654c..77207dc 100644 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -6,6 +6,14 @@ set -e APP_NAME="fluentgerman" APP_DIR="/opt/$APP_NAME" +# Resolved from this script's location, so the copies below survive the `cd` +# in step 5 and don't depend on where the script was invoked from. +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# A fresh install has no .env yet, so nothing depends on the database password +# and we can generate one. An existing install keeps whatever its .env holds. +FRESH_INSTALL=true +[ -f "$APP_DIR/backend/.env" ] && FRESH_INSTALL=false APP_USER="fluentgerman" DB_NAME="fluentgerman" DB_USER="fluentgerman" @@ -23,18 +31,27 @@ apt-get update -qq apt-get install -y -qq python3 python3-venv python3-pip > /dev/null echo "✓ Python installed" +# Generated here, not earlier: a minimal Debian has no python3 until now +DB_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" + # 3. MySQL database setup echo "Setting up MySQL database..." mysql -u root -e "CREATE DATABASE IF NOT EXISTS $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -mysql -u root -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY 'CHANGE_ME';" +mysql -u root -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD';" +if [ "$FRESH_INSTALL" = true ]; then + # Nothing is using the old password yet — pin it to the one .env will get + mysql -u root -e "ALTER USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASSWORD';" +fi mysql -u root -e "GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost';" mysql -u root -e "FLUSH PRIVILEGES;" echo "✓ MySQL database ready" # 4. Application directory -mkdir -p "$APP_DIR" -cp -r backend/* "$APP_DIR/backend/" -cp -r frontend/* "$APP_DIR/frontend/" +mkdir -p "$APP_DIR/backend" "$APP_DIR/frontend" +cp -r "$REPO_DIR"/backend/* "$APP_DIR/backend/" +# `*` never matches dotfiles, and step 6 needs this one +cp "$REPO_DIR/backend/.env.example" "$APP_DIR/backend/" +cp -r "$REPO_DIR"/frontend/* "$APP_DIR/frontend/" chown -R "$APP_USER:$APP_USER" "$APP_DIR" echo "✓ Files deployed to $APP_DIR" @@ -47,29 +64,46 @@ deactivate echo "✓ Python venv created" # 6. Environment file -if [ ! -f "$APP_DIR/backend/.env" ]; then +if [ "$FRESH_INSTALL" = true ]; then cp "$APP_DIR/backend/.env.example" "$APP_DIR/backend/.env" # Generate random secret key SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(48))") sed -i "s/generate-a-strong-random-key-here/$SECRET/" "$APP_DIR/backend/.env" + # Match the database user created above (token_urlsafe is / and # free) + sed -i "s#://$DB_USER:YOUR_PASSWORD@#://$DB_USER:$DB_PASSWORD@#" "$APP_DIR/backend/.env" + # It holds real credentials now + chown "$APP_USER:$APP_USER" "$APP_DIR/backend/.env" + chmod 600 "$APP_DIR/backend/.env" echo "⚠ Created .env from template — EDIT $APP_DIR/backend/.env with your API keys and passwords!" fi # 7. Systemd service -cp deploy/fluentgerman.service /etc/systemd/system/ +cp "$REPO_DIR/deploy/fluentgerman.service" /etc/systemd/system/ systemctl daemon-reload systemctl enable "$APP_NAME" -systemctl start "$APP_NAME" -echo "✓ Systemd service active" +if [ "$FRESH_INSTALL" = true ]; then + # Don't go live with the template's LLM key and default admin password + echo "✓ Systemd service installed (not started — configure .env first)" +else + systemctl restart "$APP_NAME" + echo "✓ Systemd service restarted" +fi # 8. Nginx config -cp deploy/nginx.conf.example /etc/nginx/sites-available/$APP_NAME +cp "$REPO_DIR/deploy/nginx.conf.example" /etc/nginx/sites-available/$APP_NAME ln -sf /etc/nginx/sites-available/$APP_NAME /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx echo "✓ Nginx configured" echo "" echo "=== Deployment complete! ===" -echo "1. Edit /opt/$APP_NAME/backend/.env with your settings" -echo "2. Restart: systemctl restart $APP_NAME" -echo "3. Access: http://your-server-domain" +if [ "$FRESH_INSTALL" = true ]; then + echo "The database password was generated and written to .env — leave it alone." + echo "1. Edit /opt/$APP_NAME/backend/.env: set LLM_API_KEY and ADMIN_PASSWORD" + echo "2. Start it: systemctl start $APP_NAME" + echo "3. Access: http://your-server-domain" +else + echo "1. Edit /opt/$APP_NAME/backend/.env if anything changed" + echo "2. Restart: systemctl restart $APP_NAME" + echo "3. Access: http://your-server-domain" +fi diff --git a/frontend/admin.html b/frontend/admin.html index d3ad03d..7cd7240 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -54,8 +54,10 @@
- - - + + +