Index: docs/EXPORT-FORMAT.md
===================================================================
--- docs/EXPORT-FORMAT.md	(revision 712b1615e6ae1b194be929712df713bcd9082041)
+++ docs/EXPORT-FORMAT.md	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
@@ -116,5 +116,5 @@
 | `shaer:coverAlt` | `posts.cover_alt` | |
 | `shaer:viewCount` | `posts.view_count` | optional; a restore without it loses history |
-| `shaer:audio` | `audio_tracks` rows referenced from the content | title, artist, duration, credit, license, links, and a `media` reference |
+| `shaer:audio` | `audio_tracks` rows referenced from the content | title, artist, duration, credit, license, external links, `shaer:ref` (the `[[track:id]]` it belongs to) and `shaer:media` (which attachment holds its file) |
 
 **Deliberately not exported:** `yjs_binary` (collaborative editor state — large,
Index: scripts/import-archive.mjs
===================================================================
--- scripts/import-archive.mjs	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ scripts/import-archive.mjs	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+//
+// Importeer een inhoudsarchief in een site (shaer-pmr).
+//
+//     node scripts/import-archive.mjs <slug> <archief.zip|map> --dry-run
+//     node scripts/import-archive.mjs <slug> <archief.zip|map>
+//     node scripts/import-archive.mjs <slug> <archief.zip|map> --overwrite
+//
+// Het formaat staat in docs/EXPORT-FORMAT.md.
+//
+// Standaard wordt een bestaande post met hetzelfde id of dezelfde slug
+// OVERGESLAGEN. Dat maakt de import idempotent en zorgt dat je nooit per ongeluk
+// inhoud vernietigt die er al staat. --overwrite doet het wel, expliciet.
+//
+// Er gaat GEEN Update de fediverse in. Als andere servers een oudere kopie
+// hebben, blijft die daar staan; dat rechttrekken is een uitzending naar iedereen
+// en hoort een aparte, bewuste actie te zijn.
+
+import { readArchive, importArchive } from '../src/services/ArchiveImportService.js';
+
+const args = process.argv.slice(2);
+const vrij = args.filter((a) => !a.startsWith('-'));
+const [slug, bron] = vrij;
+
+if (!slug || !bron) {
+  console.error('gebruik: node scripts/import-archive.mjs <slug> <archief.zip|map> [--dry-run] [--overwrite]');
+  process.exit(1);
+}
+
+let files;
+try { files = readArchive(bron); }
+catch (e) { console.error(`kan het archief niet lezen: ${e.message}`); process.exit(1); }
+
+let r;
+try { r = importArchive(files, { slug, dryRun: args.includes('--dry-run'), overwrite: args.includes('--overwrite') }); }
+catch (e) { console.error(`geweigerd: ${e.message}`); process.exit(1); }
+
+console.log(`formaatversie : ${r.formatVersion}`);
+console.log(`herkomst      : ${r.origin || '(geen)'}`);
+console.log(`AP-ids        : ${r.idsBehouden ? 'BEHOUDEN (zelfde origin)' : 'nieuw (andere origin)'}`);
+console.log(`posts         : ${r.posts}${r.overschreven ? `, waarvan ${r.overschreven} overschreven` : ''}`);
+console.log(`overgeslagen  : ${r.overgeslagen}   (bestonden al)`);
+console.log(`antwoorden    : ${r.replies}   (alleen-lezen archief)`);
+console.log(`media         : ${r.media} teruggezet, ${r.mediaMissing} ontbrekend`);
+
+if (r.gemist.length) {
+  console.log('\nontbrekende media:');
+  for (const m of r.gemist.slice(0, 20)) console.log(`  ${m.post}  ${m.url}`);
+  if (r.gemist.length > 20) console.log(`  ... en nog ${r.gemist.length - 20}`);
+}
+for (const w of r.waarschuwingen) console.log(`\nLET OP: ${w}`);
+
+if (args.includes('--dry-run')) console.log('\n--dry-run: niets geschreven.');
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 712b1615e6ae1b194be929712df713bcd9082041)
+++ src/services/ArchiveExportService.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
@@ -109,13 +109,28 @@
 }
 
