Opening any person page on a large tree took 4-5s on an idle server. Root cause:
list_persons looped over every person calling privacy.person_visibility (which
issues TWO get_membership_role queries per call) AND _attach_primary_name (one
name query per person). On the reporter's 2,324-person tree that's ~7,000
serialized DB round-trips per page load — the person page fetches the full
person list to build its name-lookup map.
Fix:
- Resolve the viewer's membership role ONCE. Members see the whole tree (full),
so skip the per-person privacy engine entirely.
- Add _attach_primary_names: one batched names query (person_id IN (...),
ordered the same as the single-person query so it picks the same name) instead
of one per person.
- Apply the same batching to the non-member path, search_persons, the deleted-
persons list, and public_view_service.list_public_persons.
Member-path list_persons goes from ~3·N queries to ~3 total. Other tree-wide
list endpoints (events/relationships/media/citations) were already flat selects.
Adds a regression test that asserts list_persons issues a constant number of
queries (not proportional to person count). Suite: 103 passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
Two changes.
1. Privacy fix (NN#2/NN#3) — the citation and source list endpoints gated only
on can_view_tree, so a non-member on a public/unlisted/site_members tree could
enumerate citations and sources tied to a redacted living person, leaking that
the person exists and has sourced facts (and possibly their name via a source
title). #46 closed this for events/media/names/relationships but not
citations/sources. Now citation_service.list_citations and
source_service.{list_sources,get_source} delegate non-member reads to
public_view_service, mirroring the #46 pattern:
- citations: shown only when the cited fact resolves to FULL-visibility
person(s) — covers the person_id, name_id, event_id (person or both-partner),
and relationship_id (both-partner) target paths.
- sources: shown only when they back at least one visible citation; a withheld
source 404s (don't reveal it exists).
Tests cover all four citation target types + source withholding + member-sees-all.
2. On-demand tree purge — owners can permanently delete a soft-deleted tree now
instead of waiting out the 30-day auto-purge window. POST /trees/{id}/purge
(owner-only): the tree must already be in the trash, and the caller retypes its
name to confirm. Media objects are deleted from storage, then a single
DELETE on trees cascades all tree-owned rows via the tree_id ON DELETE CASCADE;
the audit entry survives (tree_id SET NULL). Frontend adds a "Delete forever"
button to the Recently-deleted list. No migration.
Suite: 102 passing.
Signed-off-by: Justin Paul <justin@jpaul.me>
A logged-in NON-member of a public/unlisted tree could read living people's
dates, real alternate names, and media (incl. downloading photos) through the
family-view endpoints — only the person LIST was redacted; list_events,
list_relationships, list_names, list_media gated on can_view_tree alone.
For non-members, these now delegate to the same visibility-filtered reads the
public surface uses (person_visibility-driven): living-person events/names
dropped, relationships touching a hidden person dropped, media limited to
full-visibility persons, and media download (get_media → media_content) 404s
for a redacted/unlinked person's media. Members are unchanged.
Adds list_public_relationships_for_person / list_public_media / can_view_media
to public_view_service. Test: an authed non-member sees no living-person PII
across events/names/relationships/media and can't download a living person's
file, while the owner still sees everything. Full suite: 72 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
Adds the anonymous read surface (/api/v1/public) — the privacy-critical core.
- CurrentUserOrNone dependency: optional auth that never 401s (anonymous OK).
- public_view_service: every projection passes through privacy.person_visibility.
persons redacted (living → "Living person", hidden dropped); relationships
only when both endpoints non-hidden; events only for FULL-visibility persons
(partnership events only when both partners full); names only for FULL
persons. Not-viewable trees raise 404 (not 403) so the surface can't probe
for private trees. Media deferred (higher-sensitivity; own pass later).
- public router: read-only directory + tree + persons/relationships/events +
person detail/names/events. Directory lists `public` to all and adds
`site_members` for authenticated callers; never lists unlisted/private.
- PublicTreeRead omits owner_id.
Tests (ran locally — CI does not run pytest): an anonymous end-to-end leak test
asserting a living person's real name, alias, and birth year appear in NO public
response while the deceased person's data does; plus private=404, unlisted
viewable-by-link-but-unlisted, site_members requires login, and directory
visibility. Full suite: 70 passed. Regenerated openapi.json + TS client.
Note: the AUTHED list endpoints still leak per-person for non-members
(pre-existing) — fixed next, separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>