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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,5 +33,8 @@
|
||||
"file_folder_retention": "Folder retention changed",
|
||||
"file_folder_delete": "Folder deleted",
|
||||
"api_token_create": "API token created",
|
||||
"api_token_revoke": "API token revoked"
|
||||
"api_token_revoke": "API token revoked",
|
||||
"download_link_create": "Share link created",
|
||||
"download_link_update": "Share link changed",
|
||||
"download_link_revoke": "Share link revoked"
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"password": "Password (optional)",
|
||||
"copy": "Copy link",
|
||||
"copyFailed": "Couldn’t copy",
|
||||
"urlHidden": "This link still opens, but its address can no longer be shown. To share the file again, make a new link.",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "This link will stop working immediately.",
|
||||
"views_one": "{{count}} view",
|
||||
|
||||
@@ -33,5 +33,8 @@
|
||||
"file_folder_retention": "Mappa megőrzési ideje módosítva",
|
||||
"file_folder_delete": "Mappa törölve",
|
||||
"api_token_create": "API-token létrehozva",
|
||||
"api_token_revoke": "API-token visszavonva"
|
||||
"api_token_revoke": "API-token visszavonva",
|
||||
"download_link_create": "Megosztási link létrehozva",
|
||||
"download_link_update": "Megosztási link módosítva",
|
||||
"download_link_revoke": "Megosztási link visszavonva"
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"password": "Jelszó (opcionális)",
|
||||
"copy": "Link másolása",
|
||||
"copyFailed": "Nem sikerült másolni",
|
||||
"urlHidden": "Ez a link továbbra is működik, de a címe már nem jeleníthető meg. Ha újra meg akarod osztani a fájlt, készíts új linket.",
|
||||
"revoke": "Visszavonás",
|
||||
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
||||
"views_one": "{{count}} megtekintés",
|
||||
|
||||
@@ -140,8 +140,10 @@ export interface ShareRecipient {
|
||||
|
||||
export interface ShareLink {
|
||||
id: number;
|
||||
token: string;
|
||||
url: string; // path like "/watch/<token>" — prepend window.location.origin to share
|
||||
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
|
||||
// link still opens for whoever has it; to share it anew, make a new link.
|
||||
token: string | null;
|
||||
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share
|
||||
allow_download: boolean;
|
||||
has_password: boolean;
|
||||
expires_at: string | null;
|
||||
@@ -266,7 +268,7 @@ export const downloadsApi = {
|
||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
||||
revokeDownloadLink: (linkId: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
||||
// The library player's resume checkpoint. A position too early or too close to the end clears it.
|
||||
saveDownloadProgress: (
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
|
||||
import { quickShareLink } from "./downloads";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
const t = (k: string) => k;
|
||||
const origin = window.location.origin;
|
||||
@@ -71,6 +72,34 @@ describe("quickShareLink", () => {
|
||||
expect(firstToast().level).toBe("success");
|
||||
});
|
||||
|
||||
it("passes over a link whose address the server can no longer show", async () => {
|
||||
downloadLinks.mockResolvedValue([
|
||||
{ id: 4, url: null },
|
||||
{ id: 3, url: "/watch/older" },
|
||||
]);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/older`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
|
||||
const hidden = { id: 4, url: null };
|
||||
downloadLinks.mockResolvedValue([hidden]);
|
||||
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||
const client = qc();
|
||||
|
||||
const url = await quickShareLink(client, { id: 6 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/new`);
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||
expect(client.getQueryData(qk.downloadLinks(6))).toEqual([
|
||||
{ id: 8, url: "/watch/new" },
|
||||
hidden,
|
||||
]);
|
||||
});
|
||||
|
||||
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||
copyText.mockResolvedValue(false);
|
||||
|
||||
@@ -27,22 +27,25 @@ export async function quickShareLink(
|
||||
job: { id: number },
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
let link;
|
||||
let path;
|
||||
try {
|
||||
const existing = await qc.fetchQuery({
|
||||
queryKey: qk.downloadLinks(job.id),
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
});
|
||||
link = existing[0];
|
||||
if (!link) {
|
||||
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
||||
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
||||
path = existing.find((l) => l.url)?.url;
|
||||
if (!path) {
|
||||
const made = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [made, ...existing]);
|
||||
path = made.url;
|
||||
}
|
||||
if (!path) throw new Error("the new link came back without its address");
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("downloads.share.linkFailed"));
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + link.url;
|
||||
const url = window.location.origin + path;
|
||||
const action = {
|
||||
label: t("downloads.share.openNewTab"),
|
||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||
|
||||
Reference in New Issue
Block a user