-/** De audio-metadata die alleen in de database staat en nergens anders uit te halen is. */
-function audioOf(post) {
+/**
+ * De audio-metadata die alleen in de database staat en nergens anders uit te
+ * halen is: titel, artiest, credit, licentie, externe links.
+ *
+ * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
+ * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
+ * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
+ * herstel op niets terug.
+ */
+function audioOf(post, attachments) {
   const uit = [];
   for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
     try {
-      const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
+      const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
       if (!t) continue;
+      let bestand;
+      if (t.storage_path) {
+        const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
+        const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
+        bestand = bij ? bij.url : undefined;
+      }
       uit.push({
         'shaer:ref': `[[track:${t.id}]]`,
+        'shaer:media': bestand,
         name: t.title, artist: t.artist || undefined, album: t.album || undefined,
         duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
@@ -137,5 +152,5 @@
       if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
       const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
-      return { multiple: !!d.multiple, opties, endTime: d.endTime || null };
+      return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
     } catch { return null; }
   })();
@@ -164,4 +179,7 @@
     ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
     endTime: poll ? (poll.endTime || undefined) : undefined,
+    // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
+    // is gesloten na een herstel weer open -- gevonden op echte beta-data.
+    closed: (poll && poll.closed) ? true : undefined,
     quoteUrl: post.quote_uri || undefined,
     'shaer:quoteActor': post.quote_actor || undefined,
@@ -179,5 +197,5 @@
     'shaer:coverAlt': post.cover_alt || undefined,
     'shaer:viewCount': post.view_count || undefined,
-    'shaer:audio': audioOf(post),
+    'shaer:audio': audioOf(post, attachments),
   };
 }
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ src/services/ArchiveImportService.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
@@ -0,0 +1,288 @@
+/**
+ * Import van een draagbaar inhoudsarchief (shaer-pmr).
+ *
+ * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
+ * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
+ *
+ *   VERSIE EERST   Een hogere onbekende formatVersion wordt in zijn GEHEEL
+ *                  geweigerd. Een half begrepen herstel is erger dan geen
+ *                  herstel, want het ziet eruit alsof het gelukt is.
+ *   IDENTITEIT     De origin uit het manifest bepaalt of de AP-ids behouden
+ *                  blijven. Dat is geen vraag aan de gebruiker: een verkeerd
+ *                  antwoord publiceert objecten onder een id dat je niet beheert.
+ *   NIETS STILS    Ontbrekende media worden geteld en gemeld.
+ *   GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
+ *                  rechttrekken is een aparte, bewuste actie.
+ *
+ * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import zlib from 'zlib';
+import crypto from 'crypto';
+import { randomUUID } from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+import { FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const sqlTijd = (iso) => { const t = Date.parse(iso); return isNaN(t) ? null : new Date(t).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''); };
+
+// ── Inlezen ───────────────────────────────────────────────────────
+
+/** Lees een archiefmap in als pad -> Buffer. */
+export function readArchiveDir(dir) {
+  const files = new Map();
+  const loop = (sub) => {
+    for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+      const rel = sub ? `${sub}/${naam.name}` : naam.name;
+      if (naam.isDirectory()) loop(rel);
+      else files.set(rel, fs.readFileSync(path.join(dir, rel)));
+    }
+  };
+  loop('');
+  return files;
+}
+
+/**
+ * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
+ * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
+ */
+export function readArchiveZip(buf) {
+  const files = new Map();
+  const eocd = (() => {
+    for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
+    return -1;
+  })();
+  if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
+  const aantal = buf.readUInt16LE(eocd + 10);
+  let p = buf.readUInt32LE(eocd + 16);
+  for (let n = 0; n < aantal; n++) {
+    if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
+    const methode = buf.readUInt16LE(p + 10);
+    const gecomp = buf.readUInt32LE(p + 20);
+    const naamLen = buf.readUInt16LE(p + 28);
+    const extraLen = buf.readUInt16LE(p + 30);
+    const commentLen = buf.readUInt16LE(p + 32);
+    const lokaalOffset = buf.readUInt32LE(p + 42);
+    const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
+    const lNaam = buf.readUInt16LE(lokaalOffset + 26);
+    const lExtra = buf.readUInt16LE(lokaalOffset + 28);
+    const start = lokaalOffset + 30 + lNaam + lExtra;
+    const rauw = buf.subarray(start, start + gecomp);
+    if (!naam.endsWith('/')) {
+      files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
+    }
+    p += 46 + naamLen + extraLen + commentLen;
+  }
+  return files;
+}
+
+export function readArchive(bron) {
+  const st = fs.statSync(bron);
+  return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
+}
+
+// ── Importeren ────────────────────────────────────────────────────
+
+/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
+function veiligMediaPad(urlPad) {
+  if (!urlPad || !urlPad.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
+}
+
+/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
+function padVanOrigineel(u) {
+  const s = String(u || '');
+  if (s.startsWith('/media/')) return s;
+  try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
+}
+
+/**
+ * Zet een archief terug in een site.
+ *
+ * @param {Map<string,Buffer>} files  het ingelezen archief
+ * @param {object} opts  { slug, dryRun, overwrite, origin }
+ */
+export function importArchive(files, opts = {}) {
+  const rapport = {
+    formatVersion: null, origin: null, idsBehouden: null,
+    posts: 0, overgeslagen: 0, overschreven: 0,
+    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
+  };
+
+  const manifestBuf = files.get('manifest.json');
+  if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
+  const manifest = JSON.parse(manifestBuf.toString('utf8'));
+  rapport.formatVersion = manifest.formatVersion;
+
+  // VERSIE EERST, voordat er ook maar iets gelezen wordt.
+  if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
+  if (manifest.formatVersion > FORMAT_VERSION) {
+    throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
+  }
+
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
+  if (!site) throw new Error(`onbekende site: ${opts.slug}`);
+  const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  rapport.origin = manifest.origin || null;
+
+  // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
+  // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
+  // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
+  // servers halen het daar toch op.
+  const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
+  rapport.idsBehouden = idsBehouden;
+  if (!idsBehouden) {
+    rapport.waarschuwingen.push(
+      `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
+    );
+  }
+
+  const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
+  const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
+  const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
+  const idKaart = new Map();     // oud post-id -> nieuw post-id
+
+  const schrijf = [];            // alles eerst uitrekenen, dan in EEN transactie
+
+  for (const pad of postPaden) {
+    const o = JSON.parse(files.get(pad).toString('utf8'));
+    const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
+    const nieuwId = idsBehouden ? oudId : randomUUID();
+    idKaart.set(oudId, nieuwId);
+
+    const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
+    if (botsing && !opts.overwrite) {
+      // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
+      // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
+      // idempotent maakt.
+      rapport.overgeslagen += 1;
+      continue;
+    }
+    if (botsing) rapport.overschreven += 1;
+
+    // Media: terug naar hun oorspronkelijke plek onder /media, want de content
+    // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
+    // op een ander domein.
+    for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
+      if (a['shaer:availability'] === 'missing') {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
+        continue;
+      }
+      const bytes = files.get(a.url);
+      if (!bytes) {
+        // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
+        // archief, geen ontbrekende media -- apart melden, niet stil optellen.
+        rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
+        continue;
+      }
+      if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
+        rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
+        continue;
+      }
+      const doel = veiligMediaPad(padVanOrigineel(a['shaer:originalUrl']))
+        || veiligMediaPad(`/media/archief/${path.basename(a.url)}`);
+      if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
+      schrijf.push({ soort: 'media', doel, bytes });
+      rapport.media += 1;
+    }
+
+    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
+    rapport.posts += 1;
+  }
+
+  // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
+  for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
+    const coll = JSON.parse(files.get(pad).toString('utf8'));
+    if (coll['shaer:archive'] !== true) {
+      rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
+      continue;
+    }
+    const oudId = path.basename(pad, '.json');
+    const postId = idKaart.get(oudId);
+    if (!postId) continue;                       // post overgeslagen -> antwoorden ook
+    for (const it of (coll.orderedItems || [])) {
+      schrijf.push({ soort: 'reply', postId, it });
+      rapport.replies += 1;
+    }
+  }
+
+  if (opts.dryRun) return rapport;
+
+  // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
+  // uitkomst, want dan lijkt het gelukt.
+  const insPost = db.prepare(`INSERT OR REPLACE INTO posts
+    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
+     pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
+     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, origin_server)
+    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
+     @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
+     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, 'import')`);
+  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
+    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
+    VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
+
+  db.transaction(() => {
+    for (const s of schrijf) {
+      if (s.soort === 'media') {
+        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
+        fs.writeFileSync(s.doel, s.bytes);
+        continue;
+      }
+      if (s.soort === 'reply') {
+        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
+          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
+        continue;
+      }
+      const o = s.obj;
+      const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
+      insPost.run({
+        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
+        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
+        status: o['shaer:status'] || 'draft',
+        cover_image_url: null, cover_alt: o['shaer:coverAlt'] || null, cover_video_url: null,
+        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
+        tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
+        published_at: sqlTijd(o.published), created_at: sqlTijd(o.published), updated_at: sqlTijd(o.updated || o.published),
+        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: sqlTijd(o['shaer:publishAt']),
+        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
+        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
+        content_warning: o.summary || null,
+        poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
+        quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
+        ap_visibility: o['shaer:apVisibility'] || null,
+        paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
+        view_count: o['shaer:viewCount'] || 0,
+      });
+      // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
+      for (const t of (o['shaer:audio'] || [])) {
+        const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
+        if (!trackId) continue;
+        let mediaId = null;
+        const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
+        const doel = bij && veiligMediaPad(padVanOrigineel(bij['shaer:originalUrl']));
+        if (doel) {
+          mediaId = randomUUID();
+          try {
+            db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+              .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
+          } catch { mediaId = null; }
+        }
+        try {
+          db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
+                      VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
+            .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
+              mediaId, t.credit || null, t.license || null,
+              ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
+        } catch { /* geen audio-tabellen op deze installatie */ }
+      }
+    }
+  })();
+
+  return rapport;
+}
Index: test/archive-import.test.js
===================================================================
--- test/archive-import.test.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
+++ test/archive-import.test.js	(revision 58cfe5f79a0c04fbb8d1908fcd73af6c8e0b3c85)
@@ -0,0 +1,300 @@
+// De importer (shaer-pmr), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
+//
+// De zwaarste toets is de RONDGANG: exporteer de moeilijkste post die Klonkt kan
+// maken, gooi hem weg, lees hem terug, en kijk of er nog hetzelfde staat. Een
+// exporter en een importer die elk apart kloppen maar niet op elkaar aansluiten
+// zijn samen niets waard.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-imp-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AX = await import('../src/services/ArchiveExportService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
+const PLAATJE = Buffer.from('bytes-van-de-cover');
+fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
+
+db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, published_at,
+            created_at, updated_at, content_warning, nsfw, paid, paid_min_cents, quote_uri, quote_actor,
+            poll_json, language, tags, pinned, view_count)
+            VALUES ('zwaar','s1','zware-post','u1','Een zware post',
+            '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">','de teaser','published',
+            '2026-08-01 10:00:00','2026-08-01 10:00:00','2026-08-01 12:00:00','spoiler',1,1,500,
+            'https://mstdn.social/users/x/statuses/1','https://mstdn.social/users/x',
+            '{"multiple":false,"options":[{"name":"ja"},{"name":"nee"}],"endTime":"2026-08-09T10:00:00Z"}',
+            'nl','muziek, code',1,42)`).run();
+db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, created_at)
+            VALUES ('reply','zwaar',?,?,'Anna','@anna@mstdn.social','<p>hoi</p>','2026-08-02T10:00:00Z','2026-08-02 10:00:00')`)
+  .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
+
+const ARCHIEF = AX.buildArchive('me', { exportedAt: 'X' });
+const leeg = () => {
+  db.prepare('DELETE FROM posts').run();
+  db.prepare('DELETE FROM ap_interactions').run();
+  try { fs.rmSync(path.join(MEDIA, '2026'), { recursive: true, force: true }); } catch { /* niets */ }
+};
+
+test('de rondgang: wat eruit ging komt er hetzelfde weer in', () => {
+  leeg();
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(r.posts, 1);
+  assert.equal(r.idsBehouden, true, 'zelfde origin, dus de AP-ids blijven');
+
+  const p = db.prepare('SELECT * FROM posts WHERE id = ?').get('zwaar');
+  assert.ok(p, 'het post-id is behouden -- daar wijzen de boosts elders naar');
+  assert.equal(p.title, 'Een zware post');
+  assert.equal(p.slug, 'zware-post');
+  assert.match(p.content, /<p>body<\/p>/);
+  assert.equal(p.excerpt, 'de teaser');
+  assert.equal(p.content_warning, 'spoiler');
+  assert.equal(p.nsfw, 1);
+  assert.equal(p.paid, 1);
+  assert.equal(p.paid_min_cents, 500);
+  assert.equal(p.quote_uri, 'https://mstdn.social/users/x/statuses/1');
+  assert.equal(p.language, 'nl');
+  assert.equal(p.tags, 'muziek, code');
+  assert.equal(p.pinned, 1);
+  assert.equal(p.view_count, 42);
+  assert.deepEqual(JSON.parse(p.poll_json).options.map((o) => o.name), ['ja', 'nee']);
+});
+
+test('en de media staan terug op hun oude plek, want de content wijst daarheen', () => {
+  const op = path.join(MEDIA, '2026', 'cover.jpg');
+  assert.ok(fs.existsSync(op), 'anders is de <img> in de body een dode verwijzing');
+  assert.deepEqual(fs.readFileSync(op), PLAATJE);
+});
+
+test('de antwoorden komen terug als archief', () => {
+  const r = db.prepare("SELECT * FROM ap_interactions WHERE post_id = 'zwaar'").all();
+  assert.equal(r.length, 1);
+  assert.equal(r[0].actor_name, 'Anna');
+  assert.equal(r[0].object_uri, 'https://mstdn.social/users/anna/statuses/9');
+});
+
+test('twee keer importeren levert geen dubbele posts op', () => {
+  const voor = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(r.overgeslagen, 1, 'de bestaande post wordt overgeslagen, niet overschreven');
+  assert.equal(r.posts, 0);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, voor);
+});
+
+test('overschrijven gebeurt alleen als je erom vraagt', () => {
+  db.prepare("UPDATE posts SET title = 'handmatig gewijzigd' WHERE id = 'zwaar'").run();
+  AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'handmatig gewijzigd',
+    'zonder --overwrite blijft wat er staat staan');
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me', overwrite: true });
+  assert.equal(r.overschreven, 1);
+  assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'Een zware post');
+});
+
+test('een droogloop schrijft niets', () => {
+  leeg();
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me', dryRun: true });
+  assert.equal(r.posts, 1, 'hij vertelt wel wat er zou gebeuren');
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0);
+  assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
+});
+
+test('een nieuwere formaatversie wordt in zijn GEHEEL geweigerd', () => {
+  // Niet half lezen: een half begrepen herstel ziet eruit alsof het gelukt is.
+  const files = new Map(ARCHIEF.files);
+  const m = JSON.parse(files.get('manifest.json').toString());
+  m.formatVersion = 99;
+  files.set('manifest.json', Buffer.from(JSON.stringify(m)));
+  assert.throws(() => AI.importArchive(files, { slug: 'me' }), /nieuwer dan deze Klonkt kent/);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0, 'en er is niets geschreven');
+});
+
+test('een andere origin levert NIEUWE ids op, met een waarschuwing', () => {
+  // Oude ids houden op een ander domein zou objecten publiceren onder een id dat
+  // je niet beheert -- andere servers halen dat daar op, en het is bovendien een
+  // vervalsingsoppervlak.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const m = JSON.parse(files.get('manifest.json').toString());
+  m.origin = 'https://ergens-anders.test';
+  files.set('manifest.json', Buffer.from(JSON.stringify(m)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.idsBehouden, false);
+  assert.match(r.waarschuwingen.join(' '), /origin verschilt/);
+  assert.equal(db.prepare("SELECT COUNT(*) AS n FROM posts WHERE id = 'zwaar'").get().n, 0);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 1, 'wel geimporteerd, met een nieuw id');
+});
+
+test('ontbrekende media worden geteld en gemeld', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
+  const o = JSON.parse(files.get(pad).toString());
+  o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: 'https://oud.test/media/weg.jpg', 'shaer:availability': 'missing', 'shaer:originalUrl': 'https://oud.test/media/weg.jpg' }];
+  files.set(pad, Buffer.from(JSON.stringify(o)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.mediaMissing, 1);
+  assert.equal(r.gemist[0].url, 'https://oud.test/media/weg.jpg');
+});
+
+test('een archief dat zijn eigen bestand mist wordt apart gemeld', () => {
+  // 'included' met een ontbrekend bestand is een KAPOT archief, geen verdwenen
+  // media. Die twee op een hoop gooien verbergt een echt probleem.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  for (const k of [...files.keys()]) if (k.startsWith('media/')) files.delete(k);
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.mediaMissing, 0);
+  assert.match(r.waarschuwingen.join(' '), /dat er niet in zit/);
+});
+
+test('een bestand met een verkeerde checksum wordt niet weggeschreven', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const k = [...files.keys()].find((x) => x.startsWith('media/'));
+  files.set(k, Buffer.from('iets heel anders'));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.match(r.waarschuwingen.join(' '), /checksum klopt niet/);
+  assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
+});
+
+test('een antwoordenbundel zonder archiefmarkering wordt overgeslagen', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('replies/'));
+  const c = JSON.parse(files.get(pad).toString());
+  delete c['shaer:archive'];
+  files.set(pad, Buffer.from(JSON.stringify(c)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.replies, 0);
+  assert.match(r.waarschuwingen.join(' '), /niet gemarkeerd als archief/);
+});
+
+test('een pad buiten de media-map wordt niet geschreven', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
+  const o = JSON.parse(files.get(pad).toString());
+  const mk = [...files.keys()].find((k) => k.startsWith('media/'));
+  o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: mk, 'shaer:availability': 'included', 'shaer:originalUrl': '/media/../../../tmp/klonkt-inbraak' }];
+  files.set(pad, Buffer.from(JSON.stringify(o)));
+  AI.importArchive(files, { slug: 'me' });
+  assert.equal(fs.existsSync('/tmp/klonkt-inbraak'), false);
+});
+
+test('readable/ wordt nooit gelezen', () => {
+  // Dat is de hele reden dat het afgeleid is: twee bronnen die allebei gelezen
+  // worden gaan uiteenlopen.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  for (const k of [...files.keys()]) if (k.startsWith('readable/')) files.set(k, Buffer.from('ONZIN'));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.posts, 1);
+  assert.match(db.prepare("SELECT content FROM posts WHERE id = 'zwaar'").get().content, /<p>body<\/p>/);
+});
+
+test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
+  const zip = path.join(MEDIA, 'rond.zip');
+  fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
+  const uit = AI.readArchive(zip);
+  assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
+  for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
+});
+
+test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
+  // Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
+  // geen uitwisselformaat maar een privé-doosje.
+  const zlib = await import('zlib');
+  const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
+  const naam = Buffer.from('manifest.json', 'utf8');
+  const comp = zlib.deflateRawSync(inhoud);
+  const crc = (() => {
+    let c = -1;
+    for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
+    return (c ^ -1) >>> 0;
+  })();
+  const lh = Buffer.alloc(30);
+  lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
+  lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
+  lh.writeUInt16LE(naam.length, 26);
+  const ch = Buffer.alloc(46);
+  ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
+  ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
+  ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
+  eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
+  const zip = path.join(MEDIA, 'deflate.zip');
+  fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
+  assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
+});
+
+test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
+  leeg();
+  db.prepare('DELETE FROM audio_tracks').run();
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const WAV = Buffer.from('nep-audio-bytes');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
+              VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
+              VALUES ('audio','s1','met-audio','u1','Met audio','<p>luister</p>[[track:tr1]]','published','2026-08-03 10:00:00')`).run();
+
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  db.prepare('DELETE FROM posts').run();
+  db.prepare('DELETE FROM audio_tracks').run();
+  db.prepare('DELETE FROM media').run();
+  fs.rmSync(path.join(MEDIA, 'audio'), { recursive: true, force: true });
+
+  AI.importArchive(arch.files, { slug: 'me' });
+  const t = db.prepare("SELECT * FROM audio_tracks WHERE id = 'tr1'").get();
+  assert.ok(t, '[[track:tr1]] in de content valt anders op niets terug');
+  assert.equal(t.title, 'Kanonnen');
+  assert.equal(t.license, 'CC BY');
+  const m = db.prepare('SELECT * FROM media WHERE id = ?').get(t.media_id);
+  assert.ok(m && fs.existsSync(m.storage_path), 'en het bestand staat er weer');
+  assert.deepEqual(fs.readFileSync(m.storage_path), WAV);
+});
+
+test('een vroegtijdig gesloten poll blijft gesloten', () => {
+  // Gevonden bij de rondgang op echte beta-data: poll_json.closed viel weg, dus
+  // een poll die je met een einddatum in de TOEKOMST had gesloten stond na een
+  // herstel weer open. AS2 kent hiervoor `closed` op een Question.
+  leeg();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, poll_json)
+              VALUES ('dicht','s1','dichte-poll','u1','Dicht','<p>x</p>','published','2026-08-04 10:00:00',
+              '{"multiple":false,"options":[{"name":"a"},{"name":"b"}],"endTime":"2030-01-01T00:00:00Z","closed":true}')`).run();
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  assert.equal(JSON.parse(arch.files.get('posts/dicht.json').toString()).closed, true);
+  db.prepare('DELETE FROM posts').run();
+  AI.importArchive(arch.files, { slug: 'me' });
+  const d = JSON.parse(db.prepare("SELECT poll_json FROM posts WHERE id = 'dicht'").get().poll_json);
+  assert.equal(d.closed, true, 'anders staat een gesloten stemming na een herstel weer open');
+  assert.equal(d.endTime, '2030-01-01T00:00:00Z');
+});
+
+test('zonder manifest is het geen archief', () => {
+  const files = new Map(ARCHIEF.files);
+  files.delete('manifest.json');
+  assert.throws(() => AI.importArchive(files, { slug: 'me' }), /geen manifest/);
+});
+
+test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
