Activity export to Markdown file (client-side, decrypts privates)

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 <a download>, 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).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:13:42 +02:00
commit 12d16c0835
2 changed files with 173 additions and 0 deletions

View file

@ -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 @@
</div>
</form>
<section class="card" aria-labelledby="exp-h">
<h3 id="exp-h">Eksporter</h3>
<p class="muted">
Last ned alle aktivitetene du kan se — inkludert dine private — som
en Markdown-fil. Filen lages i nettleseren din; serveren får aldri
klartekst.
</p>
{#if exportError}<p class="error" role="alert">{exportError}</p>{/if}
{#if exportSize !== null}
<p class="muted" role="status">Eksportert ({(exportSize / 1024).toFixed(1)} kB).</p>
{/if}
<div class="row">
<button type="button" onclick={exportNow} disabled={exporting}>
{exporting ? 'Eksporterer …' : 'Last ned eksport'}
</button>
</div>
</section>
<section class="card" aria-labelledby="inv-h">
<h3 id="inv-h">Invitasjonslenker</h3>
<p class="muted">

136
frontend/src/lib/export.ts Normal file
View file

@ -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<number> {
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;
}