diff --git a/.gitignore b/.gitignore index d1439e3..e1fff35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,30 @@ +# Python .venv/ __pycache__/ +*.py[cod] +*.egg-info/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ + +# Node node_modules/ dist/ -backend/.pepper +coverage/ +*.tsbuildinfo +npm-debug.log* + +# Secrets .env +.env.* +!.env.example +backend/.pepper + +# Editors and OS +.vscode/ +.idea/ +.DS_Store +*.swp + diff --git a/README.md b/README.md index 8b0af9d..ac67293 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,71 @@ -# My Library: Personal Book Collection Tracker +# My Library -Flask + React + PostgreSQL. Users catalog their books, track reading progress, keep a reading journal per -book, and search/filter their library. Each user sees only their own data. +A personal book tracker. You sign up, add the books you own, track how far you are in each one, and keep a +reading journal for every book. You can search your library by title or author and filter it by genre. Each +user sees only their own books. -## Run (CoderPad) +It is a Flask (Python) API with a React (TypeScript) frontend, backed by PostgreSQL. -The pad starts Flask (`backend/app.py`, port 5000) and Vite automatically. The schema is created on startup. -Optionally set `PASSWORD_PEPPER` (≥ 32 chars). Without it, a development pepper is generated into -`backend/.pepper` and a warning is logged. +## What you need -## Run (local) +- [mise](https://mise.jdx.dev) – installs the right versions of Python, Node and the other tools for you +- [Docker](https://www.docker.com) – runs the PostgreSQL database + +## First-time setup ```bash -mise trust && mise install # Python 3.14, Node 24 -docker compose up -d db # postgres:16 on 127.0.0.1:5432 -until docker compose exec -T db pg_isready -U postgres; do sleep 1; done -mise exec -- python -m venv .venv && .venv/bin/pip install -r backend/requirements.txt -mise exec -- npm install -.venv/bin/python backend/app.py # API on :5000 -mise exec -- npm run dev # UI; /api is proxied to Flask +mise trust # allow mise to use this project's mise.toml +mise install # install Python, Node, uv, ruff and biome +mise run setup # create the Python virtual environment and install all dependencies ``` -## Test +## Run the app ```bash -.venv/bin/python -W error -m unittest discover -s backend/tests -t backend -v # real Postgres (books_test); run from repo root -mise exec -- npm test && mise exec -- npm run typecheck +mise run dev ``` -## Design +This starts the database, the API (port 5000) and the web app. Open http://localhost:5173 and create an +account. Press `Ctrl+C` to stop it. -- **Feature slices.** One Flask Blueprint per feature: `auth.py`, `books.py` and `notes.py` each hold their - routes, validation and SQL. `db.py` holds the pool and transactions; `validation.py` holds the shared input rules. -- **Isolation in SQL.** Every query filters on the session's user (`AND user_id = %s`, or joins to `books` - for notes). Another user's book and a missing book return the same 404. -- **Passwords.** Peppered PBKDF2-HMAC-SHA256 (FIPS-approved, stdlib only). See [ADR-0001](docs/adr/0001-password-hashing-pbkdf2.md). -- **Sessions.** Opaque tokens hashed at rest; 30-minute idle and 12-hour absolute expiry; real logout; CSRF - handled by SameSite plus a JSON-only rule. See [ADR-0002](docs/adr/0002-server-side-sessions.md). -- **Data rules in two places.** The API returns specific 400s, and Postgres CHECK and FK constraints - backstop them. -- **Frontend.** React Router (search and filter state lives in the URL), a small auth context, Tailwind. - No state library. +You can also start the parts on their own: `mise run db`, `mise run dev:api` and `mise run dev:web`. -Full spec: [docs/superpowers/specs/2026-10-02-book-tracker-design.md](docs/superpowers/specs/2026-10-02-book-tracker-design.md) +To stop the database when you are done, run `docker compose down`. -## Known trade-offs +## Run the tests -| Not included | Add when | -|---|---| -| Pagination | libraries exceed a few hundred books | -| Trigram/full-text index | ILIKE search slows (thousands of books) | -| Login rate limiting | before production | -| Migrations tool | schema changes after first deploy | -| Pepper rotation | before production key management | +```bash +mise run test # everything +mise run test:py # backend only +mise run test:ts # frontend only, plus the type check +``` -A request that fails with 4xx rolls back its transaction, including the session idle-window slide, so failed -requests don't extend the 30-minute idle timeout. +The backend tests use a real PostgreSQL database called `books_test`. It is created for you, so the database +from `mise run db` just needs to be running (the test task starts it). + +## Lint and format + +```bash +mise run format # fix formatting and import order (writes changes) +mise run lint # check linting, formatting and types without changing files +``` + +Run `mise run lint` and `mise run test` before you push. + +## Change backend dependencies + +Edit `backend/requirements.in`, then run: + +```bash +mise run lock # rebuild the pinned backend/requirements.txt +mise run setup # install the new versions +``` + +## Settings + +- `DATABASE_URL` – the PostgreSQL connection string. It defaults to the local Docker database. +- `PASSWORD_PEPPER` – a secret of at least 32 characters used when storing passwords. If you do not set it, + a development value is created in `backend/.pepper` and a warning is logged. Always set it outside local + development. + +Run `mise tasks` to see every available task. diff --git a/backend/app.py b/backend/app.py index a6bef8a..9f462b4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,7 +3,8 @@ import logging import psycopg2.errors -from flask import Flask, g, jsonify, request +from flask import Flask, Response, g, jsonify, request +from flask.typing import ResponseReturnValue from werkzeug.exceptions import HTTPException import auth @@ -28,18 +29,19 @@ def create_app() -> Flask: app.register_blueprint(notes.bp) @app.get("/api/health") - def health(): + @auth.allow_anonymous + def health() -> ResponseReturnValue: return {"status": "ok"} @app.before_request - def require_json_for_mutations(): + def require_json_for_mutations() -> None: if request.method in MUTATING_METHODS and not request.is_json: raise ApiError( 400, "Request body must be JSON with Content-Type: application/json" ) @app.after_request - def log_request(response): + def log_request(response: Response) -> Response: log.info( "%s %s -> %s user=%s", request.method, @@ -50,18 +52,20 @@ def create_app() -> Flask: return response @app.errorhandler(ApiError) - def handle_api_error(error: ApiError): + def handle_api_error(error: ApiError) -> ResponseReturnValue: body = {"error": error.message} if error.field: body["field"] = error.field return jsonify(body), error.status @app.errorhandler(HTTPException) - def handle_http_error(error: HTTPException): - return jsonify(error=error.description), error.code + def handle_http_error(error: HTTPException) -> ResponseReturnValue: + return jsonify(error=error.description), error.code or 500 @app.errorhandler(psycopg2.errors.CheckViolation) - def handle_check_violation(error: psycopg2.errors.CheckViolation): + def handle_check_violation( + error: psycopg2.errors.CheckViolation, + ) -> ResponseReturnValue: return ( jsonify( error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry" @@ -70,14 +74,12 @@ def create_app() -> Flask: ) @app.errorhandler(Exception) - def handle_unexpected(_error: Exception): + def handle_unexpected(_error: Exception) -> ResponseReturnValue: log.exception("Unhandled error on %s %s", request.method, request.path) return jsonify(error="Unexpected server error"), 500 return app -app = create_app() - if __name__ == "__main__": - app.run(host="127.0.0.1", port=5000) + create_app().run(host="127.0.0.1", port=5000) diff --git a/backend/auth.py b/backend/auth.py index 0c62fdc..aae45b4 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1,135 +1,90 @@ -"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes.""" +"""Auth slice: server-side sessions (ADR-0002) and auth routes.""" -import base64 -import functools import hashlib -import hmac -import logging -import os import re import secrets +from collections.abc import Callable from pathlib import Path +from typing import ParamSpec, TypedDict, TypeVar, cast import psycopg2.errors -from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request +from flask import ( + Blueprint, + Flask, + Response, + current_app, + g, + jsonify, + make_response, + request, +) -from db import query, query_one -from validation import ApiError, json_body, require_utf8 +from db import query, query_one, query_row +from passwords import ( + dummy_hash, + hash_password, + load_pepper, + needs_rehash, + verify_password, +) +from validation import ApiError, JsonObject, json_body, require_utf8 -log = logging.getLogger(__name__) bp = Blueprint("auth", __name__, url_prefix="/api/auth") -ITERATIONS = 600_000 -SALT_BYTES = 16 -MIN_PEPPER_CHARS = 32 SESSION_COOKIE = "sid" USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}") MIN_PASSWORD, MAX_PASSWORD = 12, 1024 INVALID_LOGIN = "Invalid username or password" +P = ParamSpec("P") +R = TypeVar("R") + + +class UserRow(TypedDict): + id: int + username: str + + +class UserWithHash(UserRow): + password_hash: str + def init_app(app: Flask) -> None: app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper") - _dummy_hash() + dummy_hash() + app.before_request(_require_session) app.register_blueprint(bp) -# --- Passwords ----------------------------------------------------------------- - - -def hash_password( - password: str, - pepper: bytes, - *, - salt: bytes | None = None, - iterations: int = ITERATIONS, -) -> str: - salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt - derived = _derive(password, pepper, salt, iterations) - return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}" - - -def verify_password(password: str, stored: str, pepper: bytes) -> bool: - _, iterations, salt, expected = stored.split("$") - derived = _derive(password, pepper, base64.b64decode(salt), int(iterations)) - return hmac.compare_digest(derived, base64.b64decode(expected)) - - -def needs_rehash(stored: str) -> bool: - return int(stored.split("$")[1]) < ITERATIONS - - -def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes: - peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest() - return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations) - - -def _b64(raw: bytes) -> str: - return base64.b64encode(raw).decode() - - -@functools.cache -def _dummy_hash() -> str: - # Verified against for unknown usernames so their response time matches a real account. - return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS) - - -def load_pepper(pepper_file: Path) -> bytes: - from_env = os.environ.get("PASSWORD_PEPPER") - if from_env is not None: - if len(from_env) < MIN_PEPPER_CHARS: - raise RuntimeError( - f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. " - 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' - ) - return from_env.encode() - try: - fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError: - pass - else: - with os.fdopen(fd, "w") as f: - f.write(secrets.token_hex(32)) - log.warning( - "PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper " - "from a secrets manager.", - pepper_file, - ) - pepper = pepper_file.read_text().strip() - if len(pepper) < MIN_PEPPER_CHARS: - raise RuntimeError( - f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete " - "the file to regenerate it (existing passwords will stop verifying)." - ) - return pepper.encode() - - # --- Sessions ------------------------------------------------------------------ -def login_required(view): - @functools.wraps(view) - def wrapper(*args, **kwargs): - token = request.cookies.get(SESSION_COOKIE) - row = None - if token: - row = query_one( - """ - UPDATE sessions - SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours') - WHERE token_hash = %s - AND expires_at > now() - AND created_at > now() - interval '12 hours' - RETURNING user_id - """, - (_token_hash(token),), - ) - if row is None: - raise ApiError(401, "Not signed in or session expired; log in again") - g.user_id = row["user_id"] - return view(*args, **kwargs) +def allow_anonymous(view: Callable[P, R]) -> Callable[P, R]: + view.allow_anonymous = True # type: ignore[attr-defined] + return view - return wrapper + +def _require_session() -> None: + view = current_app.view_functions.get(request.endpoint or "") + if view is None or getattr(view, "allow_anonymous", False): + return + token = request.cookies.get(SESSION_COOKIE) + row = None + if token: + row = query_one( + """ + UPDATE sessions + SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours') + WHERE token_hash = %s + AND expires_at > now() + AND created_at > now() - interval '12 hours' + RETURNING user_id + """, + (_token_hash(token),), + ) + if row is None: + raise ApiError(401, "Not signed in or session expired; log in again") + g.user_id = row["user_id"] def _token_hash(token: str) -> bytes: @@ -137,10 +92,11 @@ def _token_hash(token: str) -> bytes: def _pepper() -> bytes: - return current_app.extensions["password_pepper"] + pepper: bytes = current_app.extensions["password_pepper"] + return pepper -def _start_session(user: dict, status: int): +def _start_session(user: UserRow, status: int) -> Response: token = secrets.token_urlsafe(32) query( "DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],) @@ -160,7 +116,7 @@ def _start_session(user: dict, status: int): # --- Routes ---------------------------------------------------------------------- -def _password(body: dict) -> str: +def _password(body: JsonObject) -> str: password = body.get("password") if not isinstance(password, str): raise ApiError(400, "password is required and must be a string", "password") @@ -173,7 +129,8 @@ def _password(body: dict) -> str: @bp.post("/register") -def register(): +@allow_anonymous +def register() -> Response: body = json_body({"username", "password"}) username = body.get("username") if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username): @@ -188,7 +145,7 @@ def register(): 400, f"password must be at least {MIN_PASSWORD} characters", "password" ) try: - user = query_one( + user = query_row( "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", (username, hash_password(password, _pepper())), ) @@ -196,22 +153,26 @@ def register(): raise ApiError( 409, "Username already taken; choose another", "username" ) from None - return _start_session(user, 201) + return _start_session(cast(UserRow, user), 201) @bp.post("/login") -def login(): +@allow_anonymous +def login() -> Response: body = json_body({"username", "password"}) password = _password(body) username = body.get("username") user = None if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username): - user = query_one( - "SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)", - (username,), + user = cast( + UserWithHash | None, + query_one( + "SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)", + (username,), + ), ) if user is None: - verify_password(password, _dummy_hash(), _pepper()) + verify_password(password, dummy_hash(), _pepper()) raise ApiError(401, INVALID_LOGIN) if not verify_password(password, user["password_hash"], _pepper()): raise ApiError(401, INVALID_LOGIN) @@ -224,7 +185,8 @@ def login(): @bp.post("/logout") -def logout(): +@allow_anonymous +def logout() -> Response: token = request.cookies.get(SESSION_COOKIE) if token: query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),)) @@ -236,8 +198,7 @@ def logout(): @bp.get("/me") -@login_required -def me(): +def me() -> Response: return jsonify( query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,)) ) diff --git a/backend/books.py b/backend/books.py index d3962f1..d966611 100644 --- a/backend/books.py +++ b/backend/books.py @@ -1,13 +1,16 @@ """Books slice: CRUD, progress, search/filter, and the genre list.""" import re +from collections.abc import Callable +from datetime import datetime +from typing import TypedDict, cast import psycopg2.errors -from flask import Blueprint, g, jsonify, request +from flask import Blueprint, Response, g, jsonify, request +from flask.typing import ResponseReturnValue -from auth import login_required -from db import query, query_one -from validation import ApiError, integer, json_body, text +from db import Params, Row, query, query_one, query_row +from validation import ApiError, JsonObject, integer, json_body, text bp = Blueprint("books", __name__, url_prefix="/api") @@ -17,7 +20,7 @@ MAX_GENRE_ID = 32_767 GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}") REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages") FIELDS = set(REQUIRED_FIELDS) | {"current_page"} -VALIDATORS = { +VALIDATORS: dict[str, Callable[[JsonObject], str | int]] = { "title": lambda body: text(body, "title", MAX_TEXT), "author": lambda body: text(body, "author", MAX_TEXT), "genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID), @@ -25,6 +28,22 @@ VALIDATORS = { "current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES), } + +class BookFields(TypedDict): + title: str + author: str + genre_id: int + total_pages: int + current_page: int + + +class BookRow(BookFields): + id: int + genre_name: str + created_at: datetime + updated_at: datetime + + BOOK_SELECT = """ SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name, b.total_pages, b.current_page, b.created_at, b.updated_at @@ -41,7 +60,7 @@ def reading_status(current_page: int, total_pages: int) -> str: return "reading" -def to_json(row: dict) -> dict: +def to_json(row: BookRow) -> dict[str, object]: return { "id": row["id"], "title": row["title"], @@ -59,16 +78,19 @@ def escape_like(term: str) -> str: return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") -def owned_book(book_id: int) -> dict: +def owned_book(book_id: int, for_update: bool = False) -> BookRow: row = query_one( - BOOK_SELECT + " WHERE b.id = %s AND b.user_id = %s", (book_id, g.user_id) + BOOK_SELECT + + " WHERE b.id = %s AND b.user_id = %s" + + (" FOR UPDATE OF b" if for_update else ""), + (book_id, g.user_id), ) if row is None: raise ApiError(404, "Book not found") - return row + return cast(BookRow, row) -def _check_progress(book: dict, field: str) -> None: +def _check_progress(book: BookFields, field: str) -> None: if book["current_page"] > book["total_pages"]: raise ApiError( 400, @@ -78,9 +100,9 @@ def _check_progress(book: dict, field: str) -> None: ) -def _save(sql: str, params: tuple) -> dict | None: +def _save(sql: str, params: Params) -> Row: try: - return query_one(sql, params) + return query_row(sql, params) except psycopg2.errors.ForeignKeyViolation: raise ApiError( 400, "Unknown genre_id; see GET /api/genres", "genre_id" @@ -88,16 +110,14 @@ def _save(sql: str, params: tuple) -> dict | None: @bp.get("/genres") -@login_required -def list_genres(): +def list_genres() -> Response: return jsonify(query("SELECT id, name FROM genres ORDER BY name")) @bp.get("/books") -@login_required -def list_books(): +def list_books() -> Response: sql = BOOK_SELECT + " WHERE b.user_id = %s" - params: list = [g.user_id] + params: list[object] = [g.user_id] search = request.args.get("q", "").strip() if search: if len(search) > MAX_TEXT or "\x00" in search: @@ -116,16 +136,20 @@ def list_books(): sql += " AND b.genre_id = %s" params.append(int(genre_id)) sql += " ORDER BY b.updated_at DESC, b.id DESC" - return jsonify([to_json(row) for row in query(sql, tuple(params))]) + return jsonify([to_json(cast(BookRow, row)) for row in query(sql, tuple(params))]) @bp.post("/books") -@login_required -def create_book(): +def create_book() -> ResponseReturnValue: body = json_body(FIELDS) - book = {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS} - book["current_page"] = ( - VALIDATORS["current_page"](body) if "current_page" in body else 0 + book = cast( + BookFields, + {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS} + | { + "current_page": ( + VALIDATORS["current_page"](body) if "current_page" in body else 0 + ) + }, ) _check_progress(book, "current_page") row = _save( @@ -147,27 +171,28 @@ def create_book(): @bp.get("/books/") -@login_required -def get_book(book_id: int): +def get_book(book_id: int) -> Response: return jsonify(to_json(owned_book(book_id))) @bp.patch("/books/") -@login_required -def update_book(book_id: int): +def update_book(book_id: int) -> Response: body = json_body(FIELDS) if not body: raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}") - current = owned_book(book_id) - book = {field: current[field] for field in FIELDS} | { - field: VALIDATORS[field](body) for field in body - } + current: Row = dict(owned_book(book_id, for_update=True)) + book = cast( + BookFields, + {field: current[field] for field in FIELDS} + | {field: VALIDATORS[field](body) for field in body}, + ) _check_progress(book, "total_pages" if "total_pages" in body else "current_page") _save( """ UPDATE books SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now() WHERE id = %s AND user_id = %s + RETURNING id """, ( book["title"], @@ -183,8 +208,7 @@ def update_book(book_id: int): @bp.delete("/books/") -@login_required -def delete_book(book_id: int): +def delete_book(book_id: int) -> ResponseReturnValue: if ( query_one( "DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id", diff --git a/backend/db.py b/backend/db.py index 20e7276..80cf808 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,15 +1,24 @@ -"""PostgreSQL access: a connection pool, one connection per request, and two query helpers.""" +"""PostgreSQL access: a connection pool, one connection per request, and query helpers.""" +import logging import os +from collections.abc import Sequence from pathlib import Path +from typing import Any +import psycopg2.extensions import psycopg2.extras import psycopg2.pool -from flask import Flask, current_app, g +from flask import Flask, Response, current_app, g + +log = logging.getLogger(__name__) DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@127.0.0.1:5432/postgres" SCHEMA = Path(__file__).with_name("schema.sql") +Row = dict[str, Any] +Params = tuple[object, ...] + def init_app(app: Flask) -> None: pool = psycopg2.pool.ThreadedConnectionPool( @@ -26,24 +35,34 @@ def init_app(app: Flask) -> None: app.teardown_appcontext(_release_connection) -def query(sql: str, params: tuple = ()) -> list[dict]: +def query(sql: str, params: Params = ()) -> Sequence[Row]: with _connection().cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute(sql, params) return cur.fetchall() if cur.description else [] -def query_one(sql: str, params: tuple = ()) -> dict | None: +def query_one(sql: str, params: Params = ()) -> Row | None: rows = query(sql, params) return rows[0] if rows else None -def _connection(): +def query_row(sql: str, params: Params = ()) -> Row: + rows = query(sql, params) + if len(rows) != 1: + raise RuntimeError( + f"Expected exactly one row but got {len(rows)}; the statement must return one row: {sql.strip()}" + ) + return rows[0] + + +def _connection() -> psycopg2.extensions.connection: if "db" not in g: g.db = current_app.extensions["db_pool"].getconn() - return g.db + conn: psycopg2.extensions.connection = g.db + return conn -def _finish_transaction(response): +def _finish_transaction(response: Response) -> Response: conn = g.get("db") if conn is not None: if response.status_code < 400: @@ -53,8 +72,18 @@ def _finish_transaction(response): return response -def _release_connection(_exc): +def _release_connection(_exc: BaseException | None) -> None: conn = g.pop("db", None) - if conn is not None: + if conn is None: + return + pool = current_app.extensions["db_pool"] + try: conn.rollback() - current_app.extensions["db_pool"].putconn(conn) + except psycopg2.Error: + log.warning( + "Discarding a database connection that failed to roll back; the pool will open a new one", + exc_info=True, + ) + pool.putconn(conn, close=True) + else: + pool.putconn(conn) diff --git a/backend/notes.py b/backend/notes.py index d331d8d..7bfe999 100644 --- a/backend/notes.py +++ b/backend/notes.py @@ -1,9 +1,12 @@ """Notes slice: a per-book reading journal. Ownership is always derived through books.user_id.""" -from flask import Blueprint, g, jsonify +from datetime import datetime +from typing import TypedDict, cast -from auth import login_required -from db import query, query_one +from flask import Blueprint, Response, g, jsonify +from flask.typing import ResponseReturnValue + +from db import Row, query, query_one, query_row from validation import ApiError, json_body, text bp = Blueprint("notes", __name__, url_prefix="/api") @@ -12,13 +15,22 @@ MAX_BODY = 10_000 NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at" -def to_json(row: dict) -> dict: +class NoteRow(TypedDict): + id: int + book_id: int + body: str + created_at: datetime + updated_at: datetime + + +def to_json(row: Row) -> dict[str, object]: + note = cast(NoteRow, row) return { - "id": row["id"], - "book_id": row["book_id"], - "body": row["body"], - "created_at": row["created_at"].isoformat(), - "updated_at": row["updated_at"].isoformat(), + "id": note["id"], + "book_id": note["book_id"], + "body": note["body"], + "created_at": note["created_at"].isoformat(), + "updated_at": note["updated_at"].isoformat(), } @@ -33,8 +45,7 @@ def _require_book(book_id: int) -> None: @bp.get("/books//notes") -@login_required -def list_notes(book_id: int): +def list_notes(book_id: int) -> Response: _require_book(book_id) rows = query( f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC", @@ -44,11 +55,10 @@ def list_notes(book_id: int): @bp.post("/books//notes") -@login_required -def add_note(book_id: int): +def add_note(book_id: int) -> ResponseReturnValue: _require_book(book_id) body = text(json_body({"body"}), "body", MAX_BODY) - row = query_one( + row = query_row( f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}", (book_id, body), ) @@ -56,8 +66,7 @@ def add_note(book_id: int): @bp.patch("/notes/") -@login_required -def update_note(note_id: int): +def update_note(note_id: int) -> Response: body = text(json_body({"body"}), "body", MAX_BODY) row = query_one( f""" @@ -75,8 +84,7 @@ def update_note(note_id: int): @bp.delete("/notes/") -@login_required -def delete_note(note_id: int): +def delete_note(note_id: int) -> ResponseReturnValue: row = query_one( "DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id", (note_id, g.user_id), diff --git a/backend/passwords.py b/backend/passwords.py new file mode 100644 index 0000000..e6366ec --- /dev/null +++ b/backend/passwords.py @@ -0,0 +1,83 @@ +"""Password hashing (ADR-0001): PBKDF2-SHA256 over an HMAC pepper, plus loading that pepper.""" + +import base64 +import functools +import hashlib +import hmac +import logging +import os +import secrets +from pathlib import Path + +log = logging.getLogger(__name__) + +ITERATIONS = 600_000 +SALT_BYTES = 16 +MIN_PEPPER_CHARS = 32 + + +def hash_password( + password: str, + pepper: bytes, + *, + salt: bytes | None = None, + iterations: int = ITERATIONS, +) -> str: + salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt + derived = _derive(password, pepper, salt, iterations) + return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}" + + +def verify_password(password: str, stored: str, pepper: bytes) -> bool: + _, iterations, salt, expected = stored.split("$") + derived = _derive(password, pepper, base64.b64decode(salt), int(iterations)) + return hmac.compare_digest(derived, base64.b64decode(expected)) + + +def needs_rehash(stored: str) -> bool: + return int(stored.split("$")[1]) < ITERATIONS + + +def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes: + peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest() + return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations) + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode() + + +@functools.cache +def dummy_hash() -> str: + # Verified against for unknown usernames so their response time matches a real account. + return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS) + + +def load_pepper(pepper_file: Path) -> bytes: + from_env = os.environ.get("PASSWORD_PEPPER") + if from_env is not None: + if len(from_env) < MIN_PEPPER_CHARS: + raise RuntimeError( + f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + return from_env.encode() + try: + fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + pass + else: + with os.fdopen(fd, "w") as f: + f.write(secrets.token_hex(32)) + log.warning( + "PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper " + "from a secrets manager.", + pepper_file, + ) + pepper = pepper_file.read_text().strip() + if len(pepper) < MIN_PEPPER_CHARS: + raise RuntimeError( + f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete " + "the file to regenerate it (existing passwords will stop verifying)." + ) + return pepper.encode() diff --git a/backend/requirements.in b/backend/requirements.in new file mode 100644 index 0000000..942a547 --- /dev/null +++ b/backend/requirements.in @@ -0,0 +1,2 @@ +Flask>=3.1.3 +psycopg2-binary>=2.9.9 diff --git a/backend/requirements.txt b/backend/requirements.txt index 909efd1..63678d8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,2 +1,189 @@ -Flask>=3.0.0 -psycopg2-binary>=2.9.9 +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --generate-hashes backend/requirements.in -o backend/requirements.txt +blinker==1.9.0 \ + --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ + --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc + # via flask +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via flask +flask==3.1.3 \ + --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ + --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c + # via -r backend/requirements.in +itsdangerous==2.2.0 \ + --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ + --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 + # via flask +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via flask +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via + # flask + # jinja2 + # werkzeug +psycopg2-binary==2.9.13 \ + --hash=sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528 \ + --hash=sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce \ + --hash=sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf \ + --hash=sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6 \ + --hash=sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf \ + --hash=sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964 \ + --hash=sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5 \ + --hash=sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c \ + --hash=sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba \ + --hash=sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076 \ + --hash=sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705 \ + --hash=sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e \ + --hash=sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed \ + --hash=sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24 \ + --hash=sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2 \ + --hash=sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26 \ + --hash=sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798 \ + --hash=sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98 \ + --hash=sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0 \ + --hash=sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165 \ + --hash=sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008 \ + --hash=sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50 \ + --hash=sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29 \ + --hash=sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a \ + --hash=sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b \ + --hash=sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69 \ + --hash=sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955 \ + --hash=sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720 \ + --hash=sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c \ + --hash=sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c \ + --hash=sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff \ + --hash=sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916 \ + --hash=sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8 \ + --hash=sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3 \ + --hash=sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f \ + --hash=sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22 \ + --hash=sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8 \ + --hash=sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6 \ + --hash=sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b \ + --hash=sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020 \ + --hash=sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7 \ + --hash=sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2 \ + --hash=sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7 \ + --hash=sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac \ + --hash=sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b \ + --hash=sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292 \ + --hash=sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2 \ + --hash=sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c \ + --hash=sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22 \ + --hash=sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b \ + --hash=sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9 \ + --hash=sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2 \ + --hash=sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0 \ + --hash=sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf \ + --hash=sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9 \ + --hash=sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1 \ + --hash=sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8 \ + --hash=sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373 \ + --hash=sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60 \ + --hash=sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e \ + --hash=sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6 \ + --hash=sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee \ + --hash=sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0 \ + --hash=sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8 \ + --hash=sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666 \ + --hash=sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1 \ + --hash=sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389 + # via -r backend/requirements.in +werkzeug==3.1.9 \ + --hash=sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060 \ + --hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab + # via flask diff --git a/backend/tests/support.py b/backend/tests/support.py index 43c401f..a0e523c 100644 --- a/backend/tests/support.py +++ b/backend/tests/support.py @@ -3,9 +3,10 @@ import unittest import psycopg2 -from app import app +from app import create_app HTTPS = "https://localhost" +app = create_app() def db_execute(sql: str, params: tuple = ()) -> list[tuple]: diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index e91ba8f..37a7ef6 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -1,6 +1,10 @@ +import os +import subprocess +import sys from pathlib import Path -from tests.support import HTTPS, ApiTestCase, db_execute +import db +from tests.support import HTTPS, ApiTestCase, app, db_execute SCHEMA = Path(__file__).parent.parent / "schema.sql" @@ -43,3 +47,29 @@ class AppTests(ApiTestCase): ) self.assertEqual(response.status_code, 413) self.assertIn("error", response.get_json()) + + +class ConnectionReleaseTests(ApiTestCase): + def test_closed_connection_is_discarded_instead_of_leaking_a_pool_slot(self): + pool = app.extensions["db_pool"] + with self.assertLogs("db", "WARNING"): + for _ in range(pool.maxconn + 1): + with app.app_context(): + db.query("SELECT 1") + db._connection().close() + with app.app_context(): + self.assertEqual(db.query("SELECT 1 AS one"), [{"one": 1}]) + + +class AppFactoryTests(ApiTestCase): + def test_importing_the_app_module_does_not_connect_to_the_database(self): + result = subprocess.run( + [sys.executable, "-c", "import app"], + cwd=Path(__file__).parent.parent, + env=os.environ | {"DATABASE_URL": "postgresql://nobody@127.0.0.1:9/none"}, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e24b39c..b4989e7 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,9 +1,10 @@ import hashlib import os +import re -from app import app -from auth import hash_password -from tests.support import ApiTestCase, db_execute +from app import create_app +from passwords import hash_password +from tests.support import ApiTestCase, app, db_execute class AuthTests(ApiTestCase): @@ -156,3 +157,30 @@ class AuthTests(ApiTestCase): "pbkdf2_sha256$600000$" ) ) + + +class AuthByDefaultTests(ApiTestCase): + PUBLIC = frozenset({"health", "auth.register", "auth.login", "auth.logout"}) + + def test_only_the_expected_endpoints_allow_anonymous_access(self): + public = { + endpoint + for endpoint, view in app.view_functions.items() + if getattr(view, "allow_anonymous", False) + } + self.assertEqual(public, self.PUBLIC) + + def test_every_other_route_rejects_anonymous_requests(self): + for rule in app.url_map.iter_rules(): + if rule.endpoint in self.PUBLIC or rule.endpoint == "static": + continue + path = re.sub(r"<[^>]+>", "1", rule.rule) + for method in rule.methods - {"HEAD", "OPTIONS"}: + with self.subTest(method=method, path=path): + self.assertEqual(self.call(method, path).status_code, 401) + + def test_a_new_route_requires_login_without_any_decorator(self): + fresh = create_app() + fresh.add_url_rule("/api/new-thing", "new_thing", lambda: {"ok": True}) + response = self.call("GET", "/api/new-thing", client=fresh.test_client()) + self.assertEqual(response.status_code, 401) diff --git a/backend/tests/test_books.py b/backend/tests/test_books.py index dce6eac..26f6a11 100644 --- a/backend/tests/test_books.py +++ b/backend/tests/test_books.py @@ -1,4 +1,10 @@ -from tests.support import ApiTestCase +import os +import threading +import time + +import psycopg2 + +from tests.support import ApiTestCase, db_execute class BookTests(ApiTestCase): @@ -147,3 +153,50 @@ class BookIsolationTests(ApiTestCase): self.assertEqual(response.get_json(), {"error": "Book not found"}) self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), []) self.assertEqual(self.call("GET", path).get_json()["title"], "Dune") + + +class ConcurrentUpdateTests(ApiTestCase): + def setUp(self): + super().setUp() + self.register() + + def wait_for_lock_wait(self): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + waiting = db_execute( + "SELECT count(*) FROM pg_stat_activity" + " WHERE datname = current_database() AND wait_event_type = 'Lock'" + )[0][0] + if waiting: + return + time.sleep(0.02) + self.fail("PATCH never waited on the locked book row") + + def test_concurrent_updates_to_different_fields_both_persist(self): + book = self.create_book() + other_session = psycopg2.connect(os.environ["DATABASE_URL"]) + result = {} + try: + with other_session.cursor() as cur: + cur.execute( + "UPDATE books SET title = 'Dune Messiah' WHERE id = %s", + (book["id"],), + ) + patch = threading.Thread( + target=lambda: result.update( + response=self.call( + "PATCH", f"/api/books/{book['id']}", {"current_page": 100} + ) + ) + ) + patch.start() + self.wait_for_lock_wait() + other_session.commit() + patch.join(timeout=10) + finally: + other_session.close() + + self.assertFalse(patch.is_alive(), "PATCH did not finish") + self.assertEqual(result["response"].status_code, 200) + saved = self.call("GET", f"/api/books/{book['id']}").get_json() + self.assertEqual((saved["title"], saved["current_page"]), ("Dune Messiah", 100)) diff --git a/backend/tests/test_passwords.py b/backend/tests/test_passwords.py index 2dda93c..c0f1a3c 100644 --- a/backend/tests/test_passwords.py +++ b/backend/tests/test_passwords.py @@ -5,7 +5,13 @@ import unittest from pathlib import Path from unittest import mock -from auth import ITERATIONS, hash_password, load_pepper, needs_rehash, verify_password +from passwords import ( + ITERATIONS, + hash_password, + load_pepper, + needs_rehash, + verify_password, +) PEPPER = b"p" * 32 KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE=" @@ -54,9 +60,9 @@ class PepperTests(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ): os.environ.pop("PASSWORD_PEPPER", None) pepper_file = Path(tmp) / ".pepper" - with self.assertLogs("auth", level="WARNING"): + with self.assertLogs("passwords", level="WARNING"): first = load_pepper(pepper_file) - with self.assertLogs("auth", level="WARNING"): + with self.assertLogs("passwords", level="WARNING"): second = load_pepper(pepper_file) self.assertEqual(first, second) self.assertEqual(len(first), 64) diff --git a/backend/validation.py b/backend/validation.py index ee156c6..0ff6f0b 100644 --- a/backend/validation.py +++ b/backend/validation.py @@ -1,7 +1,11 @@ """Request validation shared by every slice. Errors carry a status, a fix-it message, and the field.""" +from typing import Any + from flask import request +JsonObject = dict[str, Any] + class ApiError(Exception): def __init__(self, status: int, message: str, field: str | None = None): @@ -11,7 +15,7 @@ class ApiError(Exception): self.field = field -def json_body(allowed: set[str]) -> dict: +def json_body(allowed: set[str]) -> JsonObject: body = request.get_json(silent=True) if not isinstance(body, dict): raise ApiError(400, "Request body must be a JSON object") @@ -36,7 +40,7 @@ def require_utf8(value: str, field: str) -> None: ) from None -def text(body: dict, field: str, max_len: int) -> str: +def text(body: JsonObject, field: str, max_len: int) -> str: value = body.get(field) if not isinstance(value, str) or not value.strip(): raise ApiError(400, f"{field} is required and must be non-blank text", field) @@ -49,7 +53,7 @@ def text(body: dict, field: str, max_len: int) -> str: return value -def integer(body: dict, field: str, low: int, high: int) -> int: +def integer(body: JsonObject, field: str, low: int, high: int) -> int: value = body.get(field) if isinstance(value, bool) or not isinstance(value, int): raise ApiError(400, f"{field} must be a whole number", field) diff --git a/bin/start b/bin/start new file mode 100755 index 0000000..4bb7c0c --- /dev/null +++ b/bin/start @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +cd "$(dirname "$0")/.." + +# The pad template's node_modules lacks this app's extra packages (router, Tailwind); a no-op once installed. +npm install --no-audit --no-fund + +# Locally the deps live in the mise venv; the pad's system python3 has Flask and psycopg2 preinstalled. +PYTHON=python3 +[ -x .venv/bin/python ] && PYTHON=.venv/bin/python + +"$PYTHON" backend/app.py & +FLASK_PID=$! + +until curl -s -o /dev/null http://localhost:5000/api/health; do + if ! kill -0 "$FLASK_PID" 2>/dev/null; then + echo "Flask exited during startup; fix the error above (often a missing dependency or an unreachable PostgreSQL) and restart." >&2 + exit 1 + fi + sleep 0.5 +done + +exec node_modules/.bin/vite diff --git a/biome.json b/biome.json index ad5c11b..b9317a4 100644 --- a/biome.json +++ b/biome.json @@ -2,5 +2,20 @@ "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", "formatter": { "indentStyle": "space" + }, + "linter": { + "rules": { + "correctness": { + "useExhaustiveDependencies": { + "level": "error", + "options": { "reportUnnecessaryDependencies": false } + } + } + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } } } diff --git a/index.html b/index.html index 3b1ad9a..80dd2e8 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,12 @@ My Library +
diff --git a/mise.toml b/mise.toml index b7d9f00..1127b59 100644 --- a/mise.toml +++ b/mise.toml @@ -3,6 +3,11 @@ python = "3.14" node = "24" ruff = "0.16.10" biome = "2.5.15" +uv = "0.12.21" + +[tasks.lock] +description = "Re-resolve backend/requirements.in into the pinned, hashed backend/requirements.txt" +run = "uv pip compile --quiet --universal --generate-hashes backend/requirements.in -o backend/requirements.txt" [tasks.setup] description = "Create the venv and install backend and frontend dependencies" @@ -46,8 +51,12 @@ description = "Format and auto-fix Python and TypeScript" depends = ["format:py", "format:ts"] [tasks."lint:py"] -description = "Lint the backend and verify formatting (no writes)" -run = ["ruff check backend", "ruff format --check backend"] +description = "Lint, type-check and verify formatting of the backend (no writes)" +run = [ + "ruff check backend", + "ruff format --check backend", + "uvx --quiet --with-requirements backend/requirements.txt --with types-psycopg2==2.9.21.20260911 mypy@2.4.0 --strict backend --exclude backend/tests", +] [tasks."lint:ts"] description = "Lint the frontend and verify formatting (no writes)" @@ -56,3 +65,17 @@ run = "biome check src vite.config.ts" [tasks.lint] description = "Lint and format-check Python and TypeScript (CI-safe, no writes)" depends = ["lint:py", "lint:ts"] + +[tasks."test:py"] +description = "Backend integration tests against real Postgres (books_test)" +depends = ["setup", "db"] +run = ".venv/bin/python -W error -m unittest discover -s backend/tests -t backend" + +[tasks."test:ts"] +description = "Frontend component tests and typecheck" +depends = ["setup"] +run = ["npm test", "npm run typecheck"] + +[tasks.test] +description = "Run backend and frontend tests" +depends = ["test:py", "test:ts"] diff --git a/package-lock.json b/package-lock.json index 957034d..800b415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "flask-react-template", "version": "0.0.0", "dependencies": { + "@fontsource-variable/newsreader": "^5.3.0", + "@fontsource/atkinson-hyperlegible": "^5.3.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.18.4" @@ -990,6 +992,24 @@ } } }, + "node_modules/@fontsource-variable/newsreader": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/newsreader/-/newsreader-5.3.0.tgz", + "integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/atkinson-hyperlegible": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz", + "integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 877c47e..b9f6d5d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "test": "vitest run" }, "dependencies": { + "@fontsource-variable/newsreader": "^5.3.0", + "@fontsource/atkinson-hyperlegible": "^5.3.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.18.4" diff --git a/ruff.toml b/ruff.toml index b0f8550..7781aab 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,4 +1 @@ -# The backend imports its modules as top-level names (`import db`). Without this, ruff's -# first-party detection matches the frontend's src/auth, src/books, src/notes directories -# instead, and splits backend imports into two groups. src = ["backend"] diff --git a/src/App.tsx b/src/App.tsx index 4141979..b21a786 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { BrowserRouter, Link, @@ -6,15 +6,18 @@ import { Outlet, Route, Routes, + useParams, } from "react-router"; import { errorMessage } from "./api"; import { AuthProvider, useAuth } from "./auth/AuthContext"; import { LoginForm } from "./auth/LoginForm"; import { RequireAuth } from "./auth/RequireAuth"; -import { BookList } from "./books/BookList"; import { BookDetail } from "./books/BookDetail"; import { BookForm } from "./books/BookForm"; -import { secondaryButton } from "./ui"; +import { BookList } from "./books/BookList"; +import { AlertIcon, BookIcon } from "./icons"; +import { ThemeToggle } from "./ThemeToggle"; +import { errorText, secondaryButton } from "./ui"; export function App() { return ( @@ -34,8 +37,22 @@ export function AppRoutes() { }> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> } /> @@ -43,18 +60,30 @@ export function AppRoutes() { ); } +function KeyedByBookId({ children }: { children: ReactNode }) { + const { id } = useParams(); + return {children}; +} + function Shell() { const { user, logout } = useAuth(); const [error, setError] = useState(null); return ( -
-
-
- +
+
+
+ + + + My Library -
- {user?.username} +
+ + {user?.username}
-
+
diff --git a/src/ThemeToggle.test.tsx b/src/ThemeToggle.test.tsx new file mode 100644 index 0000000..adf020a --- /dev/null +++ b/src/ThemeToggle.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeToggle } from "./ThemeToggle"; + +const root = document.documentElement; +const stored = new Map(); + +beforeEach(() => { + // Vitest's jsdom on Node 24 exposes no localStorage, so give it a minimal one. + vi.stubGlobal("localStorage", { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + removeItem: (key: string) => stored.delete(key), + }); +}); + +afterEach(() => { + delete root.dataset.theme; + stored.clear(); +}); + +describe("ThemeToggle", () => { + it("cycles system, light, dark and back to following the system", async () => { + render(); + const button = screen.getByRole("button", { name: /^Theme: System/ }); + expect(root.dataset.theme).toBeUndefined(); + + await userEvent.click(button); + expect(button).toHaveAccessibleName("Theme: Light. Switch to Dark"); + expect(root.dataset.theme).toBe("light"); + expect(localStorage.getItem("theme")).toBe("light"); + + await userEvent.click(button); + expect(root.dataset.theme).toBe("dark"); + expect(localStorage.getItem("theme")).toBe("dark"); + + await userEvent.click(button); + expect(root.dataset.theme).toBeUndefined(); + expect(localStorage.getItem("theme")).toBeNull(); + }); + + it("starts from the theme index.html applied before first paint", () => { + root.dataset.theme = "dark"; + render(); + expect(screen.getByRole("button", { name: /^Theme: Dark/ })).toBeVisible(); + }); +}); diff --git a/src/ThemeToggle.tsx b/src/ThemeToggle.tsx new file mode 100644 index 0000000..8a0c6e3 --- /dev/null +++ b/src/ThemeToggle.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { MoonIcon, SunIcon, SystemThemeIcon } from "./icons"; + +const THEMES = { + system: { label: "System", Icon: SystemThemeIcon, next: "light" }, + light: { label: "Light", Icon: SunIcon, next: "dark" }, + dark: { label: "Dark", Icon: MoonIcon, next: "system" }, +} as const; +type Theme = keyof typeof THEMES; + +const root = document.documentElement; + +export function ThemeToggle() { + const [theme, setTheme] = useState( + () => (root.dataset.theme as Theme | undefined) ?? "system", + ); + + useEffect(() => { + if (theme === "system") delete root.dataset.theme; + else root.dataset.theme = theme; + try { + if (theme === "system") localStorage.removeItem("theme"); + else localStorage.setItem("theme", theme); + } catch { + // Storage can be blocked (private mode); the choice still applies for this visit. + } + }, [theme]); + + const { label, Icon, next } = THEMES[theme]; + const hint = `Theme: ${label}. Switch to ${THEMES[next].label}`; + return ( + + ); +} diff --git a/src/api.ts b/src/api.ts index dad7e20..8943586 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,6 +32,13 @@ export class ApiError extends Error { } let handleUnauthorized: () => void = () => {}; +let authEpoch = 0; + +// A 401 only means "signed out" for the session that sent the request; one started before +// a later login, logout or expiry must not end the session that replaced it. +export function startAuthEpoch() { + authEpoch += 1; +} export function setUnauthorizedHandler(handler: () => void) { handleUnauthorized = handler; @@ -42,6 +49,7 @@ export async function api( path: string, body?: unknown, ): Promise { + const epoch = authEpoch; const response = await fetch(`/api${path}`, { method, credentials: "same-origin", @@ -51,8 +59,14 @@ export async function api( if (response.status === 204) return undefined as T; const data = await response.json().catch(() => null); if (!response.ok) { - if (response.status === 401 && !path.startsWith("/auth/")) + if ( + response.status === 401 && + !path.startsWith("/auth/") && + epoch === authEpoch + ) { + startAuthEpoch(); handleUnauthorized(); + } throw new ApiError( response.status, data?.error ?? `Request failed with status ${response.status}`, diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 41f9874..138950e 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -1,11 +1,12 @@ import { createContext, + type ReactNode, useContext, useEffect, + useRef, useState, - type ReactNode, } from "react"; -import { api, setUnauthorizedHandler, type User } from "../api"; +import { api, setUnauthorizedHandler, startAuthEpoch, type User } from "../api"; type AuthValue = { user: User | null; @@ -20,26 +21,42 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const authChanged = useRef(false); useEffect(() => { setUnauthorizedHandler(() => setUser(null)); api("GET", "/auth/me") - .then(setUser, () => setUser(null)) + .then( + (me) => { + if (!authChanged.current) setUser(me); + }, + () => { + if (!authChanged.current) setUser(null); + }, + ) .finally(() => setLoading(false)); }, []); + function changeUser(next: User | null) { + authChanged.current = true; + startAuthEpoch(); + setUser(next); + } + const value: AuthValue = { user, loading, login: async (username, password) => - setUser(await api("POST", "/auth/login", { username, password })), + changeUser( + await api("POST", "/auth/login", { username, password }), + ), register: async (username, password) => - setUser( + changeUser( await api("POST", "/auth/register", { username, password }), ), logout: async () => { await api("POST", "/auth/logout"); - setUser(null); + changeUser(null); }, }; return {children}; diff --git a/src/auth/LoginForm.tsx b/src/auth/LoginForm.tsx index cfe08fb..0fde195 100644 --- a/src/auth/LoginForm.tsx +++ b/src/auth/LoginForm.tsx @@ -1,7 +1,16 @@ -import { useState, type FormEvent } from "react"; -import { Navigate, useLocation, type Location } from "react-router"; +import { type SubmitEvent, useState } from "react"; +import { type Location, Navigate, useLocation } from "react-router"; import { errorMessage } from "../api"; -import { card, inputClass, labelText, primaryButton } from "../ui"; +import { AlertIcon, BookIcon } from "../icons"; +import { ThemeToggle } from "../ThemeToggle"; +import { + card, + errorText, + inputClass, + labelText, + linkText, + primaryButton, +} from "../ui"; import { useAuth } from "./AuthContext"; export function LoginForm() { @@ -22,7 +31,7 @@ export function LoginForm() { const isLogin = mode === "login"; - async function submit(event: FormEvent) { + async function submit(event: SubmitEvent) { event.preventDefault(); setError(null); setBusy(true); @@ -36,12 +45,21 @@ export function LoginForm() { } return ( -
-
-

+
+
+ +
+

+ + + + My Library +

+
+

{isLogin ? "Sign in" : "Create account"}

-
+ {error && ( - )} @@ -83,7 +102,7 @@ export function LoginForm() {
{progressError && ( - )} -
+
Edit @@ -168,6 +179,7 @@ export function BookDetail() { className={dangerButton} onClick={() => setConfirmingDelete(true)} > + Delete book
@@ -175,10 +187,10 @@ export function BookDetail() {

{`Delete “${book.title}”? This also deletes its reading journal.`}

-
+
{deleteError && ( -

+

+ {deleteError}

)} diff --git a/src/books/BookForm.test.tsx b/src/books/BookForm.test.tsx index 043625a..a868abe 100644 --- a/src/books/BookForm.test.tsx +++ b/src/books/BookForm.test.tsx @@ -79,13 +79,7 @@ describe("BookForm", () => { expect(calls).toContainEqual({ method: "PATCH", path: "/api/books/7", - body: { - title: "Dune Messiah", - author: "Frank Herbert", - genre_id: 5, - total_pages: 380, - current_page: 142, - }, + body: { title: "Dune Messiah" }, }); }); @@ -104,4 +98,41 @@ describe("BookForm", () => { await user.click(screen.getByRole("button", { name: "Retry" })); expect(await screen.findByLabelText("Title")).toHaveValue("Dune"); }); + + it("saving without changes returns to the book without a request", async () => { + const calls = signedIn({ + "GET /api/books/7": () => [200, dune], + "GET /api/books/7/notes": () => [200, []], + }); + const user = renderApp("/books/7/edit"); + await screen.findByDisplayValue("Dune"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await screen.findByRole("heading", { name: "Dune", level: 1 }); + expect(calls.some((call) => call.method === "PATCH")).toBe(false); + }); + + it("preserves progress saved in another session when only the title changes", async () => { + let saved = { ...dune }; + signedIn({ + "GET /api/books/7": () => [200, { ...saved }], + "GET /api/books/7/notes": () => [200, []], + "PATCH /api/books/7": ({ body }) => { + saved = { ...saved, ...(body as object) }; + return [200, saved]; + }, + }); + const user = renderApp("/books/7/edit"); + const title = await screen.findByDisplayValue("Dune"); + await screen.findByRole("option", { name: "Science Fiction" }); + + // Another session saves progress after this form loads its snapshot. + saved = { ...saved, current_page: 300 }; + await user.clear(title); + await user.type(title, "Dune Messiah"); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }); + expect(screen.getByRole("progressbar")).toHaveAttribute("value", "300"); + }); }); diff --git a/src/books/BookForm.tsx b/src/books/BookForm.tsx index ca351db..3953d64 100644 --- a/src/books/BookForm.tsx +++ b/src/books/BookForm.tsx @@ -1,10 +1,14 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { type SubmitEvent, useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; -import { api, ApiError, errorMessage, type Book } from "../api"; +import { ApiError, api, type Book, errorMessage } from "../api"; +import { AlertIcon } from "../icons"; import { card, + errorText, inputClass, labelText, + linkText, + pageTitle, primaryButton, secondaryButton, } from "../ui"; @@ -33,6 +37,7 @@ export function BookForm() { const navigate = useNavigate(); const { genres, error: genresError } = useGenres(); const [fields, setFields] = useState(EMPTY); + const [loaded, setLoaded] = useState(EMPTY); const [loading, setLoading] = useState(editing); const [loadError, setLoadError] = useState(null); const [error, setError] = useState(null); @@ -47,13 +52,15 @@ export function BookForm() { api("GET", `/books/${id}`).then( (book) => { if (cancelled) return; - setFields({ + const current = { title: book.title, author: book.author, genre_id: String(book.genre.id), total_pages: String(book.total_pages), current_page: String(book.current_page), - }); + }; + setFields(current); + setLoaded(current); setLoading(false); }, (err) => { @@ -78,10 +85,8 @@ export function BookForm() { : {}; } - async function submit(event: FormEvent) { + async function submit(event: SubmitEvent) { event.preventDefault(); - setError(null); - setBusy(true); const payload = { title: fields.title, author: fields.author, @@ -89,9 +94,23 @@ export function BookForm() { total_pages: Number(fields.total_pages), current_page: Number(fields.current_page), }; + // An edit sends only what the user changed, so it cannot overwrite fields + // (such as progress) saved elsewhere after this form loaded. + const changes = Object.fromEntries( + Object.entries(payload).filter( + ([name]) => + fields[name as keyof Fields] !== loaded[name as keyof Fields], + ), + ); + if (editing && Object.keys(changes).length === 0) { + navigate(`/books/${id}`); + return; + } + setError(null); + setBusy(true); try { const saved = editing - ? await api("PATCH", `/books/${id}`, payload) + ? await api("PATCH", `/books/${id}`, changes) : await api("POST", "/books", payload); navigate(`/books/${saved.id}`); } catch (err) { @@ -106,8 +125,11 @@ export function BookForm() { if (loadError) { return (
-

{loadError}

-
+

+ + {loadError} +

+
- + ← Back to library
); } - if (loading) return

Loading…

; + if (loading) return

Loading…

; return ( -
-

- {editing ? "Edit book" : "Add a book"} -

+ +

{editing ? "Edit book" : "Add a book"}

{genresError && ( -

{`Could not load genres: ${genresError}`}

+

+ + {`Could not load genres: ${genresError}`} +

)} -
-