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
+5 -3
View File
@@ -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: (
+29
View File
@@ -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);
+9 -6
View File
@@ -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"),