source: Klonkt/docs/EXPORT-FORMAT.md

main
Last change on this file was 6248497, checked in by roboburr <roboburr@…>, 5 weeks ago

Export en import kiezen de juiste instance, en tijdstempels zijn echt UTC

Twee fouten, allebei op productie gevonden en allebei van het soort dat SUCCES
meldt.

## 1. Het script opende de verkeerde database

Op een split install deelt elke Klonkt de code in /opt/klonkt maar staat zijn data
onder /var/lib/klonkt/<slug>/ met een eigen .env. De scripts lazen die .env niet,
dus database.js viel terug op storage/database.sqlite IN DE CODE-MAP. Op een
server die ooit de enkelvoudige opzet draaide ligt daar een achtergebleven oude
database.

Waargenomen: export-archive.mjs boiert schreef een archief van 444 bytes met
"posts: 0" en meldde dat het gelukt was, terwijl het in een oude lege database
keek. liz en sood gaven "onbekende site". Het script raakte die oude database
ook nog aan (boot-migraties), terwijl het alleen hoort te lezen.

Nu leest scripts/instance-env.mjs de .env van de instance (standaard
/var/lib/klonkt/<slug>/.env, of --data-root / --env) VOORDAT de service geladen
wordt -- database.js opent de database namelijk bij import. De uitvoer noemt
voortaan welke .env en welke database gebruikt zijn.

En zonder PUBLIC_BASE_URL stopt hij nu HARD in plaats van het als bijzin te
melden: zonder origin krijgt het archief een lege origin, en dan maakt een import
nieuwe AP-ids. Dan is het een kopie van de tekst en geen herstel -- precies de
belofte waar het formaat om draait.

Voor de importer weegt dit zwaarder dan voor de exporter: in de verkeerde
database schrijven draai je niet terug.

## 2. Tijdstempels schoven met de tijdzone van de machine

Vraag van Bart: normaliseert de export wel naar UTC? Nee.

SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar zonder
zone. Date.parse leest die vorm als LOKALE tijd. Op een machine in Amsterdam komt
daar 10:56:10Z uit: twee uur verschoven, in elk archief.

Geen enkele test kon dat zien, want de testmachine draait op UTC. En mijn eigen
rondgang-vergelijking plakte er zelf een Z achter, waarmee ik er precies overheen
keek.

toISO leest een zoneloze vorm nu expliciet als UTC. Nieuwe test die alleen onder
een niet-UTC zone iets bewijst; gecontroleerd dat hij onder Europe/Amsterdam
omvalt zonder de fix. Hele suite 577/577 onder zowel UTC als Europe/Amsterdam.

  • Property mode set to 100644
File size: 13.8 KB
Line 
1# Klonkt Content Archive, format version 1
2
3A portable archive of **your own content**: posts, their media, and — as a
4read-only historical record — the replies other people wrote underneath them.
5It can be exported from one Klonkt and imported into another, and it stays
6readable when Klonkt is gone.
7
8This is the format specification. It is written before the exporter and the
9importer exist, on purpose: without a written format each of them writes down
10its own assumptions, and then there are two sources of truth instead of one.
11
12## What this is not
13
14| | |
15|---|---|
16| **Storage backup** (`shaer-190t`) | A byte-level zip of the storage directory, including password hashes, sessions and the per-actor private RSA keys. A backup for Klonkt-to-Klonkt restore. Never hand it to anyone. |
17| **GDPR data export** (`shaer-0j2`) | Personal data for inspection. Different scope, different audience. |
18| **This format** | Your content, portable, importable, reproducible, and free of every credential. |
19
20They may share serialisation code. They do not share a purpose, and they must
21not quietly become the same file.
22
23## Container layout
24
25```
26manifest.json format version, origin, counts, checksums
27posts/<post-id>.json ONE post — canonical, the only thing import reads
28replies/<post-id>.json replies received under that post — read-only archive
29media/<sha256>.<ext> the bytes, content-addressed
30readable/<slug>.md rendered for humans — derived, NEVER read on import
31```
32
33Delivered as a zip. `<post-id>` is Klonkt's own post id, which is also the tail
34of the ActivityPub object id (`<origin>/ap/notes/<post-id>`). Using it as the
35filename means the identity is visible in the tree and no character escaping is
36needed. The slug lives inside the file, where it can change without renaming
37anything.
38
39## manifest.json
40
41```json
42{
43 "formatVersion": 1,
44 "generator": "klonkt/1.6.0",
45 "exportedAt": "2026-08-06T13:00:00Z",
46 "origin": "https://boiert.eu",
47 "actor": "https://boiert.eu/ap/users/boiert",
48 "site": { "slug": "boiert", "title": "Boiert" },
49 "counts": { "posts": 412, "replies": 1180, "media": 233, "mediaMissing": 7 },
50 "files": { "posts/abc123.json": "<sha256>", "media/<sha256>.jpg": "<sha256>" }
51}
52```
53
54`origin` is the field the importer cannot work without. It decides whether the
55ActivityPub ids may be kept (see [Import rules](#import-rules)). Everything else
56in the manifest is verification and reporting.
57
58`exportedAt` lives **only** here. It must not appear in any post file, or two
59exports of unchanged content would differ.
60
61## posts/&lt;post-id&gt;.json
62
63An ActivityStreams 2.0 object carrying the post **as authored**, with Klonkt's
64own fields in the `shaer:` namespace.
65
66### It is not what we federate, and that is deliberate
67
68`buildNote()` produces a *projection* for the fediverse, and that projection is
69lossy by design:
70
71- the title is baked into the content as a bold first line, because Mastodon
72 ignores a Note's `name`;
73- a paid post federates a **teaser only**, never the body;
74- `<img>` is stripped from the content and moved into `attachment`, because
75 Mastodon drops inline images;
76- when a post carries an external embed the image attachments are suppressed, so
77 the link card wins.
78
79Exporting that projection would mean archiving a paid post as its teaser. So the
80archive carries the authoring truth instead, and the differences are:
81
82| | federated (`buildNote`) | archive |
83|---|---|---|
84| type | always `Note` | `Article` when the post has a title, else `Note` |
85| title | inside `content` | `name` |
86| paid post body | teaser | full content |
87| inline images | stripped from `content` | left in `content`, and also listed in `attachment` |
88
89### Fields
90
91| AS2 / `shaer:` | source | notes |
92|---|---|---|
93| `id` | `<origin>/ap/notes/<posts.id>` | the federation identity; see import rules |
94| `type` | `posts.title` present? | `Article` / `Note` |
95| `attributedTo` | site actor | |
96| `name` | `posts.title` | plain text, not HTML |
97| `content` | `posts.content` | authored HTML, shorthand intact |
98| `contentMap` | `posts.language` | omitted when the post has no language |
99| `summary` | `posts.content_warning` | AS2 summary is the content warning |
100| `sensitive` | `posts.nsfw` | |
101| `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC. SQLite writes `CURRENT_TIMESTAMP` in UTC but without a zone, and `Date.parse` reads that shape as *local* time — so a timezone-less value is explicitly read as UTC before conversion. Klonkt stores timestamps in two spellings (`YYYY-MM-DD HH:MM:SS` and full ISO); the archive normalises to ISO. The **instant** survives a round trip, the spelling does not. |
102| `updated` | `posts.updated_at` | omitted when equal to `published` |
103| `url` | `<origin>/<slug>` | the human permalink |
104| `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media); each carries a `shaer:role` |
105| `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
106| `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
107| `quoteUrl`, `shaer:quoteActor` | `posts.quote_uri`, `quote_actor` | FEP-044f |
108| `shaer:slug` | `posts.slug` | |
109| `shaer:status` | `posts.status` | `draft` posts are yours too and are exported |
110| `shaer:excerpt` | `posts.excerpt` | |
111| `shaer:type` | `posts.type` | |
112| `shaer:pinned`, `shaer:noindex`, `shaer:fanOnly` | idem | |
113| `shaer:paid`, `shaer:paidMinCents` | idem | **see the warning below** |
114| `shaer:apVisibility` | `posts.ap_visibility` | |
115| `shaer:publishAt` | `posts.publish_at` | a scheduled post keeps its schedule |
116| `shaer:coverAlt` | `posts.cover_alt` | |
117| `shaer:viewCount` | `posts.view_count` | optional; a restore without it loses history |
118| `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) |
119
120**Deliberately not exported:** `yjs_binary` (collaborative editor state — large,
121regenerable, and meaningless elsewhere), `content_rendered` (derived from
122`content`; exporting it would create a second truth that can drift),
123`origin_server`, and every id that belongs to a foreign server.
124
125> **Warning — a paid post exports in the clear.** The archive contains the full
126> body of paywalled posts, because an archive that silently drops your own
127> content is worse than useless. An export of a site with paid posts must be
128> handled like the content itself.
129
130### Klonkt shorthand inside `content`
131
132`content` may contain Klonkt shorthand that other software will not understand:
133
134```
135[[track:<id>]] a hosted audio track → see shaer:audio
136[[album:<id>]] an album of tracks
137[[playlist:<id>]] a playlist
138[[embed:<url>]] an external player (Spotify, YouTube, SoundCloud, …)
139```
140
141They are left in place, because resolving them at export would destroy the
142authored source. A foreign consumer should either strip them or resolve them
143using `shaer:audio`. `readable/<slug>.md` has them rendered.
144
145## Media
146
147Media are content-addressed by the SHA-256 of the bytes, so two exports of the
148same content produce the same filenames and duplicates collapse on their own.
149
150An attachment has three possible states, and the format must be able to say
151which one applies. Two are not enough: at Boiert we will know that a picture
152existed and where it lived, without having the bytes. An empty attachment list
153would be a lie, and a reference to a missing file would be a broken archive.
154
155```json
156{
157 "type": "Image",
158 "mediaType": "image/jpeg",
159 "name": "alt text",
160 "url": "media/9f86d0….jpg",
161 "shaer:availability": "included",
162 "shaer:originalUrl": "https://boiert.eu/media/2026/cover.jpg",
163 "shaer:sha256": "9f86d0…"
164}
165```
166
167| `shaer:availability` | meaning | `url` |
168|---|---|---|
169| `included` | the bytes are in `media/` | container-relative path |
170| `missing` | it existed, we know where, we do not have it | the original absolute URL |
171
172Every attachment also carries **`shaer:role`**, saying what it was for:
173
174| role | restored to |
175|---|---|
176| `cover` / `coverVideo` | `posts.cover_image_url` / `cover_video_url` |
177| `inline` | already referenced from `content` |
178| `c2s` | an entry in `posts.c2s_attachments` |
179| `poster` | the `poster` of the `c2s` entry named in `shaer:posterFor` |
180| `track` | the file behind a `[[track:id]]`, linked from `shaer:audio` |
181
182The role is not decoration. Without it the archive holds the bytes but not the
183fact that they *were the cover*, and the post comes back without one — which is
184invisible until you compare every column, not a handful.
185
186`url` for an included attachment is a **container-relative path**, not a URL. An
187importer must rewrite it. This is the one place where the archive deviates from
188strict AS2, and it is the price of carrying bytes at all.
189
190An importer must **count and report** every `missing` attachment. It must never
191silently drop one: a post whose picture has vanished should say so.
192
193## replies/&lt;post-id&gt;.json
194
195An AS2 `OrderedCollection` of the replies received under that post, as received.
196
197```json
198{
199 "type": "OrderedCollection",
200 "shaer:archive": true,
201 "shaer:inReplyTo": "https://boiert.eu/ap/notes/abc123",
202 "orderedItems": [ { "id": "https://mstdn.social/users/x/statuses/1", "…": "…" } ]
203}
204```
205
206This is **other people's content**. Three rules follow, and none is optional:
207
2081. It is never re-delivered, produces no notifications and no push. It is
209 history, not traffic.
2102. Their ids belong to their servers. We carry them as they are and never claim
211 them.
2123. Because of this section an export is not purely "your data". That is exactly
213 why this format and the GDPR export (`shaer-0j2`) have different scopes and
214 must not silently become the same file.
215
216`shaer:archive: true` is what an importer keys on. An importer that does not
217understand this section must skip it, never read it as own content.
218
219## readable/&lt;slug&gt;.md
220
221Markdown with YAML front matter, rendered from the canonical file: title, date,
222content with the shorthand resolved, images as links into `media/`.
223
224It exists so the archive survives Klonkt itself. It is **derived** and travels
225in one direction only — the importer never reads it. This is not tidiness: an
226archive whose Markdown and JSON are both read is an archive with two sources of
227truth, and they will drift.
228
229## Versioning
230
231`formatVersion` is an integer in `manifest.json`.
232
233- Adding a property does **not** bump it. Readers must ignore properties they do
234 not know.
235- Changing or removing the meaning of a property bumps it.
236- An importer that meets a `formatVersion` **higher than it knows must refuse the
237 whole archive** and say so. It must never read it partially: a half-understood
238 restore is worse than no restore, because it looks like it worked.
239
240## Import rules
241
242**Identity.** Compare `manifest.origin` with the importing site's own origin.
243
244- Equal → **keep the ActivityPub ids**. This is what makes a restore a restore:
245 the boosts, likes and replies that already point at those ids find their post
246 again.
247- Different → **mint new ids**, and keep the original `url` as a historical
248 reference. Keeping foreign ids would mean publishing objects under an id you
249 do not control; remote servers resolve such an id at its original host, and it
250 is a forgery surface besides.
251
252The importer determines this itself. It is not a question for the user, because
253a wrong answer cannot be taken back.
254
255**Consequences of keeping ids.** The post id is part of the identity, so the
256importer must be able to *set* the primary key rather than let it be generated.
257That is a different write path from creating a post, and it must not be reachable
258outside the import.
259
260**No `Update` broadcast.** If the restored content differs from what other
261servers cached long ago, their copy stays stale. Sending `Update` activities
262would fix that, but it is a broadcast to the whole fediverse and must never be a
263side effect of an import. Separate, deliberate action.
264
265**Dry run.** The importer offers a mode that reports what would happen —
266including the missing-media count — before anything is written.
267
268**Idempotent.** Importing the same archive twice produces no duplicates. On a
269collision with an existing id the importer follows one documented rule (skip,
270overwrite, or refuse and report); it never guesses per post.
271
272## Reproducibility
273
274Two exports of unchanged content must be **byte-identical except for
275`manifest.exportedAt`**, which is by definition the moment of export. Everything
276else — every post file, every media file, and the `files` checksum map — must
277match. That is what makes an archive verifiable and a diff meaningful: if the
278checksum map is equal, nothing changed.
279
280- JSON: keys sorted, two-space indent, LF, trailing newline.
281- Posts ordered by `published`, then by `id` for ties.
282- No timestamps inside post files; `exportedAt` lives only in the manifest.
283- Zip entries in sorted path order, with a fixed modification time.
284- Media names derive from the bytes, so they never depend on export order.
285
286## Worked example: the hardest post
287
288The format is tested against the most demanding post Klonkt can produce, not the
289easiest: a **poll** with **attachments**, a **quote card**, **custom emoji**, a
290**content warning**, and **paid** access.
291
292- poll → `type: "Question"` with `oneOf`/`anyOf` and `endTime`; the votes are
293 federation state, not content, and are not exported;
294- attachments → `attachment[]`, each with its own availability;
295- quote → `quoteUrl` + `shaer:quoteActor` (FEP-044f);
296- custom emoji → `tag[]` entries of `toot:Emoji`, whose icons are media like any
297 other and are content-addressed the same way;
298- content warning → `summary` + `sensitive: true`;
299- paid → full `content` plus `shaer:paid`, with the warning above.
300
301A post that survives this round trip unchanged is the acceptance test for
302slices 2 and 3.
Note: See TracBrowser for help on using the repository browser.