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:
+14
-12
@@ -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)
|
||||
|
||||
+81
-120
@@ -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,))
|
||||
)
|
||||
|
||||
+57
-33
@@ -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",
|
||||
|
||||
+39
-10
@@ -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:[email protected]: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)
|
||||
|
||||
+26
-18
@@ -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/<int(max=2147483647):book_id>/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/<int(max=2147483647):book_id>/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/<int(max=2147483647):note_id>")
|
||||
@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/<int(max=2147483647):note_id>")
|
||||
@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),
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
Flask>=3.1.3
|
||||
psycopg2-binary>=2.9.9
|
||||
+189
-2
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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://[email protected]:9/none"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user