SPDX-License-Identifier: AGPL-3.0-or-later
Base: /apps/etherpad_nextcloud
A file or folder id a route takes - {fileId}, fileId, parentFolderId, templateFileId - is a positive whole number in digits. Anything else is refused, not cast: 1e3, 7.5 and 7x name no file. The API answers 400 with Invalid file ID. (or the folder's sentence), the public routes 400 with "This link does not point to a valid file.", and the viewer and embed pages show their error page. One exception: /api/v1/pads/resolve takes the path when fileId is absent, empty or 0.
-
GET /- Controller:
ViewerController::showPad - Query:
file=/path/to/file.pad - Purpose: compatibility entry route that redirects to native Files viewer URL.
- Signed-in users only: Nextcloud sends anyone else to the login and back.
- Controller:
-
GET /by-id/{fileId}- Controller:
ViewerController::showPadById - Purpose: compatibility entry route via file ID; redirects to native Files viewer URL.
- Signed-in users only, as above.
- 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
code: missing_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
- while another sync of the file runs, the reply can come up to about three seconds later;
epnc:sync-flush-finishedcarries the sync's answer inresult, which can bestatus: locked(seePOST /api/v1/pads/sync/{fileId})
- 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:
A share token identifies the share, not a file inside it. For a share of a single file that is enough. For a share of a folder the request has to say which file it means, and there are two ways to do that:
fileId=<int>- the file's Nextcloud id. Preferred, and what the app's own viewer sends.file=/subfolder/file.pad- the path inside the shared folder. Still supported, so links written before ids were accepted keep working.
Prefer the id where you have one. A name has to survive being put in a query string, and a query string spells a space two ways: A+B.pad and A B.pad arrive as the same text. An id has one spelling.
A request is answered when the file it names is a .pad the share actually contains and the visitor may read. It is refused when:
- the id is not a positive integer, including when
fileId=is sent empty; - the id names no file inside this share, whether it exists elsewhere or not at all;
- both are sent and they name different files.
A folder named in place of a file is refused as not being a .pad (400), not as missing (404).
In none of those cases does the request fall through to the other locator. A refused id is not retried as a path, because "the id did not work, so something else opened" is the outcome ids exist to prevent.
When both are sent for a folder share, the path is compared in full: A.pad at the top of the share and Sub/A.pad are different files. A single-file share has no path inside it, so there the file's name is what a path can name - and without an id it is ignored entirely, as it always has been.
-
GET /public/{token}- Controller:
PublicShareRedirectController::showPad - Query (folder share):
file=/subfolder/file.pad- name only, and deliberately: this route builds no viewer address of its own, it hands over to Nextcloud's own share page. - Purpose: compatibility route for public shares; redirects to
/s/{token}with selected file. It does not look at the share: Nextcloud's share page checks the token, and asks for the password of a share that has one. - A file it cannot name - no
.pad, or a path out of the share - leads to the share's root, so a dead token ends on the share page's "Share not found" rather than on an error about the file.
- Controller:
-
GET /api/v1/public/open/{token}- Controller:
PublicViewerController::openPadData - Throttled to 300 calls a minute: by address for a visitor who is not signed in, per user for one who is. For a writable link to a protected pad, a call can start an Etherpad session: each new visitor's, or each call's without a memory cache. The limit is well above a class opening a link at once behind one address;
contentallows 60. A refusal is Nextcloud's429with no body. - Query:
fileId=<int>orfile=/subfolder/file.pad- see "Naming the file in a public share" above. - 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_view=true, emptyurl, and acontent_urlthe viewer loads the pad from; no Etherpad session cookie - public/external pad share: regular public Etherpad URL, plus
content_urlfor external pads
- writable protected share: Etherpad URL plus one
- Controller:
-
GET /api/v1/public/content/{token}- Controller:
PublicViewerController::padContent - Query:
fileId=<int>orfile=/subfolder/file.pad- see "Naming the file in a public share" above. - Purpose: the pad's current content for the read-only view of a public share.
- Throttled to 60 calls a minute: by address for a visitor who is not signed in, per user for one who is. A refusal is Nextcloud's
429with no body. - Result: sanitized
htmlplusis_empty; answeredno-store. - Behavior: resolves the share and re-checks the
.padbinding on every call, so a retry cannot outlive the access it was granted under. - The
content_urlhanded out by the open endpoint carries the id that open resolved to, so a file renamed or moved inside the share between the two requests is still the one answered for.
- Controller:
Admins can switch either pad type off (enable_protected_pads, enable_public_pads under "App Config Keys"). 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.
- The folder the path names has to be there and take a new file:
404with "Cannot resolve selected parent folder." when it is not there, is no folder or is one the user may not look into, or when the user's files cannot be reached,403with "Selected parent folder is not writable." when the user may not create in it (a read-only share, say), both before anything is made. The same forfrom-urlandfrom-template, and ascreate-by-parentanswers for a folder named by id. - The app's own UI no longer calls this: a pad type is picked in Nextcloud's template picker, which creates the file itself. The endpoint stays for API consumers and behaves as before.
- 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, or the target's folder not there), 403 (the target's folder takes no new file), 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:
-
GET /api/v1/pads/content/{fileId}- Controller:
PadSessionController::contentById - Purpose: the pad's current content for this app's read-only view.
- Result: sanitized
htmlplusis_empty; answeredno-store. Both fields are always present — a reader must be able to tell an empty pad from an answer that carries no content. - Behavior: re-resolves the file and re-checks the
.padbinding on every call. Own pads are read over the Etherpad API, pads on other servers over their public HTML export. Either way the read stops at 5 MiB and answers400withcode: pad_too_largepast it.
- Controller:
-
POST /api/v1/pads/initialize- Controller:
PadSessionController::initialize - Params:
file=/path/file.pad - Purpose: explicit frontmatter initialization for empty/legacy
.padfiles. - Initialising writes the file, whether it is empty or a legacy shortcut: a user who may not change it - a reader of a read-only share - gets
403with "Only someone who may edit this .pad file can do that.", before a pad, group or binding is made. A file initialised already answers as before, to anyone who can read it.
- Controller:
-
POST /api/v1/pads/initialize-by-id/{fileId}- Controller:
PadSessionController::initializeById - Purpose: explicit frontmatter initialization by stable Nextcloud
fileId. - Answers a user who may not change the file as
initializedoes:403.
- 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: taken whenfileIdis absent, empty or0)
- 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,200withstatus=updatedorunchanged;updatedsays how often the write waited for the file's lock (lock_retries). - One sync of a file runs at a time: a second one waits for the first, about three seconds at most, and then reads what the first wrote. With
filelocking.enabledfalse syncs overlap. - The lock does not come free in time - another sync of the file runs that long, or a request killed hard left its lock behind (how long that stays depends on Nextcloud's locking provider), or a client keeps the file locked while the sync reads or writes it:
200withstatus=locked,retryable: trueandlock_retries, how often the sync waited for that lock. Nothing is written; a later sync writes it, if one comes. force=1requests an immediate upstream re-check, but unchanged snapshots are still not rewritten. A pad behind the snapshot whose content differs is written.- A pad Etherpad made anew in place of the file's - without a single revision, with other text than the file saved - is never written over it:
400withcode=pad_missing, as an open answers, and the file keeps its content forrecover-from-snapshot. - 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/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), and for a file whose active row names a pad Etherpad has lost (pad_missing). Reuses the paths a restore from the trash takes: "frontmatter → fresh pad" for a file without a row, the replacement of a pad that is gone for the other, which moves the row onto the new pad. Etherpad is asked again here whether the pad is lost. - Result:
200withstatus=restored,old_pad_id,new_pad_idon success. Always provisions a fresh pad —pad_idfrom frontmatter is never reused.403withmessagewhen the user may not change the file, a read-only share say: a recovery writes the file. Refused before Etherpad is asked.409withstatus=skipped+reason=external_padfor a file without a row whose frontmatter names an external (ext.*) pad; recovery doesn't apply there. A file with a row answers as below.409withstatus=skipped+reason=file_movedorreason=file_changedwhen the file moved, or was written, while the new pad was seeded: the new pad is let go, nothing is written, and a retry starts from what the file holds then.409withmessageand thePadAlreadyHasBindingExceptionmapping if the file has a row and its pad is not lost: Etherpad has it, the row waits, the file names another pad than its row (one on another server, say), or the pad is public and the file holds nothing saved.409withstatus=skipped+reason=unknown_access_modewhen the pad is lost but its row names an access mode the app does not know, a record it did not write: no pad is made, and the row stays.409withstatus=skipped+reason=binding_state_transition_conflict, withoutretryable, when another recovery of a file with a binding moved the binding first: the new pad is let go, the file is the other one's to write, and the next open finds its pad.503withretryablewhen Etherpad does not answer - asked whether the pad is lost, or while the new pad is seeded - and when the file is locked by a sync; whatever was made is let go again. A write refused for a lock is let go only once the file can be read under its lock again, after a short wait; a lock that stays answers500as below.400when Etherpad refuses, and for a file that is no.padthe app can read, with a binding or without: no pad metadata (withcode: missing_frontmatter), or metadata it cannot parse.400withretryable: truewhen another recovery of the same file made its binding first: the new pad is let go, and the next open finds the other one's.500withoutretryablewhen the write failed and the file cannot be read afterwards - still locked, or no.padany more - so which pad it names is open: the binding goes and the new pad is kept, as a write, ours or another's, may have cut the file short. The next open finds no binding and offers a new pad from the file's content; the kept pad is named in the log for an admin.- Otherwise as elsewhere:
404for afileIdthe user cannot see,500for what has no answer of its own - a read of the file that fails, say, as when it went away meanwhile.
- 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, a binding whose file was deleted for good (pending_delete), 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_pad_with_file(yes|no)allow_external_padsandexternal_pad_allowlist: the list is taken only whileallow_external_padsis on, and otherwise left as stored. An empty list trusts every public HTTPS host.
- The Etherpad API version is read from
/apion every save. When that read fails, the version stored for the same API host stays; for another host, the default1.2.15is stored. - 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 -
latency_ms -
target -
pending_delete_count— files deleted for good whose pads the sweep has yet to delete -
session_cookie_release— the Etherpad release the open path is going by, which can differ from the one this run probed -
checks— one entry per verified part, so a failure points at the field that caused it:api,api_key,base_url,session_cookieandprotected_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.apiandapi_keyboth come from onecheckTokencall, which proves the address answers and the key is accepted and nothing else. Etherpad implementscheckTokenas an empty function, so a passing line says nothing about whether the pad store can be read. A cheap storage probe would be possible —getRevisionsCountreads one key and answerscode: 1, padID does not existfor an absent pad, which is a pass for that purpose — but no check does it today. A green panel is not a statement about Etherpad's storage.base_urlperforms 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.session_cookiereaches out as well: it reads Etherpad's/healthfor the release the cookie decision depends on.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
200withok: false, amessageand, 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. The status is deliberately not a5xx: the verdict is about the configured Etherpad and not about this request, and a gateway status invites a reverse proxy to replace the body that carries the reason. Readok, never the status.
- Controller:
-
GET /api/v1/admin/templates- Controller:
AdminController::listPadTemplates - Auth: admin only
- Result:
templates— the shared templates, each withname,sizeandmodified.
- Controller:
-
POST /api/v1/admin/templates- Controller:
AdminController::uploadPadTemplate - Auth: admin only
- Params:
name(a.padfile name without a path),content,replace(optional). Sent as a JSON body — URL-encoding a whole file would inflate it several times over. - An existing name is refused with field
template_existsunlessreplaceis set. There is no versioning behind that folder, so overwriting is a decision the caller has to state. - Stores a template offered to everyone in the picker. Rejects anything that is not a usable pad — no frontmatter pad id, a pad on another Etherpad server, empty, or over 2 MiB — because the alternative is an error shown to whoever picks the tile later.
- Controller:
-
POST /api/v1/admin/templates/delete- Controller:
AdminController::deletePadTemplate - Auth: admin only
- Params:
name
- Controller:
-
POST /api/v1/admin/consistency-check- Controller:
AdminController::consistencyCheck - Auth: admin only
- Purpose: optional check of the binding table against the file cache (
docs/architecture.md, "Admin Integrity Check"). - Result:
vanished_file_count: rows whose file the file cache has nothing of, still active and never seen deleted for good, whose pads the app leaves in place;messagesays the check found issues when there are anysamples.vanished_files(file_id,pad_id,access_mode), up to 25- Rows seen deleted for good are not counted: they are on their way, and
pending_delete_countsays how many, as in the health check.
- Controller:
-
POST /api/v1/admin/delete-vanished- Controller:
AdminController::deleteVanished - Auth: admin only
- Params:
fileId - Purpose: the vanished row (see
consistency-check) offileId, while it is still vanished, is marked as a file deleted for good, and the sweep deletes its pad as for any such file - within minutes, or at once throughsettle-pending. Withdelete_pad_with_fileoff it marks nothing, andmessagesays so. When to use it, and when not:docs/deleting-pads.md. - Result:
marked(1or0), and the list as it is after the action, asconsistency-checkgives it (vanished_file_count,samples), withpending_delete_count. 400withInvalid file ID.without a positivefileId.
- Controller:
-
POST /api/v1/admin/delete-all-vanished- Controller:
AdminController::deleteAllVanished - Auth: admin only
- Params:
expected, the count of vanished files the admin was shown and confirmed - Purpose: every vanished row marked as for
delete-vanished, within the budget a sweep has, and never more thanexpected. A list whose count is no longerexpected- grown since it was shown, say - is not taken: nothing is marked, and the answer carries the list as it is now, to be confirmed again. What is left after one call needs another. Up to 500,000 rows the count is that of the rows collected; a longer list is counted by a query of its own. - Result:
marked, and the list as fordelete-vanished. A list not empty after the call is one of two things, andmessagesays which: fewer marked than confirmed, and the rest for another call; or all confirmed marked, and files vanished since, to be looked at first. 400withInvalid count.without a positiveexpected.500when a chunk of marks fails: the marks before it stand,messagesays some may be marked already, and the log has their count.
- Controller:
-
POST /api/v1/admin/forget-vanished- Controller:
AdminController::forgetVanished - Auth: admin only
- Params:
fileId - Purpose: one vanished file's row removed, its public pad left in
Etherpad, while the row is still vanished. The pad's id goes to the
log at
info. A protected pad is not forgotten. Why, and what a forgotten pad is left as:docs/deleting-pads.md. - Result:
forgotten-falsefor a protected pad, withmessagesaying so, or a file no longer vanished - and the list as fordelete-vanished. 400withInvalid file ID.without a positivefileId.
- Controller:
-
POST /api/v1/admin/settle-pending- Controller:
AdminController::settlePending - Auth: admin only
- Purpose: an immediate run of the sweep of files gone for good
(
docs/architecture.md, "Files gone for good"), within the budget its job has, and without waiting out the five minutes a file seen deleted for good waits before its pad goes, nor the hour after Etherpad refused to delete one. It deletes only the pads of files deleted for good, and writes no file. Withdelete_pad_with_fileoff it deletes nothing, andmessagesays so. - Result:
checked: rows of files deleted for good it took;settled: pads it deleted, with their rowspending_delete_count: what is left, named as in the health check
- 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. Branch on this, never onmessage— messages are written for people and are translated, the reason a pad on another server could not be linked or read included. The full set:missing_binding(MissingBindingException) — the viewer and embed swap the dead-end error for the recovery UI (POST /api/v1/pads/recover-from-snapshot/{fileId}+ optionalGET /api/v1/pads/find-original/{fileId}lookup).pad_missing(PadLostException) — the file's row names a pad Etherpad has lost: it has none under that id (a protected pad, or a public one whose file holds saved content), or one with no revision and other text than the file saved (a public pad Etherpad made anew, with its default text, when someone visited its address). Only an open that may write asks, once per open; a sync answers it too, for a pad made anew, rather than write that pad over the file. The viewer and embed show the recovery UI without the original-file lookup;POST /api/v1/pads/recover-from-snapshot/{fileId}makes a new pad from the file's content and moves the row onto it.missing_frontmatter(MissingFrontmatterException) — the file has no pad metadata yet; clients callPOST /api/v1/pads/initialize-by-id/{fileId}once and retry the open. A file whose content is neither metadata nor a legacy shortcut cannot be initialised and is refused without this code.pad_too_large(EtherpadTooLargeException) — the pad is past the 5 MiB preview ceiling; it stays editable in Etherpad.pad_file_changed(PadFileChangedException) — the file changed while its pad was being created or initialised; try again. On create, a file may now exist under that name, and the retry says so.pad_type_disabled(PadTypeDisabledException) —403; carriesaccess_modenaming the disabled type, absent when neither type is enabled. Seeenable_protected_pads,enable_public_padsunder "App Config Keys".legacy_collision_no_access(LegacyPadCollisionException) — seedocs/legacy-ownpad-migration.md.legacy_protected_import_disabled(LegacyProtectedImportDisabledException) —403; the legacy.padnames a group pad and this instance does not import those. The file is left untouched, so the same open succeeds once an admin switches the import back on. Seedocs/legacy-ownpad-migration.md.
The answers of a public share (
/api/v1/public/...) carry the code that can come up there -pad_too_large- but notmissing_binding,pad_missingormissing_frontmatter: what a client does on those needs a signed-in user. Their messages are translated, one sentence for each kind of trouble.A response without a
codemay still be machine-readable through its HTTP status and other documented fields — a locked.padanswers503withretryable: true, signed in and public alike, for instance (a sync answers200withstatus=lockedinstead), and so does a request this instance's Etherpad could not be reached for. Every error this app answers is JSON, it never answers502or504, and every503of its own carriesretryable: true; what the clients make of a 5xx that is none of these is indocs/architecture.md("Errors of the API"). A file's row that another request made first (BindingNotCreatedException, two initialisations at once, say) answers400withretryable: truetoo: the next open finds that request's pad. So does a pad on another server that did not answer, whose sentence says to try again later; any other reason a pad on another server could not be read answers400without it. The create endpoints answer it with their own sentence and neither field. One Etherpad answered and refused answers400without it: trying again gives the same answer. What is never a stable identifier is themessagetext.
- Controllers send an explicit
Set-Cookieheader for Etherpad session bootstrap, beside the cookies Nextcloud sets in the same answer - its session cookie for a visitor who had none, a remembered login it renews - and not over them. - 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 set no Etherpad session cookie: the viewer loads the pad as it reads now from
content_url, which the app reads from Etherpad's API - no additional custom app cookies are added in the same response
- one custom Etherpad
- The line goes out only once the answer stands, so an open that fails leaves no session in the browser. A further cookie of the app's own would go out the same way, through
CookieHeaders(see "Cookie Header Model" indocs/architecture.md).
src/viewer-init.js- registers the
.padMIME handler synchronously through@nextcloud/viewerfrom an init script. - hands
src/viewer-main.jsover as the handler's component.
- registers the
src/public-share-main.js- opens
/explicitly for a public single-file.padshare. - hands an existing compatibility link with
pathandfilesto the native Viewer once; ordinary public folder-share navigation stays with Nextcloud Files Sharing.
- opens
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; withcode=pad_missing, the same card without the lookup. - offers "Try again" where the open may work later (
docs/architecture.md, "Errors of the API"). - 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_bindingorpad_missing, renders the same recovery flow as the inline viewer. - offers "Try again" on its error panel where the open may work later, as the viewer does.
- 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, without a time limit, as it writes. - redirects to returned
embed_urlafter successful pad creation. - tells the host
epnc:create-succeededorepnc:create-failed; afterreason: 'network'the outcome is not known (docs/architecture.md, the create flow).
- 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}
What these endpoints answer, end to end, is checked by the Playwright suite in tests/e2e/specs, against a real Nextcloud and Etherpad; CI runs it on every pull request that touches the app. tests/e2e/README.md says how to run it and what each spec covers. The answers to errors, code and retryable included, are also held by the PHPUnit suite (PadControllerErrorMapperTest, PublicViewerControllerErrorMapperTest).
Registered in lib/AppInfo/Application.php.
OCP\EventDispatcher\GenericEvent(Files FullTextSearch's legacy events) ->FullTextSearchIndexingListener,FullTextSearchResultListenerOCP\Security\CSP\AddContentSecurityPolicyEvent->CSPListenerOCA\Files\Event\LoadAdditionalScriptsEvent->LoadFilesScriptsListenerOCA\Files_Sharing\Event\BeforeTemplateRenderedEvent->LoadPublicShareScriptsListenerOCP\Files\Template\RegisterTemplateCreatorEvent->RegisterTemplateCreatorListenerOCP\Files\Template\BeforeGetTemplatesEvent->BeforeGetTemplatesListener(priority -100)OCP\Files\Template\FileCreatedFromTemplateEvent->FileCreatedFromTemplateListenerOCA\Viewer\Event\LoadViewer->LoadViewerListenerOCP\User\Events\UserLoggedOutEvent->UserLoggedOutListenerOCP\User\Events\BeforeUserDeletedEvent->RevokeSessionsOnAccountDeleteListener(the account's sessions, as on a logout)OCA\Files_Trashbin\Events\NodeRestoredEvent->RestoreFromTrashListener- legacy hook
\OCA\Files_Trashbin\Trashbin::post_restore->TrashbinHookHandler::postRestore->RestoreFromTrashListener::handleLegacyHook OCP\Files\Cache\CacheEntryRemovedEvent,OCP\Files\Cache\CacheEntryInsertedEvent,OCP\Files\Cache\CacheEntriesRemovedEvent(from 34, priority 100),OCP\Files\Events\Node\BeforeNodeDeletedEvent,OCP\Files\Events\Node\NodeDeletedEvent,OCP\Files\Events\NodeRemovedFromCache(whatocc files:scandrops), the event named\OCP\Files::postDelete(anOCP\EventDispatcher\GenericEvent),OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent,OCA\Files_Trashbin\Events\NodeRestoredEvent,OCP\User\Events\BeforeUserDeletedEvent,OCP\User\Events\UserDeletedEvent->GoneFilesListener(the marks of files deleted for good;docs/architecture.md, "Files gone for good")OCP\Files\Events\Node\BeforeNodeDeletedEvent,OCP\Files\Events\Node\NodeDeletedEvent->RevokeSessionsOnDeleteListener(the sessions of the protected pads a delete takes along, the rest byRevokeGroupSessionsJob;docs/architecture.md, "Trash/Restore")
etherpad_hostetherpad_api_host(optional) — where the server calls Etherpad's API, an internal address say; empty,etherpad_hostis used. A save of the settings form with the field empty storesetherpad_hosthere, and the form shows it from then on: a later change ofetherpad_host, throughoccor the form, leaves the API on the old address until the field is emptied again.etherpad_api_keyetherpad_api_version(default1.2.15)etherpad_cookie_domainandetherpad_cookie_domain_configured(yes|no, defaultno) — theDomainof the Etherpad session cookie.- Nextcloud and Etherpad on one host: a host-only cookie, whatever these say.
- Otherwise, with the flag
yes, the stored value stands, an empty one too: a host-only cookie, which then does not reach Etherpad. - With the flag
no, a stored value stands too (one from before the flag, or set throughocc), and an empty one is derived: the parent domain both hosts share, of two labels at least. A host-only cookie where they share less (nc.example.organdpad.other.org), or where one of them is an IP address,localhost, a name under.localhostor one without a dot. A parent that may be a public suffix (co.uk) is set all the same; the settings page, the answer to a save and the connection test warn about it while protected pads are on. - The settings form shows the derived domain while the flag is
noand no value is stored, and stores what its field holds, with the flagyes, on every save: from then on the domain does not follow a change of either host until it is set again, and a field saved empty stays empty. - Recommended: set it explicitly for complex proxy or subdomain setups.
enable_protected_pads,enable_public_pads(yes|no, defaultyes) — whether new pads of that type are made ("Pad types switched off" above). With one type off,POST /padsandcreate-by-parentrefuse it, whilefrom-templateand theinitializeendpoints make a pad of the other type; with both off, these refuse too. Files' "New" menu leaves out what cannot be made, except with both types off and external pads on: its blank entry and the user's own templates stay, and are refused. A new pad a recovery or a restore from the trash makes has the type of the pad it replaces, whatever these switches say, and existing pads open either way.etherpad_session_cookie_samesite(lax|none, defaultlax) andetherpad_http_only_session_cookie(auto|yes|no, defaultauto) — the Etherpad session cookie's attributes:docs/etherpad-integration.md, which says when to set themdelete_pad_with_file(yes|no, defaultyes) — whether the pad of a.padfile deleted for good is deleted (docs/deleting-pads.md);delete_on_trashbefore, taken over on upgradesync_interval_seconds(default120, clamp5..3600)allow_external_pads(yes|no, defaultno)allow_legacy_protected_import(yes|no, defaultno) — whether a legacy Ownpad.padnaming a group pad may bring that pad in. Turn on only where this Nextcloud is the only thing creating group pads on the Etherpad server; see the legacy migration doc.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