feat: auth by default, theme toggle, CoderPad startup, review fixes

- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
This commit is contained in:
2026-10-02 16:49:14 -05:00
parent 9d7b0e805c
commit 7f5d034a1f
46 changed files with 1797 additions and 497 deletions
+57 -33
View File
@@ -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/<int(max=2147483647):book_id>")
@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/<int(max=2147483647):book_id>")
@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/<int(max=2147483647):book_id>")
@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",