feat: inbox of received transfers in /settings
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
Auto-claim on receive so /settings shows transfers others sent you. Filename decryption stays client-side using the key stored in localStorage when the share link is opened. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c46b23b8ec
commit
2452f2642a
@ -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
|
* DELETE /api/transfers/:id
|
||||||
* Sender can revoke. Marks deleted, purges the blob asynchronously.
|
* Sender can revoke. Marks deleted, purges the blob asynchronously.
|
||||||
|
|||||||
@ -154,3 +154,11 @@ export async function deleteTransfer(id: string): Promise<void> {
|
|||||||
const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" });
|
const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`);
|
if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function claimTransfer(id: string): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import {
|
|||||||
receiveCloud,
|
receiveCloud,
|
||||||
type ReceivedTransferPreview,
|
type ReceivedTransferPreview,
|
||||||
} from "../lib/sendCloud";
|
} from "../lib/sendCloud";
|
||||||
|
import { keyToFragment } from "../lib/cloudTransfer";
|
||||||
|
import { saveTransferKey } from "../lib/localTransferKeys";
|
||||||
|
import { claimTransfer } from "../lib/api";
|
||||||
|
|
||||||
type Stage =
|
type Stage =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
@ -61,9 +64,11 @@ export default function Receive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previewTransfer(id, k)
|
previewTransfer(id, k)
|
||||||
.then((preview) =>
|
.then((preview) => {
|
||||||
setStage({ kind: "preview", preview, password: "", passwordError: null }),
|
saveTransferKey(id, keyToFragment(k));
|
||||||
)
|
claimTransfer(id).catch(() => {});
|
||||||
|
setStage({ kind: "preview", preview, password: "", passwordError: null });
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const msg = err instanceof Error ? err.message : "unknown";
|
const msg = err instanceof Error ? err.message : "unknown";
|
||||||
setStage({ kind: "error", message: msg });
|
setStage({ kind: "error", message: msg });
|
||||||
|
|||||||
@ -89,6 +89,8 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ReceivedSection />
|
||||||
|
|
||||||
<SharedLinksSection />
|
<SharedLinksSection />
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
@ -257,6 +259,96 @@ function itemStatus(t: LinkItem): "active" | "expired" | "consumed" {
|
|||||||
return "active";
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReceivedSection() {
|
||||||
|
const [items, setItems] = useState<LinkItem[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listInboxTransfers()
|
||||||
|
.then((list) => {
|
||||||
|
const received = list
|
||||||
|
.filter((t) => t.direction === "received")
|
||||||
|
.map<LinkItem>((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 (
|
||||||
|
<section className="mb-12">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
Inbox
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||||
|
Received transfers
|
||||||
|
</h2>
|
||||||
|
<div className="paper-panel divide-y divide-paper-edge">
|
||||||
|
{items === null && (
|
||||||
|
<div className="px-5 py-6 text-sm text-ink-muted">Loading…</div>
|
||||||
|
)}
|
||||||
|
{items !== null && items.length === 0 && (
|
||||||
|
<div className="px-5 py-6 text-sm text-ink-muted">
|
||||||
|
Nothing here yet. Links others send you will show up here after you open them.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items?.map((item) => {
|
||||||
|
const status = itemStatus(item);
|
||||||
|
const dimmed = status !== "active";
|
||||||
|
const openUrl = item.keyFrag
|
||||||
|
? `/r/${item.id}#k=${item.keyFrag}`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`px-5 py-4 ${dimmed ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-ink text-sm truncate">
|
||||||
|
{item.filename ?? "Encrypted file"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[11px] text-ink-muted mt-1 flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
<span>{formatSize(item.sizeBytes)}</span>
|
||||||
|
<span>
|
||||||
|
{item.downloadCount}/{item.maxDownloads} downloads
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{status === "expired"
|
||||||
|
? "expired"
|
||||||
|
: status === "consumed"
|
||||||
|
? "done"
|
||||||
|
: `expires ${formatExpiry(item.expiresAt)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{openUrl && status === "active" && (
|
||||||
|
<a
|
||||||
|
href={openUrl}
|
||||||
|
className="shrink-0 text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
Open →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SharedLinksSection() {
|
function SharedLinksSection() {
|
||||||
const [items, setItems] = useState<LinkItem[] | null>(null);
|
const [items, setItems] = useState<LinkItem[] | null>(null);
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user