- 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
63 lines
2.0 KiB
Python
63 lines
2.0 KiB
Python
"""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):
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.message = message
|
|
self.field = field
|
|
|
|
|
|
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")
|
|
unknown = sorted(set(body) - allowed)
|
|
if unknown:
|
|
raise ApiError(
|
|
400,
|
|
f"Unknown field(s): {', '.join(unknown)}. Allowed: {', '.join(sorted(allowed))}",
|
|
unknown[0],
|
|
)
|
|
return body
|
|
|
|
|
|
def require_utf8(value: str, field: str) -> None:
|
|
try:
|
|
value.encode("utf-8")
|
|
except UnicodeEncodeError:
|
|
raise ApiError(
|
|
400,
|
|
f"{field} contains unpaired surrogate characters; send valid UTF-8 text",
|
|
field,
|
|
) from None
|
|
|
|
|
|
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)
|
|
value = value.strip()
|
|
if len(value) > max_len:
|
|
raise ApiError(400, f"{field} must be at most {max_len} characters", field)
|
|
if "\x00" in value:
|
|
raise ApiError(400, f"{field} must not contain NUL characters", field)
|
|
require_utf8(value, field)
|
|
return value
|
|
|
|
|
|
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)
|
|
if not low <= value <= high:
|
|
raise ApiError(400, f"{field} must be between {low} and {high}", field)
|
|
return value
|