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:
-
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)
- Controller:
-
POST /api/v1/admin/health- Controller:
AdminController::healthCheck - Auth: admin only
- Result includes:
hostapi_hostapi_versionpad_countlatency_mstargetpending_delete_count
- 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)