From 12d16c0835a1afc0a375fc30a00ca705a87ceeec Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 14:13:42 +0200 Subject: [PATCH] Activity export to Markdown file (client-side, decrypts privates) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new "Eksporter" section on the Profile page generates a markdown file containing all activities visible to the caller — including their own private ones — and triggers a download. Why pure client-side: - Private rows are E2E-encrypted; the server doesn't have the cleartext. Decryption MUST happen in the browser. - Avoiding a server endpoint also means there's no "give me my plaintext" target for anyone who steals a session cookie. Mechanics (frontend/src/lib/export.ts): - Fetch /api/activities (same as the dashboard does) - For each row, normalise: decrypt private payload via session.dek, pass semi/public through as-is - Build a markdown doc with sections "Private (N)" and "Offentlige og anonyme (N)" plus per-row title/description/tags/ location/scheduled blocks - Wrap in a Blob, create a temporary , click it, revoke the object URL The Profile section reports the resulting size as a quick confirmation and surfaces errors inline. No server changes required. Bundle: 32.7 KB → 34.2 KB gzipped (mostly the export helper itself, which is plain string concatenation — no new deps). --- frontend/src/components/Profile.svelte | 37 +++++++ frontend/src/lib/export.ts | 136 +++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 frontend/src/lib/export.ts diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 73aca60..7d584b1 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -3,6 +3,7 @@ import { api, ApiError } from '../lib/api'; import { changePassword } from '../lib/auth'; import { session } from '../lib/session.svelte'; + import { downloadExport } from '../lib/export'; import type { InviteEntry } from '../../../shared/types'; interface Props { @@ -115,6 +116,24 @@ }); } + // --- Export ------------------------------------------------------------- + let exporting = $state(false); + let exportSize: number | null = $state(null); + let exportError: string | null = $state(null); + + async function exportNow() { + exporting = true; + exportError = null; + exportSize = null; + try { + exportSize = await downloadExport(); + } catch { + exportError = 'Eksport feilet.'; + } finally { + exporting = false; + } + } + async function savePassword(e: SubmitEvent) { e.preventDefault(); pwError = null; @@ -197,6 +216,24 @@ +
+

Eksporter

+

+ Last ned alle aktivitetene du kan se — inkludert dine private — som + en Markdown-fil. Filen lages i nettleseren din; serveren får aldri + klartekst. +

+ {#if exportError}{/if} + {#if exportSize !== null} +

Eksportert ({(exportSize / 1024).toFixed(1)} kB).

+ {/if} +
+ +
+
+

Invitasjonslenker

diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts new file mode 100644 index 0000000..6c0f9e3 --- /dev/null +++ b/frontend/src/lib/export.ts @@ -0,0 +1,136 @@ +/** + * Activity export — pure client-side. + * + * Two reasons we don't ask the server to assemble the file: + * 1. Private rows are E2E-encrypted; the server doesn't have the cleartext. + * The client must decrypt and serialise, full stop. + * 2. Keeping the export client-side avoids creating a "give me all my data + * as plaintext" endpoint that becomes an attractive target for anyone + * who steals a session cookie. + * + * Output is markdown-flavoured text so it renders nicely in any editor or + * markdown viewer, without committing us to a specific renderer. + */ +import { api } from './api'; +import { session } from './session.svelte'; +import { + decryptPayload, base64ToBytes, type PrivatePayload, +} from './crypto'; +import type { Activity } from '../../../shared/types'; + +function formatDate(epochSeconds: number | null | undefined): string { + if (!epochSeconds) return ''; + const d = new Date(epochSeconds * 1000); + return d.toLocaleString('nb-NO', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }); +} + +interface NormalisedRow { + title: string; + description: string | null; + tags: string[]; + loc_label: string | null; + scheduled_at: number | null; + visibility: 'private' | 'semi' | 'public'; +} + +/** Decrypt a private activity into the same shape as semi/public rows. */ +function normalise(a: Activity, dek: Uint8Array | null): NormalisedRow | null { + if (a.visibility === 'private') { + if (!dek) return null; + let payload: PrivatePayload; + try { + payload = decryptPayload( + { ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) }, + dek, + ); + } catch { + return null; + } + return { + title: payload.title, + description: payload.description ?? null, + tags: payload.tags, + loc_label: payload.loc_label ?? null, + scheduled_at: payload.scheduled_at ?? null, + visibility: 'private', + }; + } + return { + title: a.title, + description: a.description, + tags: a.tags, + loc_label: a.loc_label, + scheduled_at: a.scheduled_at, + visibility: a.visibility, + }; +} + +function sectionBody(rows: NormalisedRow[]): string { + return rows.map((r) => { + const lines: string[] = [`### ${r.title}`]; + if (r.description) { + // Indent the description with `> ` so the section header stays scannable. + const quoted = r.description.split('\n').map((ln) => `> ${ln}`).join('\n'); + lines.push(quoted); + } + if (r.tags.length) lines.push(`Etiketter: ${r.tags.join(', ')}`); + if (r.loc_label) lines.push(`Sted: ${r.loc_label}`); + if (r.scheduled_at) lines.push(`Tidspunkt: ${formatDate(r.scheduled_at)}`); + return lines.join('\n'); + }).join('\n\n'); +} + +/** + * Build a markdown export of the caller's currently-visible activities and + * trigger a download. Returns the size of the generated blob in bytes so + * callers can show a quick confirmation. + */ +export async function downloadExport(): Promise { + const activities = await api.listActivities(); + const dek = session.dek; + const normalised = activities + .map((a) => normalise(a, dek)) + .filter((r): r is NormalisedRow => r !== null); + + const myPrivate = normalised.filter((r) => r.visibility === 'private'); + const mySemiPublic = normalised.filter( + (r) => r.visibility === 'semi' || r.visibility === 'public', + ); + + const header = [ + `# Vinterliste — eksport`, + session.user ? `Bruker: ${session.user.email}` : '', + `Generert: ${new Date().toLocaleString('nb-NO', { hourCycle: 'h23' })}`, + `Totalt: ${normalised.length} (${myPrivate.length} private, ${mySemiPublic.length} offentlige/anonyme synlige for deg)`, + ].filter(Boolean).join('\n'); + + const sections: string[] = [header]; + if (myPrivate.length) { + sections.push(`\n## Private (${myPrivate.length})\n\n${sectionBody(myPrivate)}`); + } + if (mySemiPublic.length) { + sections.push(`\n## Offentlige og anonyme (${mySemiPublic.length})\n\n${sectionBody(mySemiPublic)}`); + } + const text = sections.join('\n') + '\n'; + + const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement('a'); + a.href = url; + // Use ISO date in the filename so multiple exports don't overwrite. + const stamp = new Date().toISOString().slice(0, 10); + a.download = `vinterliste-${stamp}.md`; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } finally { + URL.revokeObjectURL(url); + } + + return blob.size; +}