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:
parent
f0ce5e9680
commit
12d16c0835
2 changed files with 173 additions and 0 deletions
|
|
@ -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
136
frontend/src/lib/export.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue