refactor(frontend): extract decryptPrivateCleartext helper

Home.svelte and TagPage.svelte both pre-decrypted private rows with
the same ~15-line $derived.by block (loop + decryptPayload + try/catch).
Move it into a small lib helper so future changes (parallelism, error
reporting) live in one place. Both call sites now use a plain
$derived(decryptPrivateCleartext(activities, session.dek)).

Surfaced by /audit simplify.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-05-25 17:41:13 +02:00
commit 45ad3ea3bb
3 changed files with 43 additions and 38 deletions

View file

@ -3,9 +3,7 @@
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import { api } from '../lib/api';
import { session } from '../lib/session.svelte';
import {
decryptPayload, base64ToBytes, type PrivatePayload,
} from '../lib/crypto';
import { decryptPrivateCleartext } from '../lib/privateCleartext';
import ActivityForm from './ActivityForm.svelte';
import ActivityRow from './ActivityRow.svelte';
import type { Activity } from '../../../shared/types';
@ -56,20 +54,7 @@
}
/** Pre-decrypt private payloads once per render so search can match. */
const privateCleartext = $derived.by(() => {
const map = new Map<string, PrivatePayload>();
if (!session.dek) return map;
for (const a of activities) {
if (a.visibility !== 'private') continue;
try {
map.set(a.id, decryptPayload(
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
session.dek,
));
} catch { /* skip */ }
}
return map;
});
const privateCleartext = $derived(decryptPrivateCleartext(activities, session.dek));
function matchesQuery(a: Activity, q: string): boolean {
if (!q) return true;

View file

@ -2,9 +2,7 @@
import { onMount } from 'svelte';
import { api } from '../lib/api';
import { session } from '../lib/session.svelte';
import {
decryptPayload, base64ToBytes, type PrivatePayload,
} from '../lib/crypto';
import { decryptPrivateCleartext } from '../lib/privateCleartext';
import ActivityRow from './ActivityRow.svelte';
import type { Activity } from '../../../shared/types';
@ -37,24 +35,9 @@
}
}
/**
* Pre-decrypt private payloads once so we can both filter by tag and let
* ActivityRow render without re-doing the work. Same pattern as Home.svelte.
*/
const privateCleartext = $derived.by(() => {
const map = new Map<string, PrivatePayload>();
if (!session.dek) return map;
for (const a of activities) {
if (a.visibility !== 'private') continue;
try {
map.set(a.id, decryptPayload(
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
session.dek,
));
} catch { /* skip undecryptable rows */ }
}
return map;
});
/** Pre-decrypt private rows once so we can filter by tag and let
* ActivityRow render without redoing the work. */
const privateCleartext = $derived(decryptPrivateCleartext(activities, session.dek));
function hasTag(a: Activity): boolean {
if (a.visibility === 'private') {

View file

@ -0,0 +1,37 @@
/**
* Bulk-decrypt the private rows in a list of activities, returning a
* Map<id, PrivatePayload>. Rows that fail to decrypt (corrupted ciphertext,
* wrong DEK) are silently skipped the caller treats their absence as
* "no plaintext available" rather than an error, matching how the UI
* already displays undecryptable rows.
*
* Originally duplicated in Home.svelte and TagPage.svelte; consolidated
* here so any future change (parallelism, telemetry on decrypt failures)
* lives in one place.
*/
import { decryptPayload, base64ToBytes, type PrivatePayload } from './crypto';
import type { Activity } from '../../../shared/types';
export function decryptPrivateCleartext(
activities: Activity[],
dek: Uint8Array | null,
): Map<string, PrivatePayload> {
const map = new Map<string, PrivatePayload>();
if (!dek) return map;
for (const a of activities) {
if (a.visibility !== 'private') continue;
try {
map.set(
a.id,
decryptPayload(
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
dek,
),
);
} catch {
// Skip rows we can't decrypt — UI shows them as "log in again" via
// the !session.dek branch in ActivityRow.
}
}
return map;
}