Fix CSRF 403, improve registry UI, add phonetic IDs
- Fix CSRF token handling for production: generate tokens with HMAC, set cookie via afterware, inject into HTMX requests via JS - Improve registry page: filter at top with better proportions, compact horizontal pill layout for facets - Add phonetic ID encoding (e.g., "tobi-kafu-meli") for animal display instead of truncated ULIDs - Remove "subadult" life stage (migration converts to juvenile) - Change "Death (natural)" outcome label to just "Death" - Show sex/life stage in animal picker alongside species/location 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,6 @@ class LifeStage(str, Enum):
|
||||
|
||||
HATCHLING = "hatchling"
|
||||
JUVENILE = "juvenile"
|
||||
SUBADULT = "subadult"
|
||||
ADULT = "adult"
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
# ABOUTME: ULID generation utility for creating unique identifiers.
|
||||
# ABOUTME: Provides a simple wrapper around the python-ulid library.
|
||||
# ABOUTME: Provides phonetic encoding for human-readable animal ID display.
|
||||
|
||||
from ulid import ULID
|
||||
|
||||
# Consonant-Vowel syllables for phonetic encoding
|
||||
# 16 consonants x 5 vowels = 80 syllables
|
||||
# We avoid confusing letters: c/k, j/g, q/k, h (silent), y (sometimes vowel)
|
||||
_CONSONANTS = "bdfgklmnprstvwxz" # 16 consonants
|
||||
_VOWELS = "aeiou" # 5 vowels
|
||||
|
||||
# Build syllable lookup table (80 entries, ~6.3 bits each)
|
||||
_SYLLABLES = tuple(c + v for c in _CONSONANTS for v in _VOWELS)
|
||||
_SYLLABLE_TO_INDEX = {syl: i for i, syl in enumerate(_SYLLABLES)}
|
||||
|
||||
|
||||
def generate_id() -> str:
|
||||
"""Generate a new ULID as a 26-character string.
|
||||
@@ -11,3 +21,56 @@ def generate_id() -> str:
|
||||
A 26-character uppercase alphanumeric ULID string.
|
||||
"""
|
||||
return str(ULID())
|
||||
|
||||
|
||||
def ulid_to_phonetic(ulid_str: str, syllable_count: int = 4) -> str:
|
||||
"""Convert ULID to phonetic representation.
|
||||
|
||||
Uses the random portion of the ULID (bytes 6-16) since the first
|
||||
6 bytes are timestamp and would be identical for IDs generated
|
||||
close together. With 4 syllables using 80 syllables each, encodes
|
||||
~25 bits (about 40 million unique values).
|
||||
|
||||
Args:
|
||||
ulid_str: 26-character ULID string.
|
||||
syllable_count: Number of syllables (default 4).
|
||||
|
||||
Returns:
|
||||
Hyphenated phonetic string like "tobi-kafu-meli-dova".
|
||||
"""
|
||||
# Parse ULID to get raw bytes
|
||||
ulid = ULID.from_str(ulid_str)
|
||||
ulid_bytes = ulid.bytes # 16 bytes = 128 bits
|
||||
|
||||
# Skip the first 6 bytes (timestamp) and use the random portion
|
||||
# ULID format: 48 bits timestamp (6 bytes) + 80 bits random (10 bytes)
|
||||
random_bytes = ulid_bytes[6:] # 10 bytes of randomness
|
||||
|
||||
# Convert random bytes to integer for extraction
|
||||
value = int.from_bytes(random_bytes, "big")
|
||||
|
||||
# Extract syllables using modular arithmetic
|
||||
result = []
|
||||
for _ in range(syllable_count):
|
||||
idx = value % 80
|
||||
result.append(_SYLLABLES[idx])
|
||||
value //= 80
|
||||
|
||||
return "-".join(result)
|
||||
|
||||
|
||||
def format_animal_id(animal_id: str, nickname: str | None = None) -> str:
|
||||
"""Format animal ID for display.
|
||||
|
||||
Returns nickname if available, otherwise phonetic encoding.
|
||||
|
||||
Args:
|
||||
animal_id: The 26-character ULID.
|
||||
nickname: Optional animal nickname.
|
||||
|
||||
Returns:
|
||||
Display string for the animal.
|
||||
"""
|
||||
if nickname:
|
||||
return nickname
|
||||
return ulid_to_phonetic(animal_id)
|
||||
|
||||
@@ -18,6 +18,7 @@ from animaltrack.web.exceptions import AuthenticationError, AuthorizationError
|
||||
from animaltrack.web.middleware import (
|
||||
auth_before,
|
||||
csrf_before,
|
||||
generate_csrf_token,
|
||||
request_id_before,
|
||||
)
|
||||
from animaltrack.web.responses import error_toast
|
||||
@@ -105,6 +106,39 @@ def create_app(
|
||||
|
||||
return None
|
||||
|
||||
def after(req: Request, resp, sess):
|
||||
"""Afterware to set CSRF cookie on page responses."""
|
||||
# Skip in dev mode (CSRF is bypassed anyway)
|
||||
if settings.dev_mode:
|
||||
return resp
|
||||
|
||||
# Only set cookie on GET requests (page loads)
|
||||
if req.method != "GET":
|
||||
return resp
|
||||
|
||||
# Check if cookie already set
|
||||
if settings.csrf_cookie_name in req.cookies:
|
||||
return resp
|
||||
|
||||
# Skip non-HTML responses (API endpoints, static files, etc.)
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "text/html" not in content_type:
|
||||
return resp
|
||||
|
||||
# Generate and set token
|
||||
token = generate_csrf_token(settings.csrf_secret)
|
||||
|
||||
# Set cookie - httponly=False so JS can read it for HTMX
|
||||
resp.set_cookie(
|
||||
settings.csrf_cookie_name,
|
||||
token,
|
||||
httponly=False,
|
||||
samesite="strict",
|
||||
secure=not settings.dev_mode,
|
||||
max_age=86400, # 24 hours
|
||||
)
|
||||
return resp
|
||||
|
||||
# Configure beforeware with skip patterns
|
||||
beforeware = Beforeware(
|
||||
before,
|
||||
@@ -139,6 +173,7 @@ def create_app(
|
||||
# Create FastHTML app with HTMX extensions, MonsterUI theme, and static path
|
||||
app, rt = fast_app(
|
||||
before=beforeware,
|
||||
after=after,
|
||||
hdrs=(*Theme.slate.headers(), htmx_config), # Dark industrial theme + HTMX config
|
||||
exts=["head-support", "preload"],
|
||||
static_path=static_path_for_fasthtml,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# ABOUTME: Middleware functions for authentication, CSRF, and request logging.
|
||||
# ABOUTME: Implements Beforeware pattern for FastHTML request processing.
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -18,6 +21,27 @@ from animaltrack.repositories.users import UserRepository
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def generate_csrf_token(secret: str) -> str:
|
||||
"""Generate a CSRF token using HMAC.
|
||||
|
||||
Creates a random nonce and signs it with the secret to produce a token.
|
||||
The token format is: nonce:signature (hex encoded).
|
||||
|
||||
Args:
|
||||
secret: The csrf_secret from settings.
|
||||
|
||||
Returns:
|
||||
A token string in format "nonce:signature".
|
||||
"""
|
||||
nonce = secrets.token_hex(16)
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
nonce.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{nonce}:{signature}"
|
||||
|
||||
|
||||
def is_safe_method(method: str) -> bool:
|
||||
"""Check if HTTP method is safe (doesn't require CSRF protection).
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ async def animal_cohort(request: Request, session):
|
||||
return _render_cohort_error(
|
||||
request, locations, species_list, "Please select a location", form
|
||||
)
|
||||
if not life_stage or life_stage not in ("hatchling", "juvenile", "subadult", "adult"):
|
||||
if not life_stage or life_stage not in ("hatchling", "juvenile", "adult"):
|
||||
return _render_cohort_error(
|
||||
request, locations, species_list, "Please select a life stage", form
|
||||
)
|
||||
|
||||
@@ -174,7 +174,6 @@ def cohort_form(
|
||||
life_stages = [
|
||||
("hatchling", "Hatchling"),
|
||||
("juvenile", "Juvenile"),
|
||||
("subadult", "Subadult"),
|
||||
("adult", "Adult"),
|
||||
]
|
||||
life_stage_options = [
|
||||
@@ -1037,7 +1036,6 @@ def attrs_form(
|
||||
Option("No change", value="", selected=True),
|
||||
Option("Hatchling", value="hatchling"),
|
||||
Option("Juvenile", value="juvenile"),
|
||||
Option("Subadult", value="subadult"),
|
||||
Option("Adult", value="adult"),
|
||||
]
|
||||
|
||||
@@ -1282,7 +1280,7 @@ def outcome_form(
|
||||
# Build outcome options
|
||||
outcome_options = [
|
||||
Option("Select outcome...", value="", selected=True, disabled=True),
|
||||
Option("Death (natural)", value="death"),
|
||||
Option("Death", value="death"),
|
||||
Option("Harvest", value="harvest"),
|
||||
Option("Sold", value="sold"),
|
||||
Option("Predator Loss", value="predator_loss"),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from fasthtml.common import Div, Input, Label, P, Span
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.repositories.animals import AnimalListItem
|
||||
|
||||
|
||||
@@ -29,7 +30,12 @@ def animal_checkbox_list(
|
||||
items = []
|
||||
for animal in animals[:max_display]:
|
||||
is_checked = animal.animal_id in selected_set
|
||||
display_name = animal.nickname or animal.animal_id[:8] + "..."
|
||||
display_name = format_animal_id(animal.animal_id, animal.nickname)
|
||||
|
||||
# Format sex as single letter (M/F/?)
|
||||
sex_code = {"male": "M", "female": "F", "unknown": "?"}.get(animal.sex, "?")
|
||||
# Format life stage as abbreviation
|
||||
stage_abbr = animal.life_stage[:3].title() # hat, juv, adu
|
||||
|
||||
items.append(
|
||||
Label(
|
||||
@@ -43,7 +49,7 @@ def animal_checkbox_list(
|
||||
),
|
||||
Span(display_name, cls="text-stone-200"),
|
||||
Span(
|
||||
f" ({animal.species_code}, {animal.location_name})",
|
||||
f" ({animal.species_code}, {sex_code}, {stage_abbr}, {animal.location_name})",
|
||||
cls="text-stone-500 text-sm",
|
||||
),
|
||||
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",
|
||||
|
||||
@@ -68,6 +68,37 @@ def EventSlideOverScript(): # noqa: N802
|
||||
""")
|
||||
|
||||
|
||||
def CsrfHeaderScript(): # noqa: N802
|
||||
"""JavaScript to inject CSRF token into HTMX requests.
|
||||
|
||||
Reads the csrf_token cookie and adds it as x-csrf-token header
|
||||
to all HTMX requests. This is required for POST/PUT/DELETE
|
||||
requests to pass CSRF validation.
|
||||
"""
|
||||
return Script("""
|
||||
// Read CSRF token from cookie
|
||||
function getCsrfToken() {
|
||||
var name = 'csrf_token=';
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var cookie = cookies[i].trim();
|
||||
if (cookie.indexOf(name) === 0) {
|
||||
return cookie.substring(name.length);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Configure HTMX to send CSRF token with all requests
|
||||
document.body.addEventListener('htmx:configRequest', function(event) {
|
||||
var token = getCsrfToken();
|
||||
if (token) {
|
||||
event.detail.headers['x-csrf-token'] = token;
|
||||
}
|
||||
});
|
||||
""")
|
||||
|
||||
|
||||
def EventSlideOver(): # noqa: N802
|
||||
"""Event detail slide-over panel container."""
|
||||
return Div(
|
||||
@@ -127,6 +158,7 @@ def page(
|
||||
EventSlideOverStyles(),
|
||||
SidebarScript(),
|
||||
EventSlideOverScript(),
|
||||
CsrfHeaderScript(),
|
||||
# Desktop sidebar
|
||||
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
|
||||
# Mobile menu drawer
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.models.reference import UserRole
|
||||
|
||||
@@ -361,7 +362,7 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
||||
|
||||
animal_items = []
|
||||
for animal in animals[:20]: # Limit display
|
||||
display_name = animal.get("nickname") or animal["id"][:8] + "..."
|
||||
display_name = format_animal_id(animal["id"], animal.get("nickname"))
|
||||
animal_items.append(
|
||||
Li(
|
||||
A(
|
||||
|
||||
@@ -6,8 +6,9 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fasthtml.common import H2, A, Div, Form, P, Span, Table, Tbody, Td, Th, Thead, Tr
|
||||
from monsterui.all import Button, ButtonT, Card, Grid, LabelInput
|
||||
from monsterui.all import Button, ButtonT, Grid, LabelInput
|
||||
|
||||
from animaltrack.id_gen import format_animal_id
|
||||
from animaltrack.models.reference import Location, Species
|
||||
from animaltrack.repositories.animals import AnimalListItem, FacetCounts
|
||||
|
||||
@@ -20,8 +21,8 @@ def registry_page(
|
||||
total_count: int = 0,
|
||||
locations: list[Location] | None = None,
|
||||
species_list: list[Species] | None = None,
|
||||
) -> Grid:
|
||||
"""Full registry page with sidebar and table.
|
||||
) -> Div:
|
||||
"""Full registry page with filter at top, then sidebar + table.
|
||||
|
||||
Args:
|
||||
animals: List of animals for the current page.
|
||||
@@ -33,27 +34,30 @@ def registry_page(
|
||||
species_list: List of species for facet labels.
|
||||
|
||||
Returns:
|
||||
Grid component with sidebar and main content.
|
||||
Div component with header, sidebar, and main content.
|
||||
"""
|
||||
return Grid(
|
||||
# Sidebar with facets
|
||||
facet_sidebar(facets, filter_str, locations, species_list),
|
||||
# Main content
|
||||
Div(
|
||||
# Header with filter
|
||||
registry_header(filter_str, total_count),
|
||||
# Animal table
|
||||
animal_table(animals, next_cursor, filter_str),
|
||||
cls="col-span-3",
|
||||
return Div(
|
||||
# Filter at top - full width
|
||||
registry_header(filter_str, total_count),
|
||||
# Grid with sidebar and table
|
||||
Grid(
|
||||
# Sidebar with facets
|
||||
facet_sidebar(facets, filter_str, locations, species_list),
|
||||
# Main content - table
|
||||
Div(
|
||||
animal_table(animals, next_cursor, filter_str),
|
||||
cls="col-span-3",
|
||||
),
|
||||
cols_sm=1,
|
||||
cols_md=4,
|
||||
cls="gap-4",
|
||||
),
|
||||
cols_sm=1,
|
||||
cols_md=4,
|
||||
cls="gap-4 p-4",
|
||||
cls="p-4",
|
||||
)
|
||||
|
||||
|
||||
def registry_header(filter_str: str, total_count: int) -> Div:
|
||||
"""Header with title and filter input.
|
||||
"""Header with title, count, and prominent filter.
|
||||
|
||||
Args:
|
||||
filter_str: Current filter string.
|
||||
@@ -63,30 +67,34 @@ def registry_header(filter_str: str, total_count: int) -> Div:
|
||||
Div with header and filter form.
|
||||
"""
|
||||
return Div(
|
||||
# Top row: Title and count
|
||||
Div(
|
||||
H2("Animal Registry", cls="text-xl font-bold"),
|
||||
Span(f"{total_count} animals", cls="text-sm text-stone-400"),
|
||||
cls="flex items-center justify-between",
|
||||
Span(f"{total_count} animals", cls="text-sm text-stone-400 ml-3"),
|
||||
cls="flex items-baseline mb-4",
|
||||
),
|
||||
# Filter form
|
||||
# Filter form - full width, prominent
|
||||
Form(
|
||||
Div(
|
||||
LabelInput(
|
||||
"Filter",
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder='e.g., species:duck status:alive location:"Strip 1"',
|
||||
cls="flex-1",
|
||||
# Filter input - wider with flex-grow
|
||||
Div(
|
||||
LabelInput(
|
||||
"Filter",
|
||||
id="filter",
|
||||
name="filter",
|
||||
value=filter_str,
|
||||
placeholder='species:duck status:alive location:"Strip 1"',
|
||||
),
|
||||
cls="flex-1 min-w-0", # min-w-0 prevents flex overflow
|
||||
),
|
||||
Button("Apply", type="submit", cls=ButtonT.primary),
|
||||
cls="flex gap-2 items-end",
|
||||
# Apply button - fixed size
|
||||
Button("Apply", type="submit", cls=f"{ButtonT.primary} shrink-0"),
|
||||
cls="flex gap-3 items-end",
|
||||
),
|
||||
action="/registry",
|
||||
method="get",
|
||||
cls="mt-4",
|
||||
),
|
||||
cls="mb-4",
|
||||
cls="mb-6 pb-4 border-b border-stone-700",
|
||||
)
|
||||
|
||||
|
||||
@@ -96,7 +104,7 @@ def facet_sidebar(
|
||||
locations: list[Location] | None,
|
||||
species_list: list[Species] | None,
|
||||
) -> Div:
|
||||
"""Sidebar with clickable facet counts.
|
||||
"""Sidebar with compact clickable facet counts.
|
||||
|
||||
Args:
|
||||
facets: Facet counts for display.
|
||||
@@ -116,7 +124,7 @@ def facet_sidebar(
|
||||
facet_section("Sex", facets.by_sex, filter_str, "sex"),
|
||||
facet_section("Life Stage", facets.by_life_stage, filter_str, "life_stage"),
|
||||
facet_section("Location", facets.by_location, filter_str, "location", location_map),
|
||||
cls="space-y-4",
|
||||
cls="space-y-3",
|
||||
)
|
||||
|
||||
|
||||
@@ -127,7 +135,7 @@ def facet_section(
|
||||
field: str,
|
||||
label_map: dict[str, str] | None = None,
|
||||
) -> Any:
|
||||
"""Single facet section with clickable items.
|
||||
"""Single facet section with compact pill-style items.
|
||||
|
||||
Args:
|
||||
title: Section title.
|
||||
@@ -137,11 +145,12 @@ def facet_section(
|
||||
label_map: Optional mapping from value to display label.
|
||||
|
||||
Returns:
|
||||
Card component with facet items, or None if no counts.
|
||||
Div component with facet pills, or None if no counts.
|
||||
"""
|
||||
if not counts:
|
||||
return None
|
||||
|
||||
# Build inline pill items
|
||||
items = []
|
||||
for value, count in sorted(counts.items(), key=lambda x: -x[1]):
|
||||
label = label_map.get(value, value) if label_map else value.replace("_", " ").title()
|
||||
@@ -153,19 +162,19 @@ def facet_section(
|
||||
href = f"/registry?{urlencode({'filter': new_filter})}"
|
||||
items.append(
|
||||
A(
|
||||
Div(
|
||||
Span(label, cls="text-sm"),
|
||||
Span(str(count), cls="text-xs text-stone-400 ml-auto"),
|
||||
cls="flex justify-between items-center",
|
||||
),
|
||||
Span(label, cls="text-xs"),
|
||||
Span(str(count), cls="text-xs text-stone-500 ml-1"),
|
||||
href=href,
|
||||
cls="block p-2 hover:bg-slate-800 rounded",
|
||||
cls="inline-flex items-center px-2 py-1 rounded bg-stone-800 hover:bg-stone-700 mr-1 mb-1",
|
||||
)
|
||||
)
|
||||
|
||||
return Card(
|
||||
P(title, cls="font-bold text-sm mb-2"),
|
||||
*items,
|
||||
return Div(
|
||||
P(title, cls="font-semibold text-xs text-stone-400 mb-2"),
|
||||
Div(
|
||||
*items,
|
||||
cls="flex flex-wrap",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -220,8 +229,8 @@ def animal_row(animal: AnimalListItem) -> Tr:
|
||||
last_event_dt = datetime.fromtimestamp(animal.last_event_utc / 1000, tz=UTC)
|
||||
last_event_str = last_event_dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Display ID (truncated or nickname)
|
||||
display_id = animal.nickname or animal.animal_id[:8] + "..."
|
||||
# Display ID (phonetic encoding or nickname)
|
||||
display_id = format_animal_id(animal.animal_id, animal.nickname)
|
||||
|
||||
# Status badge styling
|
||||
status_cls = {
|
||||
|
||||
Reference in New Issue
Block a user