SPDX-License-Identifier: AGPL-3.0-or-later
Base: /apps/etherpad_nextcloud
-
GET /- Controller:
ViewerController::showPad - Query:
file=/path/to/file.pad - Purpose: compatibility entry route that redirects to native Files viewer URL.
- Controller:
-
GET /by-id/{fileId}- Controller:
ViewerController::showPadById - Purpose: compatibility entry route via file ID; redirects to native Files viewer URL.
- Controller:
-
GET /embed/by-id/{fileId}- Controller:
EmbedController::showById - Purpose: minimal authenticated embed page for trusted same-site / trusted-origin integrations.
- Behavior:
- requires a logged-in Nextcloud user
- validates that
fileIdresolves to an accessible.padfile in the user's file tree - renders a blank embed page that internally calls
open-by-id - injects CSRF token manually into the blank template because this layout does not receive the normal
OC.requestTokenbootstrap - if open fails with
Missing YAML frontmatter, the embed page retries once afterinitialize-by-id/{fileId} - sets route-specific
frame-ancestorsfrom admin-configured trusted embed origins
- Host message contract:
- accepted incoming messages from trusted origins:
epnc:host-visibleepnc:host-hiddenepnc:host-before-closeepnc:host-sync-now
- emitted replies to the sending host origin:
epnc:sync-flush-startedepnc:sync-flush-finishedepnc:sync-flush-failed
- intended use:
- host sends
epnc:host-before-close - waits briefly for
epnc:sync-flush-finishedorepnc:sync-flush-failed - only then unmounts the iframe
- host sends
- accepted incoming messages from trusted origins:
- Controller:
-
GET /embed/create-by-parent/{parentFolderId}- Controller:
EmbedController::createByParent - Query:
name(required)accessMode(public|protected, optional, defaultprotected)
- Purpose: minimal authenticated create launcher page for trusted same-site / trusted-origin integrations.
- Behavior:
- requires a logged-in Nextcloud user
- validates that
parentFolderIdresolves to an accessible writable folder in the user's file tree - renders a blank page that internally calls
POST /api/v1/pads/create-by-parentsame-origin with CSRF token - injects CSRF token manually into the blank template because this layout does not receive the normal
OC.requestTokenbootstrap - on success redirects itself to the returned
embed_url - sets route-specific
frame-ancestorsfrom admin-configured trusted embed origins
- Controller:
-
GET /public/{token}- Controller:
PublicViewerController::showPad - Query (folder share):
file=/subfolder/file.pad - Purpose: compatibility route for public shares; redirects to
/s/{token}with selected file. - UX behavior:
- Errors are rendered as
noviewertemplate (not raw JSON). - Error page includes back-link to share entry page (
/s/{token}).
- Errors are rendered as
- Controller:
-
GET /api/v1/public/open/{token}- Controller:
PublicViewerController::openPadData - Query (folder share):
file=/subfolder/file.pad - Purpose: resolves a
.padfile inside a public share for the native viewer. - Result:
- writable protected share: Etherpad URL plus one
sessionIDSet-Cookieheader - read-only protected share:
is_readonly_snapshot=true, emptyurl,snapshot_text, and sanitizedsnapshot_html; no Etherpad session cookie - public/external pad share: regular public Etherpad URL
- writable protected share: Etherpad URL plus one
- Controller:
Admins can switch either pad type off (see the admin settings). POST /pads
and POST /pads/create-by-parent refuse a disabled accessMode with 403.
Pads that already exist are unaffected.
POST /pads/from-template and the initialize endpoints do not refuse a
disabled mode — they create the pad in the enabled mode instead, so a
template or a .pad that arrived outside the UI stays usable. They only
return 403 when no pad type is enabled at all. Note this can widen access:
a protected template becomes a public pad when protected pads are off.
The refusal carries code: pad_type_disabled, plus access_mode naming the
disabled type — that field is absent when no pad type is enabled at all.
Branch on the code rather than on the message text.
POST /pads/from-url is deliberately exempt: external pads are governed
solely by the separate external-pad policy, not by these two settings.
-
POST /api/v1/pads- Controller:
PadCreateController::create - Params:
file(required)accessMode(public|protected, optional, defaultprotected)
- Result: creates pad, file, and binding.
- Controller:
-
POST /api/v1/pads/create-by-parent- Controller:
PadCreateController::createByParent - Params:
parentFolderId(required, Nextcloud folder/file ID of the writable target folder)name(required, filename base;.padsuffix is appended if missing)accessMode(public|protected, optional, defaultprotected)
- Purpose: creates a managed
.padfile inside an existing parent folder without requiring the client to construct a full path string. - Result includes:
filefile_idparent_folder_idpad_idaccess_modepad_urlviewer_urlembed_url
- Intended use:
- trusted same-origin launcher pages inside Nextcloud
- not direct server-side cross-app mutation without a real Nextcloud user session
- Controller:
-
POST /api/v1/pads/from-url- Controller:
PadCreateController::createFromUrl - Params:
file(required)padUrl(required, absolutehttpsURL with/p/{padId})
- Purpose: creates
.padfor public Etherpad links from external servers. - External
.padfiles are file-only metadata/snapshot records and do not create rows inep_pad_bindings. - Security rules:
- public pad URLs only
- no GroupPad IDs (
g.<group>$<name>) - no local/private/reserved target addresses (DNS/IP checks)
- DNS result is pinned for the outbound fetch (rebinding mitigation)
- external
/export/txtresponses are size-limited (5 MiB hard limit) - external sync accepts only safe text-oriented response content-types
- Controller:
-
POST /api/v1/pads/from-template- Controller:
PadCreateController::createFromTemplate - Params:
file(required) — target path. Body placeholders ({{date}},{{user}}etc.) are resolved server-side.templateFileId(required) — id of any.padin the user's userspace; doesn't have to live in the Templates folder.
- Purpose: create-from-template path for custom frontends that need filename templating or want to pick the source file outside
/Templates. Bypasses NC'sTemplateManager. - Behaviour: resolves
{{...}}infileand template body, provisions a fresh Etherpad pad, writes the new.padcontent + snapshot, creates the binding. Returnsviewer_urlalongside the regular create response shape. - Errors: 400 (non-pad template / external template / empty), 404 (template id not found in userspace), 409 (filename collision).
- Controller:
-
POST /api/v1/pads/open- Controller:
PadSessionController::open - Params:
file=/path/file.pad - Result: secure open URL.
- Behavior: read-only (no auto-mutation of
.padmetadata), CSRF-protected. - Protected mode: response includes one Etherpad session
Set-Cookieheader.
- Controller:
-
POST /api/v1/pads/open-by-id- Controller:
PadSessionController::openById - Params:
fileId=<int> - Result: secure open URL via stable Nextcloud
fileId. - Behavior: read-only (no auto-mutation of
.padmetadata), CSRF-protected. - Protected mode: response includes one Etherpad session
Set-Cookieheader.
- Controller:
-
POST /api/v1/pads/initialize- Controller:
PadSessionController::initialize - Params:
file=/path/file.pad - Purpose: explicit frontmatter initialization for empty/legacy
.padfiles.
- Controller:
-
POST /api/v1/pads/initialize-by-id/{fileId}- Controller:
PadSessionController::initializeById - Purpose: explicit frontmatter initialization by stable Nextcloud
fileId.
- Controller:
-
GET /api/v1/pads/meta-by-id/{fileId}- Controller:
PadSessionController::metaById - Purpose: read-only metadata endpoint for external UIs that need stable file context without triggering open/session bootstrap.
- Result includes:
is_padis_pad_mimefile_idnamepathaccess_modeis_externalpad_idpad_urlpublic_open_urlviewer_urlembed_url
- Controller:
-
GET /api/v1/pads/resolve- Controller:
PadSessionController::resolveById - Query:
fileId=<int>(preferred)file=/path/file.pad(path fallback)
- Result: MIME/path/viewer target for files frontend.
- Controller:
-
POST /api/v1/pads/sync/{fileId}- Controller:
PadLifecycleController::syncById - Optional query:
force=1 - Result: snapshot sync Etherpad ->
.pad(updatedorunchanged). force=1requests an immediate upstream re-check, but unchanged snapshots are still not rewritten.- External pads:
- Sync uses public text export only (
/export/txt) based onpad_url. - HTML is not imported for external pads.
- No DB binding is required; the external target is validated from
.padfrontmatter.
- Sync uses public text export only (
- Controller:
-
GET /api/v1/pads/sync-status/{fileId}- Controller:
PadLifecycleController::syncStatusById - Result:
status=syncedifsnapshot_rev >= current_revstatus=out_of_syncifsnapshot_rev < current_revstatus=unavailablefor external pads without safe revision lookup- External pads return
unavailablebecause the app intentionally does not keep revision state for remote servers.
- Controller:
-
POST /api/v1/pads/trash- Controller:
PadLifecycleController::trash - Params:
file=/path/file.pad - Result:
200withstatus=trashedfor successful trash flow.- includes
snapshot_persisted(true|false) if file lock prevented snapshot write. - includes
delete_pending(true|false):truewhen Etherpad delete is deferred to background job.
- includes
409withstatus=skipped+reasonon invalid lifecycle state (for example already pending delete).- includes transition-race guard reason
binding_state_transition_conflicton concurrent state updates.
- includes transition-race guard reason
- Controller:
-
POST /api/v1/pads/restore- Controller:
PadLifecycleController::restore - Params:
file=/path/file.pad - Result:
200withstatus=restoredfor successful restore flow.409withstatus=skipped+reasonon invalid lifecycle state.- includes transition-race guard reason
binding_state_transition_conflicton concurrent state updates.
- includes transition-race guard reason
- Controller:
-
POST /api/v1/pads/recover-from-snapshot/{fileId}- Controller:
PadLifecycleController::recoverByFileId - Purpose: manual recovery entry point for
.padfiles that ended up without a binding row (WebDAV backup restore,occ files:scan, direct DB intervention, file copy). Reuses the same "frontmatter → fresh pad" path asNodeRestoredEventbut is guarded so it refuses when a binding row already exists. - Result:
200withstatus=restored,old_pad_id,new_pad_idon success. Always provisions a fresh pad —pad_idfrom frontmatter is never reused.409withstatus=skipped+reason=external_padfor external (ext.*) frontmatter; recovery doesn't apply there.409withmessageand thePadAlreadyHasBindingExceptionmapping if a binding row already exists for the file.
- Controller:
-
GET /api/v1/pads/find-original/{fileId}- Controller:
PadLifecycleController::findOriginalByFileId - Purpose: look up whether the orphan's frontmatter
pad_idis bound to another.padthe requester can read. Used by the recovery UI to offer "Open the original" when a copy is detected. - Result:
200with{ found: true, file_id, path, viewer_url }when the lookup hits and the bound file is readable by the requester.200with{ found: false }for every miss path (no row, ext.* pad id, trashed/pending-delete binding, binding for a file not addressable in the requester's userspace, unparseable frontmatter, orphan itself not readable, self-loop). Payload shape and status are intentionally identical so the endpoint cannot be used to probe for binding rows that belong to other users.
- Controller:
-
POST /api/v1/admin/settings- Controller:
AdminController::saveSettings - Auth: admin only
- Stores Etherpad and security settings, including:
etherpad_host(public/browser base URL)etherpad_api_host(optional internal API URL; fallback toetherpad_host)delete_on_trash(yes|no)
- Result includes
checkswith the singleprotected_padsline, recomputed from the saved values in the same shape the health check uses — so the settings page refreshes that verdict without a separate connection test, and clears results the save invalidated.
- Controller:
-
POST /api/v1/admin/health- Controller:
AdminController::healthCheck - Auth: admin only
messagesummarises the run — "All checks passed." or a note that some settings need attention. It deliberately does not say "successful": the request succeeding says nothing about the configuration.- Result includes:
-
host -
api_host -
api_version -
pad_count -
latency_ms -
target -
pending_delete_count -
checks— one entry per verified part, so a failure points at the field that caused it:api,api_key,base_url,protected_pads. Each hasid,status(ok|warning|skipped),label,detailandfield, all already translated for display.fieldnames the form input the line belongs to, so the result can be rendered at that input; it is empty when the line belongs to no single field.base_urlis the only entry that performs additional I/O: a short GET against the browser-facing Etherpad URL, which nothing else contacts. Unlike the API host it gets no local-address exemption — that exemption is what would let an admin-supplied address probe the server's own network. Redirects are not followed and the body is not buffered, since neither is needed for a status code. The protection is Nextcloud's, soallow_local_remote_servers=truedisables it instance-wide.The line is never an error. Nextcloud may be unable to reach the public URL by design (split-horizon DNS, egress firewall), and a blocked local address is reported as unverified rather than broken, since an internal deployment may still serve it to users. It runs even when the base URL matches the API URL, because the API call may succeed against a loopback address no browser can use; both lines then land on the same field, where the more severe verdict is the one shown.
-
protected_pads— the same verdict as theprotected_padsline inchecks, but machine-readable for support and other clients, ornullwhen protected pads are switched off. A failing verdict does not fail the connection test: the API can be reachable while protected pads cannot work. Fields:ok,status(ok|warning|unknown),reason,cookie_domain,cookie_domain_source(configured|derived|host_only),nextcloud_host,etherpad_host,message.See "Check iframe and cookie setup for protected pads" in
README.mdfor why the two hosts must share a parent domain. The check performs no I/O; public suffixes are recognised by a conservative heuristic rather than a Public Suffix List lookup, socommon_parent_may_be_public_suffixis a warning rather than a verdict.
-
- A failed connection test answers
502withmessageand, where the cause can be attributed,field— the same shape validation errors use, so the page marks the input rather than reporting only at the bottom. No other fields are present on that response.
- Controller:
-
POST /api/v1/admin/consistency-check- Controller:
AdminController::consistencyCheck - Auth: admin only
- Purpose: optional structural integrity check across binding table and
.padfiles. - Result includes:
binding_without_file_countfile_without_binding_countinvalid_frontmatter_countfrontmatter_scannedfrontmatter_skippedsamples(bounded debug sample lists per issue class)
- Controller:
-
POST /api/v1/admin/retry-pending-deletes- Controller:
AdminController::retryPendingDeletes - Auth: admin only
- Purpose: immediate retry of deferred Etherpad deletions:
state=pending_delete
- Result:
attemptedresolvedfailedremaining
- Controller:
-
POST /api/v1/admin/test-fault- Controller:
AdminController::setTestFault - Auth: admin only
- Availability: only when Nextcloud
debugmode is enabled - Params:
fault(string, optional; empty clears active fault)
- Purpose: deterministic E2E fault injection for lifecycle error-path testing.
- Supported fault values:
trash_read_locktrash_write_locktrash_write_failrestore_read_lockrestore_write_lockrestore_write_fail
- Controller:
viewer_url: URL for viewer redirect.embed_url: URL for the minimal authenticated embed page (/embed/by-id/{fileId}).pad_id: Etherpad pad ID.pad_url: preferred target URL for public/external pads.access_mode:publicorprotected.status(sync):updatedorunchanged.snapshot_rev(sync): Etherpad revision currently persisted in.pad.sync_status_url(open/open-by-id): endpoint for revision-based sync status in viewer.code(errors): stable identifier on selected error responses, currentlymissing_bindingforMissingBindingException. The viewer and embed use this to swap a dead-end error message for the recovery UI (POST /api/v1/pads/recover-from-snapshot/{fileId}+ optionalGET /api/v1/pads/find-original/{fileId}lookup).
- Controllers use explicit
Set-Cookieresponse headers for Etherpad session bootstrap. - Rationale: this flow needs explicit cookie attributes for iframe cross-subdomain sessions.
- Current contract:
- one custom Etherpad
Set-Cookieheader line is written by this app on protected-open responses that open writable Etherpad iframes - public read-only protected shares render the stored
.padsnapshot and do not set an Etherpad session cookie - no additional custom app cookies are added in the same response
- one custom Etherpad
- If future changes introduce multiple app-level cookies on these responses, this must be implemented and tested explicitly.
src/files-main.js- wires the Files/public-share frontend modules.
src/files/open-action.js- extracts
fileIddirectly from the authenticated Files action context whenever available. - uses
GET /api/v1/pads/resolvemainly as a fallback to convert file path ->fileIdwhen no stablefileIdis available.
- extracts
src/files/pad-opener.js- opens in files view through Nextcloud router (
fileid,openfile=true). - clears
openfile/editingagain when the native viewer closes.
- opens in files view through Nextcloud router (
src/files/public-pad-menu.js- registers
Public padin+ Neuvia API-only runtime capability checks:- modern:
addNewFileMenuEntry/getNewFileMenu().registerEntry - legacy fallback:
OC.Plugins.register('OCA.Files.NewFileMenu', ...)
- modern:
- registers
src/files/public-share-pad-links.js- global click interception is only used on public-share routes to remap share download links to the pad viewer.
src/files/route-controller.js- normalizes stale
.padFiles routes withoutopenfile=true. - opens public-share pad links through the native viewer when available.
- normalizes stale
src/viewer-main.js- prefers
POST /api/v1/pads/open-by-id(fileId, requesttoken). - falls back to
POST /api/v1/pads/open(file, requesttoken) only withoutfileId. - if open fails with missing frontmatter, calls
POST /api/v1/pads/initialize*and retries open once. - if open fails with
code=missing_binding, renders a recovery card with an optionalGET /api/v1/pads/find-original/{fileId}lookup and aPOST /api/v1/pads/recover-from-snapshot/{fileId}action. - uses
POST /api/v1/pads/sync/{fileId}periodically and on unload.
- prefers
src/embed-main.js- powers the minimal
/embed/by-id/{fileId}page. - uses same-origin
POST /api/v1/pads/open-by-id. - if open fails with missing frontmatter, calls
POST /api/v1/pads/initialize-by-id/{fileId}and retries once. - if open fails with
code=missing_binding, renders the same recovery flow as the inline viewer (lookup + recover). - sets the returned
response.urldirectly on the internal iframe. - uses the returned
sync_url/sync_interval_secondsto trigger the same snapshot sync contract as the native viewer. - listens for trusted parent-frame
postMessageevents:epnc:host-visibleepnc:host-hiddenepnc:host-before-closeepnc:host-sync-now
- powers the minimal
src/embed-create-main.js- powers the minimal
/embed/create-by-parent/{parentFolderId}page. - uses same-origin
POST /api/v1/pads/create-by-parent. - redirects to returned
embed_urlafter successful pad creation.
- powers the minimal
- Normal start:
/index.php/apps/files/files .padopen target:/index.php/apps/files/files/{fileId}?dir=...&editing=false&openfile=true- Legacy/compat fallback deep-link:
/index.php/apps/etherpad_nextcloud/by-id/{fileId} - Stale URL normalization:
- Route
/apps/files/files/{fileId}?dir=...withoutopenfile=trueis normalized (for.pad) to/apps/files/files?dir=...so future.padopens continue to work correctly.
- Route
tests/integration/e2e-pad-flow.sh- happy path: create -> open -> trash -> restore -> open
tests/integration/e2e-sync-failure.sh- failure path: create -> sync(force=1) must fail with non-2xx when Etherpad is down
- goal: no silent best-effort success on critical sync
tests/integration/e2e-lifecycle-state-guards.sh- state guards: restore(active) and trash(pending_delete) must return
409 status=skipped
- state guards: restore(active) and trash(pending_delete) must return
tests/integration/e2e-lifecycle-trash-failure.sh- deferred-delete path: create -> trash remains
200withdelete_pending=truewhen Etherpad is down - post-condition: trash-again must return
409 status=skipped(binding_not_active)
- deferred-delete path: create -> trash remains
tests/integration/e2e-lifecycle-restore-failure.sh- failure path: create -> trash -> restore must fail with non-2xx when Etherpad is down
- post-condition: trash-again must return
409 status=skipped(binding_not_active)
tests/integration/e2e-lifecycle-trash-lock-tolerant.sh- lock path: inject
trash_write_lock, trash must stay200and returnsnapshot_persisted=false - post-condition: restore still succeeds after fault is cleared
- lock path: inject
tests/integration/e2e-lifecycle-restore-write-failure.sh- write-failure path: inject
restore_write_fail, restore must fail non-2xx - post-condition: restore succeeds after fault is cleared
- write-failure path: inject
tests/integration/e2e-public-share-folder.sh- folder share: viewer/open/download/reopen + DAV-style
fileparameter + route switch
- folder share: viewer/open/download/reopen + DAV-style
tests/integration/e2e-public-share-single-file.sh- single-file share: viewer/open/download/reopen + DAV-style
fileparameter + route switch
- single-file share: viewer/open/download/reopen + DAV-style
Registered in lib/AppInfo/Application.php.
OCA\Files\Event\LoadAdditionalScriptsEvent->LoadFilesScriptsListenerOCA\Viewer\Event\LoadViewer->LoadViewerListenerOCA\Files_Sharing\Event\BeforeTemplateRenderedEvent->LoadPublicShareScriptsListenerOCA\Files_Trashbin\Events\MoveToTrashEvent->MoveToTrashListenerOCA\Files_Trashbin\Events\NodeRestoredEvent->RestoreFromTrashListenerOCP\Security\CSP\AddContentSecurityPolicyEvent->CSPListenerOCP\Files\Template\RegisterTemplateCreatorEvent->RegisterTemplateCreatorListener
etherpad_hostetherpad_api_keyetherpad_api_version(default1.2.15)etherpad_cookie_domain- Optional explicit cookie domain for protected pad session bootstrap.
- Fallback when empty:
- derived from
etherpad_host - IP/invalid hosts -> empty domain attribute
- recommendation: set explicitly for complex proxy/subdomain setups
- derived from
delete_on_trash(yes|no, defaultyes)sync_interval_seconds(default120, clamp5..3600)allow_external_pads(yes|no, defaultyes)external_pad_allowlist(newline-separated host list, optional)trusted_embed_origins(newline-separated absolutehttps://originlist, optional)- used for the route-specific
frame-ancestorspolicy on:/embed/by-id/{fileId}/embed/create-by-parent/{parentFolderId}
- when empty, no external embedding origin is added beyond
'self'
- used for the route-specific
test_fault(debug-only E2E fault injection; empty by default)