feat(files): share-link tokens are kept only hashed and encrypted, and every link change is audited

A /watch link's token is the credential, and it sat in the database as written. Migration 0107
replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner
can copy the same link again); every existing address keeps working, and the downgrade decrypts
them back. Without a copy this key can read, a link still opens but its address cannot be shown:
"Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says
so in that row instead of a broken URL. Making, changing and revoking a link are audited, never
with its token or password.
This commit is contained in:
2026-10-15 01:02:29 +02:00
parent c31d7f9d65
commit 90567718b2
18 changed files with 480 additions and 60 deletions
@@ -4,7 +4,7 @@
// Revoke is the destructive one. Both colours once sat on the button, where the ghost variant's own
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -17,31 +17,33 @@ vi.mock("react-i18next", async (importOriginal) => ({
}));
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
const h = vi.hoisted(() => ({ links: null as unknown[] | null }));
vi.mock("../lib/api", () => ({
api: {
shareRecipients: async () => [],
downloadLinks: async () => [
{
id: 1,
token: "a",
url: "/watch/a",
allow_download: false,
has_password: true,
expires_at: null,
view_count: 0,
created_at: null,
},
{
id: 2,
token: "b",
url: "/watch/b",
allow_download: false,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
],
downloadLinks: async () =>
h.links ?? [
{
id: 1,
token: "a",
url: "/watch/a",
allow_download: false,
has_password: true,
expires_at: null,
view_count: 0,
created_at: null,
},
{
id: 2,
token: "b",
url: "/watch/b",
allow_download: false,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
},
],
},
}));
@@ -49,6 +51,9 @@ import ShareDialog from "./ShareDialog";
import type { DownloadJob } from "../lib/api";
afterEach(cleanup);
beforeEach(() => {
h.links = null;
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -71,3 +76,33 @@ describe("ShareDialog · a link row's state colours", () => {
expect(iconClass(revoke)).toContain("text-danger");
});
});
describe("ShareDialog · a link whose address cannot be shown again", () => {
it("says so in place of the address, with nothing to copy", async () => {
const link = (id: number, url: string | null) => ({
id,
token: url && url.slice("/watch/".length),
url,
allow_download: false,
has_password: false,
expires_at: null,
view_count: 0,
created_at: null,
});
h.links = [link(3, null), link(2, "/watch/b")];
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByText("downloads.share.urlHidden")).toBeTruthy();
// One row has an address to show and copy; the hidden one shows none (not "…/null").
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
const shown = screen.getAllByRole("textbox").map((el) => (el as HTMLInputElement).value);
expect(shown).toContain(`${window.location.origin}/watch/b`);
expect(shown.some((v) => v.includes("null"))).toBe(false);
// Its other controls stay: it can still be protected, made downloadable, or revoked.
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
});
});
+16 -6
View File
@@ -70,10 +70,10 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const [copied, setCopied] = useState(false);
const [editPass, setEditPass] = useState(false);
const [newPass, setNewPass] = useState("");
const fullUrl = window.location.origin + link.url;
const fullUrl = link.url && window.location.origin + link.url;
const copy = async () => {
if (await copyText(fullUrl)) {
if (fullUrl && (await copyText(fullUrl))) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
@@ -114,10 +114,20 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<div className={`${SURFACE.card} p-2.5 space-y-2`}>
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
{fullUrl ? (
<>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
</>
) : (
// The server keeps no copy of this link's address it can read: the link still opens for
// whoever has it, but it cannot be shared again from here.
<span className="flex-1 min-w-0 text-xs text-muted">
{t("downloads.share.urlHidden")}
</span>
)}
{/* All three on the same rung. Only Copy was migrated at first, which left one 2rem button
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive