SPDX-License-Identifier: AGPL-3.0-or-later
File format: etherpad-nextcloud/1
The .pad file consists of:
- YAML frontmatter (metadata)
- Snapshot body with text and optional HTML
Required fields:
formatfile_idpad_idaccess_mode(public|protected)state(active; legacy files may still containtrashedorpurged)created_at(ISO8601)updated_at(ISO8601)snapshot_rev(int,-1before first successful sync)
Additional fields:
deleted_at(nullor ISO8601)pad_url(optional, absolutehttp(s)URL)pad_origin(optional, origin of external Etherpad server, e.g.https://pad.example.org)remote_pad_id(optional, actual pad ID on external server)
Example:
---
format: "etherpad-nextcloud/1"
file_id: 994
pad_id: "g.TmDeyA334sIq2LQh$p-4k9x2m7q8r1t5v6n3d0c"
access_mode: "protected"
state: "active"
deleted_at: null
created_at: "2026-03-05T00:40:36+00:00"
updated_at: "2026-03-05T11:10:21+00:00"
snapshot_rev: 42
---Legacy migration:
- Old Ownpad format
[InternetShortcut]URL=https://.../p/<pad-id>- is auto-migrated on first open to
etherpad-nextcloud/1. The migration branches on the URL origin (same vs. cross) and the pad-id format; GroupPad IDs (g.<group>$<name>) re-bind as protected, free-form IDs re-bind as public, cross-origin URLs route through the external-pad flow asext.*. - A claim-collision check protects against legacy files being used to claim pads already bound to another user's file — see
docs/legacy-ownpad-migration.mdfor the full state table.
Body layout:
[TEXT]
<plain text snapshot>
[HTML-BEGIN]
<html snapshot>
[HTML-END]
Notes:
- Text is the primary restore snapshot.
- HTML is an additional structure/format snapshot.
- External pads (
pad_origin+remote_pad_id) are imported and synced as text only for security reasons; their HTML section is written empty rather than omitted. - Every stored snapshot writes both sections. (A document that has never synced carries
snapshot_rev: -1and an empty body - no sections at all.) A snapshot with no HTML half - an external pad, whose HTML is deliberately never fetched - gets[HTML-BEGIN]/[HTML-END]with nothing between them. That is what lets the markers say where a half ends: a body always ends in the terminator and always holds an opening marker, so the last opening marker before the terminator is always the structural one, whatever the pad's own text contains. - The split relies on the HTML half carrying no
[HTML-BEGIN]on a line of its own. Etherpad assembles its export without line separators, so nothing the app fetches produces that shape. This is a property of the export rather than one the format enforces: a hand-written HTML half holding that line takes the split with it, the same way a hand-edited file can break anything else here. Escaping it was tried and dropped - an escape a reader must undo cannot be told apart from content that was already indented, so it would need a format version to be safe, which is the thing this design avoids. - The pad's text may contain either marker freely, on as many lines as it likes. It is only ever the last occurrence that is structure.
- A body written before this - a text-only snapshot with no section at all - is read as all text. That reading stays ambiguous for the one shape it always was: a text ending in
[HTML-END]. Nothing the app writes produces that shape any more. - Viewer/API responses never expose stored HTML. The read-only viewer is served by
LivePadHtmlFetcher, which fetches the pad's current HTML and runsSnapshotHtmlSanitizerover it. That sanitizer allowlists simple formatting tags and drops every attribute excepthrefon<a>, which survives only forhttp,httpsandmailto; the browser applies the same allowlist again before the HTML is injected.
- Internal + Protected
access_mode: protectedpad_id: GroupPad (g.<group>$<name>)pad_url: internal Etherpad URL
- Internal + Public
access_mode: publicpad_id: public pad ID (for examplenc-...)pad_url: internal Etherpad URL
- External + Public
access_mode: publicpad_id: external marker (ext.<remote_pad_id>)pad_origin+remote_pad_idare setpad_url: external URL used for viewer open- no row in
ep_pad_bindings; the.padfrontmatter is the source of truth for the remote target
Protected + external is not supported.
active- normal editing state
trashed/purged- legacy parser compatibility only; new writes do not use these states
The DB binding table uses active, and pending_delete for a file deleted for good whose pad
has yet to go. A trash keeps the binding row and the pad; a file restored without a row, or
whose pad Etherpad lost, gets a new pad from the .pad frontmatter and snapshot.
External pads are not managed in the DB binding table, so trash/restore only moves
the Nextcloud file and never creates, deletes, or restores anything on the remote
Etherpad server.
Implementation: lib/Service/PadFileService.php
parsePadFile(string $content): array{frontmatter, body}serialize(array $frontmatter, string $body): stringreadPad(string $content): ParsedPadFileparses once and hands back the frontmatter, the body and the fields derived from themwithExportSnapshot(ParsedPadFile $pad, PadSnapshot $snapshot)updates export metadata + snapshot bodywithRestoredSnapshot(ParsedPadFile $pad, ...)writes the document a restore leaves behind: active, undeleted, pointed at the replacement pad, both snapshot halves as given.snapshot_revis the replacement's own count once the snapshot is in it,-1when unknown, never the old pad'sgetSnapshotPartsFromBody(string $body): array{text, html}splits a stored snapshot into its two halves, from a body a caller already hasbuildInitialDocument(...)takes an optionalPadSnapshotfor a document that starts out with content; without one the document is unsnapshotted (snapshot_rev: -1) and its body is emptyPadSnapshotis text, an HTML half and the revision the snapshot was taken at. Itshtmlis a string, empty where a pad has none: every stored snapshot writes both sections either way. A negative revision is refused —-1is what an unsnapshotted document uses
Frontmatter values are held to what the format can carry back: a value containing a line terminator (\n, \r) or a NUL byte is refused with PadFileFormatException rather than written. The block is line-based, so a value with a newline in it would parse back as a further key on the next read, and a .pad that says something different after a round trip is not a .pad. The check runs on write, where the whole value is still in hand, and on read against each frontmatter line before anything is matched or trimmed - early enough that a \r a key pattern would swallow as whitespace, or one trim() would drop from an end, is refused rather than silently removed. A CRLF line ending is normalised while the document is split and never reaches it. The read side cannot see a newline inside a value at all, because the block is split into lines first: a hand-edited file that breaks a value across two lines is read as two keys, not as one broken value, and the write-side refusal is what keeps the app from ever producing one.
Snapshot write flow:
PadFileService::withExportSnapshot(...)builds the new.padcontent after an Etherpad export.PadFileLockRetryService::putContentWithSyncLockRetry(...)writes that content back to the Nextcloud file with bounded lock retry.- Stored snapshots are read by
RestoreServicewhen restoring a pad, by the forced sync when comparing content, and by an open that may write and the sync to tell whether Etherpad has lost the pad. No viewer path shows them. - External public pad create/sync paths both use the validated, host-pinned
/export/txtfetch internally (viaExternalPadExportFetcher) and store no HTML snapshot:- create uses
ExternalPadExportFetcher::normalizeAndFetchExternalPublicPadTextOrEmpty(...), allowing the.padfile to be created with an empty initial snapshot if the export is not available yet. - sync uses
ExternalPadExportFetcher::normalizeAndFetchExternalPublicPadText(...), keeping later export failures visible.
- create uses
- Sync writes only when the upstream snapshot actually differs.
force=1requests an immediate upstream re-check, but unchanged snapshots are still not rewritten.- A pad Etherpad made anew in place of the file's - without a single revision, with other text than the file saved - is not written, forced or not: the sync answers
pad_missing, and the file keeps its content for a new pad. - On successful sync:
snapshot_revis updated- body is replaced:
- internal pads: current text + HTML
- external pads: current text, no HTML