diff --git a/server/src/http/transfers.ts b/server/src/http/transfers.ts index b4c133e..5ef3a6f 100644 --- a/server/src/http/transfers.ts +++ b/server/src/http/transfers.ts @@ -267,6 +267,36 @@ transferRoutes.get("/transfers", async (c) => { }); }); +/** + * POST /api/transfers/:id/claim + * Link a transfer to the authenticated user as recipient. Called by the + * Receive page after the key-proves-access check. Idempotent: refuses to + * overwrite an existing recipient, and refuses to let the sender claim + * their own outgoing as incoming. + */ +transferRoutes.post("/transfers/:id/claim", async (c) => { + const user = await resolveSession(c); + if (!user) return c.json({ error: "unauthenticated" }, 401); + + const id = c.req.param("id"); + if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404); + + const result = await db + .update(transfers) + .set({ recipientUserId: user.id }) + .where( + and( + eq(transfers.id, id), + isNull(transfers.recipientUserId), + isNull(transfers.deletedAt), + sql`${transfers.senderUserId} IS DISTINCT FROM ${user.id}`, + ), + ) + .returning({ id: transfers.id }); + + return c.json({ claimed: result.length > 0 }); +}); + /** * DELETE /api/transfers/:id * Sender can revoke. Marks deleted, purges the blob asynchronously. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6aed66c..f4eb4f4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -154,3 +154,11 @@ export async function deleteTransfer(id: string): Promise { const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`); } + +export async function claimTransfer(id: string): Promise { + const res = await call(`/api/transfers/${encodeURIComponent(id)}/claim`, { method: "POST" }); + if (res.status === 401) return false; + if (!res.ok) return false; + const body = (await res.json()) as { claimed: boolean }; + return body.claimed; +} diff --git a/web/src/pages/Receive.tsx b/web/src/pages/Receive.tsx index 10ddffe..61f0f4e 100644 --- a/web/src/pages/Receive.tsx +++ b/web/src/pages/Receive.tsx @@ -6,6 +6,9 @@ import { receiveCloud, type ReceivedTransferPreview, } from "../lib/sendCloud"; +import { keyToFragment } from "../lib/cloudTransfer"; +import { saveTransferKey } from "../lib/localTransferKeys"; +import { claimTransfer } from "../lib/api"; type Stage = | { kind: "loading" } @@ -61,9 +64,11 @@ export default function Receive() { } previewTransfer(id, k) - .then((preview) => - setStage({ kind: "preview", preview, password: "", passwordError: null }), - ) + .then((preview) => { + saveTransferKey(id, keyToFragment(k)); + claimTransfer(id).catch(() => {}); + setStage({ kind: "preview", preview, password: "", passwordError: null }); + }) .catch((err) => { const msg = err instanceof Error ? err.message : "unknown"; setStage({ kind: "error", message: msg }); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 8881359..0126e34 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -89,6 +89,8 @@ export default function Settings() { + +
@@ -257,6 +259,96 @@ function itemStatus(t: LinkItem): "active" | "expired" | "consumed" { return "active"; } +function ReceivedSection() { + const [items, setItems] = useState(null); + + useEffect(() => { + listInboxTransfers() + .then((list) => { + const received = list + .filter((t) => t.direction === "received") + .map((t) => { + const keyFrag = getTransferKey(t.id); + let filename: string | null = null; + if (keyFrag) { + try { + const key = fragmentToKey(keyFrag); + filename = openMetadata(key, t.encryptedMetadata).name; + } catch { + filename = null; + } + } + return { ...t, filename, keyFrag }; + }); + setItems(received); + }) + .catch(() => setItems([])); + }, []); + + return ( +
+
+ Inbox +
+

+ Received transfers +

+
+ {items === null && ( +
Loading…
+ )} + {items !== null && items.length === 0 && ( +
+ Nothing here yet. Links others send you will show up here after you open them. +
+ )} + {items?.map((item) => { + const status = itemStatus(item); + const dimmed = status !== "active"; + const openUrl = item.keyFrag + ? `/r/${item.id}#k=${item.keyFrag}` + : null; + return ( +
+
+
+
+ {item.filename ?? "Encrypted file"} +
+
+ {formatSize(item.sizeBytes)} + + {item.downloadCount}/{item.maxDownloads} downloads + + + {status === "expired" + ? "expired" + : status === "consumed" + ? "done" + : `expires ${formatExpiry(item.expiresAt)}`} + +
+
+ {openUrl && status === "active" && ( + + Open → + + )} +
+
+ ); + })} +
+
+ ); +} + function SharedLinksSection() { const [items, setItems] = useState(null); const [copiedId, setCopiedId] = useState(null);