SPDX-License-Identifier: AGPL-3.0-or-later
The etherpad_nextcloud app integrates Etherpad for .pad files in Nextcloud with a native-viewer-first approach.
Etherpad is the editing source of truth; the .pad file acts as binding storage and snapshot container.
lib/Service/BindingService.php- Manages the central DB table
ep_pad_bindings. - Owns mapping
file_id <-> pad_idand states (active,pending_delete). - Only managed internal pads are bound. External pads are represented solely by
.padfrontmatter and snapshots.
- Manages the central DB table
lib/Service/LifecycleService.php- Trash/restore flow.
- Snapshot on trash, re-provisioning on restore.
- On Etherpad delete failures:
pending_deleteinstead of blocking Nextcloud trash.
lib/BackgroundJob/*PendingDeleteRetryJob.php- Bucketed retry for deferred Etherpad deletions:
- hot rows: every 5 minutes for the first hour after trash (
deleted_at <= 1h ago) - warm rows: hourly from 1h to 24h after trash
- cold rows: daily after 24h
- hot rows: every 5 minutes for the first hour after trash (
- Bucketed retry for deferred Etherpad deletions:
lib/Service/EtherpadClient.php- Adapter for Etherpad HTTP API (pad create/delete/session/read-only/export).
lib/Service/PadFileService.php- Parser/serializer for
.padv1 (YAML + snapshot body). - Revision metadata and snapshot body structure (
[TEXT],[HTML-BEGIN],[HTML-END]).
- Parser/serializer for
lib/Service/PadSessionService.php- Session-cookie flow for protected GroupPads.
lib/Service/ConsistencyCheckService.php- Optional admin integrity scan:
- bindings without file
.padfiles without binding- invalid/mismatching frontmatter on bound files
- Optional admin integrity scan:
lib/Controller/ViewerController.php- Compatibility redirect adapter:
- resolves
.padpath/id to stable Nextcloud files viewer URL.
- resolves
- Compatibility redirect adapter:
lib/Controller/PublicViewerController.php- Public-share API + compatibility redirect adapter (
/public/{token}->/s/{token}with file selection).
- Public-share API + compatibility redirect adapter (
lib/Controller/EmbedController.php- Minimal embed entrypoints for trusted same-site / trusted-origin integrations.
- Renders blank embed/open and embed/create pages with route-specific CSP
frame-ancestors.
lib/Controller/PadCreateController.php,lib/Controller/PadSessionController.php,lib/Controller/PadLifecycleController.php(all extendAbstractPadControllerfor the shared deps + helpers)- The
.padAPI surface split along three concerns: create-side endpoints, open/init/meta endpoints, and lifecycle/sync endpoints. Public URL paths (/api/v1/pads/…) are stable; only the internal controller class differs per route. - For protected pad opens,
PadSessionControllerattaches the explicit EtherpadSet-Cookiesession header via the response.
- The
Frontend code is authored as ES modules in src/ and built with Vite into
checked-in runtime assets in js/.
- Build entrypoints are defined in
vite.config.js:src/files-main.jssrc/viewer-main.jssrc/embed-main.jssrc/embed-create-main.jssrc/admin-settings.js
- Shared browser/Nextcloud helpers live in
src/lib/. - Files-app specific modules live in
src/files/. - Nextcloud loads built assets from
js/viaUtil::addScript(...); blank embed templates load their built bundles explicitly. - After editing
src/, runnpm testandnpm run buildbefore deployment.
- DB table
ep_pad_bindings(migration:lib/Migration/Version000001Date20260304222000.php)file_idpad_idaccess_modestatedeleted_atcreated_atupdated_at- stores internal managed pads only; external
ext.*rows from earlier development versions are removed byVersion000003Date20260512230000
.padfile- Frontmatter: format, binding metadata, state, export metadata.
- Body: text and HTML snapshot.
- For external pads, frontmatter (
pad_origin,remote_pad_id,pad_url) is the source of truth and no DB binding exists.
- Snapshot helpers
PadFileService::withExportSnapshot(...)constructs updated.padcontent for snapshot writes.PadFileLockRetryService::putContentWithSyncLockRetry(...)persists that content to the Nextcloud file.SnapshotExtractoronly reads stored snapshot text + sanitized HTML for viewers.
PadCreateController::createcreates an Etherpad pad (public or protected/group).- Creates the
.padfile. - Writes initial frontmatter.
- Creates DB binding.
- External create-from-URL is different: it only creates the
.padfile with external frontmatter plus an optional text snapshot; it does not create or own anything on the remote Etherpad server.
Primary flow (native viewer):
src/files-main.jsopens.padin Nextcloud Files viewer route (/apps/files/files/{fileId}?openfile=true).- on authenticated files routes, it now extracts the stable
fileIddirectly from the Nextcloud file-action context whenever available - path-based resolve is only a fallback for contexts without a usable
fileId
- on authenticated files routes, it now extracts the stable
src/viewer-main.jsresolves Etherpad open data via API:- preferred:
POST /api/v1/pads/open-by-id(fileId, CSRFrequesttoken) - fallback:
POST /api/v1/pads/open(file, CSRFrequesttoken) if no stablefileIdis available
- preferred:
PadSessionControllervalidates frontmatter/binding and resolves secure open URL:protected: session URL viaPadSessionService- external: validate the stored external URL and return a read-only snapshot/open target without DB binding lookup
public: direct/read-only URL as appropriate
- For protected pads, response includes one Etherpad session
Set-Cookieheader. - Legacy app routes (
/apps/etherpad_nextcloud,/by-id/{fileId}) redirect into the same native files viewer URL.
Primary flow (minimal blank embed page):
- External same-site / trusted-origin host loads
GET /apps/etherpad_nextcloud/embed/by-id/{fileId}inside an iframe. EmbedController::showByIdvalidates:- logged-in Nextcloud user
- accessible
.padfile by stablefileId
templates/embed.phploads the Vite-built bundle forsrc/embed-main.jsexplicitly because blank layouts do not rely on Nextcloud asset collector injection.src/embed-main.jscallsPOST /api/v1/pads/open-by-idsame-origin with CSRF token baked into the template.- because blank layout does not inject the normal
OC.requestTokenbootstrap - and this Nextcloud version exposes no public
OCP\...CSRF-token service for that template use-case EmbedControllertherefore passes the encrypted token manually from the internal CSRF token manager
- because blank layout does not inject the normal
- On
Missing YAML frontmatter, the embed page retries once afterPOST /api/v1/pads/initialize-by-id/{fileId}. - As soon as
open-by-idreturnsurl, the iframesrcis set to the Etherpad target. - Sync and host-message handlers are installed after iframe start so initial visual load is not delayed by background setup.
Trusted host integration details:
- Embed routes use route-specific
frame-ancestorsfrom admin settingtrusted_embed_origins. src/embed-main.jsaccepts host messages only from:window.location.origin- configured trusted embed origins
- Supported host messages:
epnc:host-visibleepnc:host-hiddenepnc:host-before-closeepnc:host-sync-now
- Close handshake:
- host sends
epnc:host-before-close - embed replies with
epnc:sync-flush-started - then
epnc:sync-flush-finishedorepnc:sync-flush-failed - host should wait briefly for that ack before unmounting the iframe
- host sends
Primary flow (minimal blank create launcher page):
- External same-site / trusted-origin host loads
GET /apps/etherpad_nextcloud/embed/create-by-parent/{parentFolderId}?name=...&accessMode=.... EmbedController::createByParentvalidates:- logged-in Nextcloud user
- writable target folder by stable
parentFolderId
templates/embed-create.phploads the Vite-built bundle forsrc/embed-create-main.jsexplicitly in blank layout.src/embed-create-main.jsreadsnameandaccessModefrom the launcher URL, validates them client-side, and callsPOST /api/v1/pads/create-by-parentsame-origin with CSRF token from the template.- the token is injected manually for the same reason as embed-open: blank layout has no automatic
OC.requestTokenbootstrap
- the token is injected manually for the same reason as embed-open: blank layout has no automatic
PadCreateController::createByParentperforms server-side validation ofname,accessMode, and the writable target folder before creating the.padfile and binding.- Before redirecting,
src/embed-create-main.jsposts the host page one of two structured events so the surrounding UI can react without scraping the iframe DOM:epnc:create-succeeded— payload{embed_url, file_id, pad_id, access_mode}. Fires once on the success path, immediately before the iframe self-redirects to the embed-open URL.epnc:create-failed— payload{reason, status, message}. Fires on any error.reasonis one of'invalid'(client-side validation),'conflict'(HTTP 409 — e.g. duplicate filename),'server'(any other 4xx/5xx), or'network'(fetch itself failed). The inline error rendering inside the iframe is unchanged —postMessageis purely additive for hosts that want to act on the outcome. Target-origin is*because the page doesn't know the host's origin up-front; theframe-ancestorsallowlist already constrains who can be the parent.
- On success the launcher redirects itself to the returned
embed_url, after which the normal embed-open flow takes over.
Primary flow (native viewer when available):
- Public share routes stay on Nextcloud share URL (
/s/{token}). src/viewer-main.jsdetects public share context and resolves open data via:GET /api/v1/public/open/{token}?file=...
- Same open-target rules apply:
- read-only share: Etherpad read-only URL
- editable share: regular URL/session
- For protected share-open flows, session bootstrap uses one explicit
Set-Cookieheader. - Compatibility route
/apps/etherpad_nextcloud/public/{token}redirects to native share route/s/{token}.
- Cookie construction is centralized in
PadSessionService(buildSetCookieHeader()). - We intentionally use explicit attributes required for Etherpad iframe sessions across subdomains:
DomainSecureSameSite=None
- Domain source:
- explicit
etherpad_cookie_domainapp setting when configured - otherwise derived from
etherpad_hostwith label-aware fallback rules - explicit config is recommended for proxy-heavy or non-standard subdomain setups
- explicit
- Current app-level contract:
- one custom Etherpad
Set-Cookieline per protected-open response - no additional app-level custom cookies on these same responses
- one custom Etherpad
- If we later need multiple custom cookies on the same response, header handling must be extended as a dedicated change (with targeted controller tests), because multi-
Set-Cookiebehavior is a framework-sensitive edge case.
- Frontend (
src/viewer-main.js) triggers periodic sync while a pad is open in native viewer. - Trusted embed flow (
src/embed-main.js) runs the same snapshot sync contract:- interval sync while visible
- flush on
visibilitychange - flush on
pagehide - extra flush triggers via trusted host messages
PadLifecycleController::syncByIdfetches revision state from Etherpad..padsnapshot is updated only when the upstream snapshot actually differs.force=1requests an immediate upstream re-check, but unchanged snapshots are still not rewritten.- Snapshot writes are built via
PadFileService::withExportSnapshot(...)and persisted viaPadFileLockRetryService::putContentWithSyncLockRetry(...).
- External pads are synced as text only (no HTML import).
- They are selected by
.padfrontmatter, not byep_pad_bindings.
- They are selected by
- Write-lock handling:
- short bounded retry around
.padsnapshot writes (150ms,300ms,600ms) - if still locked, API returns
status=lockedandretryable=true
- short bounded retry around
- Revision-based status check is still exposed for programmatic use:
GET /api/v1/pads/sync-status/{fileId}comparessnapshot_revwithcurrent_rev.POST /api/v1/pads/sync/{fileId}?force=1can be invoked to trigger an immediate snapshot write.- There is no UI affordance for either; the viewer drives sync automatically.
- Trash: persist a fresh snapshot if possible, delete the managed Etherpad pad, then delete the binding row.
- If Etherpad is unavailable during delete: switch state to
pending_delete, keep Nextcloud trash successful. - Restore: provision a new pad from
.padfrontmatter/snapshot when no binding row exists, or finish apending_deleterow if one remains. - External pads skip lifecycle side effects entirely. Trash/restore only affects the Nextcloud file; the remote Etherpad server is never mutated.
- Pending-delete cleanup is retried in age buckets: first every 5 minutes, then hourly, then daily.
- Admin runs
POST /api/v1/admin/consistency-check. - Service scans DB/file metadata consistency.
- Returns aggregate counters and bounded sample lists for diagnostics.
- External
.padfiles without bindings are expected and are excluded from missing-binding diagnostics.
src/files-main.js- Thin files-app entrypoint that wires the modules below.
src/files/open-action.js- Registers the authenticated
.paddefault file action. - Uses stable
fileIdfrom the Files action context whenever available.
- Registers the authenticated
src/files/pad-opener.js- Opens authenticated
.padfiles through Nextcloud router withfileid+openfile=true. - Target route:
/index.php/apps/files/files/{fileId}?dir=...&editing=false&openfile=true. - Falls back to hard navigation when the native viewer/router path cannot be used.
- Opens authenticated
src/files/created-pad-opener.js- Handles direct viewer open after creating a new public pad.
- Emits the Nextcloud Files creation event, waits briefly for SPA state registration, then calls native Viewer open.
src/files/route-controller.js- Watches Files/public-share route changes.
- Normalizes stale
.padroutes withoutopenfile=trueback to folder routes. - Opens public-share pad links through the native viewer when available.
src/files/public-pad-menu.js+ Neuintegration forPublic padwith runtime capability checks:- modern API:
addNewFileMenuEntry/getNewFileMenu().registerEntry - legacy API fallback:
OC.Plugins.register('OCA.Files.NewFileMenu', ...)
- modern API:
src/files/public-share-pad-links.js- Public-share click interception for download links that need remapping to the pad viewer.
- Authenticated Files routes intentionally do not use global click interception.
src/files/public-single-share-ui.js- Public single-file share UI state refresh.
src/files/public-pad-create-flow.jsandsrc/files/pad-create-dialogs.js- Public pad creation flow and modal UI.
src/viewer-main.js- Registers Nextcloud viewer handler for MIME
application/x-etherpad-nextcloud. - Open URL resolution via CSRF-protected
POSTendpoints:open-by-id(preferred)open(fallback)
- Handles initialize-retry when frontmatter is missing.
- Triggers periodic/unload-safe sync loop for authenticated native viewer sessions.
- Registers Nextcloud viewer handler for MIME
src/embed-main.js- Powers the minimal
/embed/by-id/{fileId}page for trusted host integrations. - Same-origin open flow via
open-by-idand optionalinitialize-by-idretry. - Sets iframe
srcas early as possible, then starts sync/host handlers. - Implements trusted host message contract and close-flush ack protocol.
- Powers the minimal
src/embed-create-main.js- Powers the minimal
/embed/create-by-parent/{parentFolderId}launcher page. - Same-origin create flow via
POST /api/v1/pads/create-by-parent. - Redirects to returned
embed_urlafter successful creation.
- Powers the minimal
src/lib/*- Shared constants, URL builders/parsers, Nextcloud runtime helpers, OC compatibility helpers, DOM helpers, and API client code.
OCA\Files\Event\LoadAdditionalScriptsEvent- Load scripts for files app.
OCA\Viewer\Event\LoadViewer- Load viewer handler.
OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent- Load scripts on public-share pages.
OCA\Files_Trashbin\Events\MoveToTrashEvent- Trash lifecycle.
OCA\Files_Trashbin\Events\NodeRestoredEvent- Restore lifecycle.