Files
justin a6179037c2 Close citation/source living-person leak; add on-demand tree purge
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>
2026-06-10 22:38:59 -04:00

75 lines
2.7 KiB
Python

import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.tree import TreeCreate, TreePurge, TreeRead, TreeUpdate
from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"])
@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED)
async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.create_tree(
session,
owner=current,
name=data.name,
description=data.description,
visibility=data.visibility,
)
return TreeRead.model_validate(tree)
@router.get("", response_model=list[TreeRead])
async def list_my_trees(
session: SessionDep, current: CurrentUser, deleted: bool = False
) -> list[TreeRead]:
if deleted:
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
else:
trees = await tree_service.list_trees_for_user(session, user=current)
return [TreeRead.model_validate(t) for t in trees]
@router.get("/{tree_id}", response_model=TreeRead)
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
return TreeRead.model_validate(tree)
@router.patch("/{tree_id}", response_model=TreeRead)
async def update_tree(
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
) -> TreeRead:
tree = await tree_service.update_tree(
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
)
return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
@router.post("/{tree_id}/restore", response_model=TreeRead)
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree)
@router.post("/{tree_id}/purge", status_code=status.HTTP_204_NO_CONTENT)
async def purge_tree(
tree_id: uuid.UUID,
data: TreePurge,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> None:
"""Permanently delete a soft-deleted tree and all its data — irreversible.
Owner-only; the tree must be in the trash and `confirm_name` must match."""
await tree_service.purge_tree(
session, store, actor=current, tree_id=tree_id, confirm_name=data.confirm_name
)