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 ( +
No uploaded files.
)} {files && files.length > 0 && ( -+ Click an image to see where it's used. +
+