diff --git a/sheaf/api/v1/files.py b/sheaf/api/v1/files.py index 60bf2dc..fe5dbbc 100644 --- a/sheaf/api/v1/files.py +++ b/sheaf/api/v1/files.py @@ -18,7 +18,10 @@ from sheaf.models.uploaded_file import UploadedFile from sheaf.models.user import User, UserTier from sheaf.schemas.member import MemberDeleteConfirm -from sheaf.services.file_cleanup import cleanup_orphaned_files +from sheaf.services.file_cleanup import ( + cleanup_orphaned_files, + find_file_references, +) from sheaf.services.system_safety import ( is_safeguarded, queue_pending_action, @@ -255,6 +258,30 @@ async def list_files( ] +@router.get("/{file_id}/references") +async def get_file_references( + file_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List everywhere an uploaded file is currently referenced (member + avatars/bios, system avatar, journal entries, and edit history). An empty + list means the file is an orphan. Lets the owner see what a delete would + break before confirming it.""" + result = await db.execute( + select(UploadedFile).where( + UploadedFile.id == file_id, + UploadedFile.user_id == user.id, + ) + ) + file = result.scalar_one_or_none() + if file is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") + + references = await find_file_references(db, str(user.id), file.key) + return {"key": file.key, "references": references} + + @router.delete("/{file_id}", dependencies=[Depends(require_scope("members:write"))]) async def delete_file( file_id: uuid.UUID, diff --git a/sheaf/services/file_cleanup.py b/sheaf/services/file_cleanup.py index 6facf32..9c3878c 100644 --- a/sheaf/services/file_cleanup.py +++ b/sheaf/services/file_cleanup.py @@ -11,13 +11,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from sheaf.crypto import decrypt -from sheaf.models.content_revision import ContentRevision +from sheaf.models.content_revision import ContentRevision, ContentRevisionTarget from sheaf.models.journal_entry import JournalEntry from sheaf.models.member import Member from sheaf.models.system import System from sheaf.models.uploaded_file import UploadedFile from sheaf.models.user import User +from sheaf.services.journals import entry_plaintext from sheaf.services.markdown import extract_image_keys +from sheaf.services.members import member_plaintext from sheaf.storage import get_storage logger = logging.getLogger("sheaf.cleanup") @@ -123,6 +125,113 @@ async def find_orphaned_files( return [f for f in uploaded if f.key in orphaned_keys] +async def find_file_references( + db: AsyncSession, + user_id: str, + key: str, +) -> list[dict]: + """Find everywhere a single uploaded-file key is referenced for this user. + + The inverse of find_orphaned_files: rather than collecting every + referenced key, it attributes one key to the specific entities that use + it, with user-facing labels. Powers the "where is this image used?" view + shown before a delete. Computed on demand; there is no persistent + reference table. An empty list means the file is an orphan. + """ + refs: list[dict] = [] + + sys_result = await db.execute( + select(System).join(User).where(User.id == user_id) + ) + system = sys_result.scalar_one_or_none() + if system is None: + return refs + + if system.avatar_url == key: + refs.append({ + "kind": "system_avatar", + "label": "System avatar", + "target_type": "system", + "target_id": str(system.id), + }) + + # Members: avatars + bio image embeds. Names/bios are encrypted, so decrypt + # via member_plaintext to both scan and label. + members_result = await db.execute( + select(Member).where(Member.system_id == system.id) + ) + members = list(members_result.scalars().all()) + member_name: dict[uuid.UUID, str] = {} + for m in members: + name, description = member_plaintext(m) + member_name[m.id] = name + if m.avatar_url == key: + refs.append({ + "kind": "member_avatar", + "label": f"{name}'s avatar", + "target_type": "member", + "target_id": str(m.id), + }) + if description and key in _extract_keys_from_markdown(description): + refs.append({ + "kind": "member_bio", + "label": f"{name}'s bio", + "target_type": "member", + "target_id": str(m.id), + }) + + # Journal entries. image_keys is pre-extracted at write time. + journals_result = await db.execute( + select(JournalEntry).where(JournalEntry.system_id == system.id) + ) + journal_label: dict[uuid.UUID, str] = {} + for e in journals_result.scalars().all(): + title, _ = entry_plaintext(e) + label = title or "Untitled entry" + journal_label[e.id] = label + if e.image_keys and key in e.image_keys: + refs.append({ + "kind": "journal", + "label": f"Journal: {label}", + "target_type": "journal_entry", + "target_id": str(e.id), + }) + + # Content revisions (edit history) for this user's members + journals. A + # key can appear in several historical revisions of the same target; list + # each target once so the view isn't spammed with duplicates. + target_ids = set(member_name) | set(journal_label) + if target_ids: + rev_result = await db.execute( + select(ContentRevision).where( + ContentRevision.target_id.in_(target_ids) + ) + ) + seen_targets: set[uuid.UUID] = set() + for r in rev_result.scalars().all(): + if not r.image_keys or key not in r.image_keys: + continue + if r.target_id in seen_targets: + continue + seen_targets.add(r.target_id) + if r.target_type == ContentRevisionTarget.MEMBER_BIO.value: + who = member_name.get(r.target_id, "a member") + label = f"Edit history of {who}'s bio" + elif r.target_type == ContentRevisionTarget.JOURNAL_ENTRY.value: + who = journal_label.get(r.target_id, "an entry") + label = f"Edit history of journal: {who}" + else: + label = "Edit history" + refs.append({ + "kind": "revision", + "label": label, + "target_type": r.target_type, + "target_id": str(r.target_id), + }) + + return refs + + async def cleanup_orphaned_files( db: AsyncSession, user_id: str, diff --git a/tests/test_files.py b/tests/test_files.py index cbc81e0..027c43d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -151,3 +151,37 @@ def test_admin_can_set_can_upload_images( ) assert resp.status_code == 200 assert resp.json()["can_upload_images"] is False + + +def test_file_references_surfaces_member_avatar(auth_client: httpx.Client): + """Selecting an uploaded file should show where it's referenced. An + avatar attached to a member surfaces as a member_avatar reference.""" + up = auth_client.post( + "/v1/files/upload", + files={"file": ("a.png", io.BytesIO(_png_bytes()), "image/png")}, + ).json() + key = up["key"] + files = auth_client.get("/v1/files/list").json() + file_id = next(f["id"] for f in files if f["key"] == key) + + # Freshly uploaded, nothing points at it yet. + refs0 = auth_client.get(f"/v1/files/{file_id}/references").json() + assert refs0["references"] == [], refs0 + + # Attach it as a member avatar (avatar_url stores the bare key). + created = auth_client.post( + "/v1/members", json={"name": "Pic Holder", "avatar_url": key} + ) + assert created.status_code == 201, created.text + + refs1 = auth_client.get(f"/v1/files/{file_id}/references").json() + kinds = {r["kind"] for r in refs1["references"]} + assert "member_avatar" in kinds, refs1 + assert any("Pic Holder" in r["label"] for r in refs1["references"]), refs1 + + +def test_file_references_404_for_unknown_file(auth_client: httpx.Client): + resp = auth_client.get( + "/v1/files/00000000-0000-0000-0000-000000000000/references" + ) + assert resp.status_code == 404 diff --git a/web/src/components/settings/uploaded-files-card.tsx b/web/src/components/settings/uploaded-files-card.tsx index 1b1ea89..467e2a8 100644 --- a/web/src/components/settings/uploaded-files-card.tsx +++ b/web/src/components/settings/uploaded-files-card.tsx @@ -1,26 +1,93 @@ import { useState } from "react"; +import { Link } from "react-router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { listFiles, deleteFile, type UploadedFileInfo } from "@/lib/files"; +import { + listFiles, + deleteFile, + getFileReferences, + type FileReference, + type UploadedFileInfo, +} from "@/lib/files"; +import { getMySystem } from "@/lib/systems"; +import { isDeleteQueued, type DestructiveConfirm } from "@/types/api"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DestructiveConfirmDialog } from "@/components/destructive-confirm-dialog"; import { formatBytes } from "@/lib/utils"; import { toast } from "sonner"; +/** In-app link for a reference target, or null if it isn't deep-linkable. + * target_type covers both live refs ("system" / "member" / "journal_entry") + * and revision refs ("member_bio" / "journal_entry"). */ +function refHref(r: FileReference): string | null { + switch (r.target_type) { + case "system": + return "/settings/system"; + case "member": + case "member_bio": + return `/members?member=${r.target_id}`; + case "journal_entry": + return `/journals/${r.target_id}`; + default: + return null; + } +} + +function ReferenceItem({ reference }: { reference: FileReference }) { + const href = refHref(reference); + return ( +
  • + + {href ? ( + + {reference.label} + + ) : ( + {reference.label} + )} +
  • + ); +} + export function UploadedFilesCard() { const qc = useQueryClient(); const { data: files, isLoading } = useQuery({ queryKey: ["files", "list"], queryFn: listFiles, }); + const { data: system } = useQuery({ + queryKey: ["system", "me"], + queryFn: getMySystem, + }); + const [selected, setSelected] = useState(null); + const [deletingFile, setDeletingFile] = useState(null); const remove = useMutation({ - mutationFn: deleteFile, - onSuccess: () => { + mutationFn: ({ id, confirm }: { id: string; confirm?: DestructiveConfirm }) => + deleteFile(id, confirm), + onSuccess: (result) => { qc.invalidateQueries({ queryKey: ["files", "list"] }); qc.invalidateQueries({ queryKey: ["storage", "usage"] }); - toast.success("File deleted"); + setDeletingFile(null); + if (isDeleteQueued(result)) { + qc.invalidateQueries({ queryKey: ["system-safety"] }); + toast.success( + `Image scheduled for deletion — cancellable in Settings until ${new Date(result.finalize_after).toLocaleDateString()}.`, + ); + } else { + toast.success("File deleted"); + } }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Delete failed"), }); - const [confirmId, setConfirmId] = useState(null); return ( @@ -35,51 +102,167 @@ export function UploadedFilesCard() {

    No uploaded files.

    )} {files && files.length > 0 && ( -
    - {files.map((f: UploadedFileInfo) => ( -
    - -
    -
    - {confirmId === f.id ? ( - - ) : ( - - )} -
    - - {f.purpose} · {formatBytes(f.size_bytes)} - -
    - ))} -
    + <> +

    + Click an image to see where it's used. +

    +
    + {files.map((f: UploadedFileInfo) => ( + + ))} +
    + )} + + !open && setSelected(null)} + onRequestDelete={(f) => { + setSelected(null); + setDeletingFile(f); + }} + /> + + !open && setDeletingFile(null)} + title="Delete image" + description="Are you sure you want to delete this image? Anything still using it will show a broken image." + tier={system?.delete_confirmation ?? "none"} + onConfirm={(confirm) => + deletingFile && remove.mutate({ id: deletingFile.id, confirm }) + } + loading={remove.isPending} + /> ); } + +function FileDetailDialog({ + file, + onOpenChange, + onRequestDelete, +}: { + file: UploadedFileInfo | null; + onOpenChange: (open: boolean) => void; + onRequestDelete: (file: UploadedFileInfo) => void; +}) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["files", file?.id, "references"], + queryFn: () => getFileReferences(file!.id), + enabled: !!file, + }); + + const refs = data?.references ?? []; + const liveRefs = refs.filter((r) => r.kind !== "revision"); + const historyRefs = refs.filter((r) => r.kind === "revision"); + const historyOnly = liveRefs.length === 0 && historyRefs.length > 0; + + return ( + + + + Image details + {file && ( + + {file.purpose} · {formatBytes(file.size_bytes)} + + )} + + + {file && ( +
    + + +
    + {isLoading && ( +

    + Checking references... +

    + )} + {isError && ( +

    + Couldn't load references. +

    + )} + {!isLoading && !isError && refs.length === 0 && ( +

    + Not used anywhere. Deleting it won't break anything. +

    + )} + + {!isLoading && !isError && historyOnly && ( +

    + Only kept by edit history — it isn't shown anywhere + current. Deleting it is safe unless you restore an older + revision below. (Orphan cleanup leaves these in place.) +

    + )} + + {!isLoading && !isError && liveRefs.length > 0 && ( +
    +

    Used in

    +
      + {liveRefs.map((r, i) => ( + + ))} +
    +
    + )} + + {!isLoading && !isError && historyRefs.length > 0 && ( +
    +

    + Edit history +

    +
      + {historyRefs.map((r, i) => ( + + ))} +
    +
    + )} +
    +
    + )} + + + + {file && ( + + )} + +
    +
    + ); +} diff --git a/web/src/lib/files.ts b/web/src/lib/files.ts index 8461057..0a49d2e 100644 --- a/web/src/lib/files.ts +++ b/web/src/lib/files.ts @@ -1,4 +1,5 @@ import { apiFetch } from "./api-client"; +import type { DeleteQueued, DestructiveConfirm } from "@/types/api"; interface UploadResponse { url: string; @@ -48,9 +49,41 @@ export function listFiles() { return apiFetch("/v1/files/list"); } -export function deleteFile(fileId: string) { - return apiFetch<{ deleted: boolean; key: string; freed_bytes: number }>( - `/v1/files/${fileId}`, - { method: "DELETE" }, +export type FileReferenceKind = + | "system_avatar" + | "member_avatar" + | "member_bio" + | "journal" + | "revision"; + +export interface FileReference { + kind: FileReferenceKind; + label: string; + target_type: string; + target_id: string; +} + +/** Where an uploaded file is currently referenced. Empty `references` means + * the file is an orphan (nothing breaks if it's deleted). */ +export function getFileReferences(fileId: string) { + return apiFetch<{ key: string; references: FileReference[] }>( + `/v1/files/${fileId}/references`, ); } + +export interface FileDeleted { + deleted: boolean; + key: string; + freed_bytes: number; +} + +/** Delete an uploaded file. Returns FileDeleted on an immediate delete, or + * DeleteQueued (202) when System Safety's image-delete grace period applies. + * `confirm` carries the step-up password / TOTP required by the system's + * delete-confirmation tier. */ +export function deleteFile(fileId: string, confirm?: DestructiveConfirm) { + return apiFetch(`/v1/files/${fileId}`, { + method: "DELETE", + ...(confirm ? { body: JSON.stringify(confirm) } : {}), + }); +} diff --git a/web/src/routes/members.tsx b/web/src/routes/members.tsx index 01f5928..a97871e 100644 --- a/web/src/routes/members.tsx +++ b/web/src/routes/members.tsx @@ -1,5 +1,5 @@ import { type FormEvent, lazy, Suspense, useMemo, useState } from "react"; -import { Link } from "react-router"; +import { Link, useSearchParams } from "react-router"; import { useQuery } from "@tanstack/react-query"; import { useMembers, useCreateMember, useDeleteMember, useUpdateMember } from "@/hooks/use-members"; import { useCustomFields, useMemberFieldValues, useSetMemberFieldValues } from "@/hooks/use-custom-fields"; @@ -878,6 +878,30 @@ export function MembersPage() { const [viewing, setViewing] = useState(null); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + // Deep link: /members?member= opens that member's view dialog (used by + // the uploaded-files "where is this used" links). Derived from the URL so it + // resolves once members load; a manual card selection takes precedence. + // Closing the dialog strips the param. + const memberParam = searchParams.get("member"); + const deepLinked = memberParam + ? members?.find((m) => m.id === memberParam) ?? null + : null; + const viewingMember = viewing ?? deepLinked; + + function closeView() { + setViewing(null); + if (memberParam) { + setSearchParams( + (prev) => { + prev.delete("member"); + return prev; + }, + { replace: true }, + ); + } + } return ( <> @@ -921,14 +945,14 @@ export function MembersPage() { )} {/* View dialog */} - {viewing && ( + {viewingMember && ( { - setEditing(viewing); - setViewing(null); + setEditing(viewingMember); + closeView(); }} - onClose={() => setViewing(null)} + onClose={closeView} /> )}