Skip to content

Latest commit

 

History

History
591 lines (517 loc) · 44.5 KB

api-reference.md

File metadata and controls

591 lines (517 loc) · 44.5 KB

API Reference

SPDX-License-Identifier: AGPL-3.0-or-later

Nextcloud App Routes

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.
  • 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.
  • 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 fileId resolves to an accessible .pad file 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.requestToken bootstrap
      • if open fails with code: missing_frontmatter, the embed page retries once after initialize-by-id/{fileId}
      • sets route-specific frame-ancestors from admin-configured trusted embed origins
    • Host message contract:
      • accepted incoming messages from trusted origins:
        • epnc:host-visible
        • epnc:host-hidden
        • epnc:host-before-close
        • epnc:host-sync-now
      • emitted replies to the sending host origin:
        • epnc:sync-flush-started
        • epnc:sync-flush-finished
        • epnc:sync-flush-failed
      • intended use:
        • host sends epnc:host-before-close
        • waits briefly for epnc:sync-flush-finished or epnc: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-finished carries the sync's answer in result, which can be status: locked (see POST /api/v1/pads/sync/{fileId})
  • GET /embed/create-by-parent/{parentFolderId}

    • Controller: EmbedController::createByParent
    • Query:
      • name (required)
      • accessMode (public|protected, optional, default protected)
    • Purpose: minimal authenticated create launcher page for trusted same-site / trusted-origin integrations.
    • Behavior:
      • requires a logged-in Nextcloud user
      • validates that parentFolderId resolves to an accessible writable folder in the user's file tree
      • renders a blank page that internally calls POST /api/v1/pads/create-by-parent same-origin with CSRF token
      • injects CSRF token manually into the blank template because this layout does not receive the normal OC.requestToken bootstrap
      • on success redirects itself to the returned embed_url
      • sets route-specific frame-ancestors from admin-configured trusted embed origins

Naming the file in a public share

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.
  • 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; content allows 60. A refusal is Nextcloud's 429 with no body.
    • Query: fileId=<int> or file=/subfolder/file.pad - see "Naming the file in a public share" above.
    • Purpose: resolves a .pad file inside a public share for the native viewer.
    • Result:
      • writable protected share: Etherpad URL plus one sessionID Set-Cookie header
      • read-only protected share: is_readonly_view=true, empty url, and a content_url the viewer loads the pad from; no Etherpad session cookie
      • public/external pad share: regular public Etherpad URL, plus content_url for external pads
  • GET /api/v1/public/content/{token}

    • Controller: PublicViewerController::padContent
    • Query: fileId=<int> or file=/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 429 with no body.
    • Result: sanitized html plus is_empty; answered no-store.
    • Behavior: resolves the share and re-checks the .pad binding on every call, so a retry cannot outlive the access it was granted under.
    • The content_url handed 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.

Pad types switched off

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.

Routes for signed-in users

  • POST /api/v1/pads

    • Controller: PadCreateController::create
    • Params:
      • file (required)
      • accessMode (public|protected, optional, default protected)
    • Result: creates pad, file, and binding.
    • The folder the path names has to be there and take a new file: 404 with "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, 403 with "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 for from-url and from-template, and as create-by-parent answers 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.
  • 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; .pad suffix is appended if missing)
      • accessMode (public|protected, optional, default protected)
    • Purpose: creates a managed .pad file inside an existing parent folder without requiring the client to construct a full path string.
    • Result includes:
      • file
      • file_id
      • parent_folder_id
      • pad_id
      • access_mode
      • pad_url
      • viewer_url
      • embed_url
    • Intended use:
      • trusted same-origin launcher pages inside Nextcloud
      • not direct server-side cross-app mutation without a real Nextcloud user session
  • POST /api/v1/pads/from-url

    • Controller: PadCreateController::createFromUrl
    • Params:
      • file (required)
      • padUrl (required, absolute https URL with /p/{padId})
    • Purpose: creates .pad for public Etherpad links from external servers.
    • External .pad files are file-only metadata/snapshot records and do not create rows in ep_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/txt responses are size-limited (5 MiB hard limit)
      • external sync accepts only safe text-oriented response content-types
  • 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 .pad in 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's TemplateManager.
    • Behaviour: resolves {{...}} in file and template body, provisions a fresh Etherpad pad, writes the new .pad content + snapshot, creates the binding. Returns viewer_url alongside 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).
  • POST /api/v1/pads/open

    • Controller: PadSessionController::open
    • Params: file=/path/file.pad
    • Result: secure open URL.
    • Behavior: read-only (no auto-mutation of .pad metadata), CSRF-protected.
    • Protected mode: response includes one Etherpad session Set-Cookie header.
  • 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 .pad metadata), CSRF-protected.
    • Protected mode: response includes one Etherpad session Set-Cookie header.
  • GET /api/v1/pads/content/{fileId}

    • Controller: PadSessionController::contentById
    • Purpose: the pad's current content for this app's read-only view.
    • Result: sanitized html plus is_empty; answered no-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 .pad binding 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 answers 400 with code: pad_too_large past it.
  • POST /api/v1/pads/initialize

    • Controller: PadSessionController::initialize
    • Params: file=/path/file.pad
    • Purpose: explicit frontmatter initialization for empty/legacy .pad files.
    • 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 403 with "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.
  • 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 initialize does: 403.
  • 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_pad
      • is_pad_mime
      • file_id
      • name
      • path
      • access_mode
      • is_external
      • pad_id
      • pad_url
      • public_open_url
      • viewer_url
      • embed_url
  • GET /api/v1/pads/resolve

    • Controller: PadSessionController::resolveById
    • Query:
      • fileId=<int> (preferred)
      • file=/path/file.pad (path fallback: taken when fileId is absent, empty or 0)
    • Result: MIME/path/viewer target for files frontend.
  • POST /api/v1/pads/sync/{fileId}

    • Controller: PadLifecycleController::syncById
    • Optional query: force=1
    • Result: snapshot sync Etherpad -> .pad, 200 with status=updated or unchanged; updated says 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.enabled false 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: 200 with status=locked, retryable: true and lock_retries, how often the sync waited for that lock. Nothing is written; a later sync writes it, if one comes.
    • force=1 requests 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: 400 with code=pad_missing, as an open answers, and the file keeps its content for recover-from-snapshot.
    • External pads:
      • Sync uses public text export only (/export/txt) based on pad_url.
      • HTML is not imported for external pads.
      • No DB binding is required; the external target is validated from .pad frontmatter.
  • GET /api/v1/pads/sync-status/{fileId}

    • Controller: PadLifecycleController::syncStatusById
    • Result:
      • status=synced if snapshot_rev >= current_rev
      • status=out_of_sync if snapshot_rev < current_rev
      • status=unavailable for external pads without safe revision lookup
      • External pads return unavailable because the app intentionally does not keep revision state for remote servers.
  • POST /api/v1/pads/recover-from-snapshot/{fileId}

    • Controller: PadLifecycleController::recoverByFileId
    • Purpose: manual recovery entry point for .pad files 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:
      • 200 with status=restored, old_pad_id, new_pad_id on success. Always provisions a fresh pad — pad_id from frontmatter is never reused.
      • 403 with message when the user may not change the file, a read-only share say: a recovery writes the file. Refused before Etherpad is asked.
      • 409 with status=skipped + reason=external_pad for 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.
      • 409 with status=skipped + reason=file_moved or reason=file_changed when 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.
      • 409 with message and the PadAlreadyHasBindingException mapping 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.
      • 409 with status=skipped + reason=unknown_access_mode when 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.
      • 409 with status=skipped + reason=binding_state_transition_conflict, without retryable, 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.
      • 503 with retryable when 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 answers 500 as below.
      • 400 when Etherpad refuses, and for a file that is no .pad the app can read, with a binding or without: no pad metadata (with code: missing_frontmatter), or metadata it cannot parse.
      • 400 with retryable: true when 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.
      • 500 without retryable when the write failed and the file cannot be read afterwards - still locked, or no .pad any 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: 404 for a fileId the user cannot see, 500 for what has no answer of its own - a read of the file that fails, say, as when it went away meanwhile.
  • GET /api/v1/pads/find-original/{fileId}

    • Controller: PadLifecycleController::findOriginalByFileId
    • Purpose: look up whether the orphan's frontmatter pad_id is bound to another .pad the requester can read. Used by the recovery UI to offer "Open the original" when a copy is detected.
    • Result:
      • 200 with { found: true, file_id, path, viewer_url } when the lookup hits and the bound file is readable by the requester.
      • 200 with { 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.
  • 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 to etherpad_host)
      • delete_pad_with_file (yes|no)
      • allow_external_pads and external_pad_allowlist: the list is taken only while allow_external_pads is on, and otherwise left as stored. An empty list trusts every public HTTPS host.
    • The Etherpad API version is read from /api on every save. When that read fails, the version stored for the same API host stays; for another host, the default 1.2.15 is stored.
    • Result includes checks with the single protected_pads line, 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.
  • POST /api/v1/admin/health

    • Controller: AdminController::healthCheck
    • Auth: admin only
    • message summarises 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_cookie and protected_pads. Each has id, status (ok|warning|skipped), label, detail and field, all already translated for display. field names 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.

        api and api_key both come from one checkToken call, which proves the address answers and the key is accepted and nothing else. Etherpad implements checkToken as an empty function, so a passing line says nothing about whether the pad store can be read. A cheap storage probe would be possible — getRevisionsCount reads one key and answers code: 1, padID does not exist for 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_url performs 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, so allow_local_remote_servers=true disables it instance-wide.

        session_cookie reaches out as well: it reads Etherpad's /health for 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 the protected_pads line in checks, but machine-readable for support and other clients, or null when 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.md for 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, so common_parent_may_be_public_suffix is a warning rather than a verdict.

    • A failed connection test answers 200 with ok: false, a message and, 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 a 5xx: 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. Read ok, never the status.
  • GET /api/v1/admin/templates

    • Controller: AdminController::listPadTemplates
    • Auth: admin only
    • Result: templates — the shared templates, each with name, size and modified.
  • POST /api/v1/admin/templates

    • Controller: AdminController::uploadPadTemplate
    • Auth: admin only
    • Params: name (a .pad file 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_exists unless replace is 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.
  • POST /api/v1/admin/templates/delete

    • Controller: AdminController::deletePadTemplate
    • Auth: admin only
    • Params: name
  • 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; message says the check found issues when there are any
      • samples.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_count says how many, as in the health check.
  • POST /api/v1/admin/delete-vanished

    • Controller: AdminController::deleteVanished
    • Auth: admin only
    • Params: fileId
    • Purpose: the vanished row (see consistency-check) of fileId, 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 through settle-pending. With delete_pad_with_file off it marks nothing, and message says so. When to use it, and when not: docs/deleting-pads.md.
    • Result: marked (1 or 0), and the list as it is after the action, as consistency-check gives it (vanished_file_count, samples), with pending_delete_count.
    • 400 with Invalid file ID. without a positive fileId.
  • 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 than expected. A list whose count is no longer expected - 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 for delete-vanished. A list not empty after the call is one of two things, and message says which: fewer marked than confirmed, and the rest for another call; or all confirmed marked, and files vanished since, to be looked at first.
    • 400 with Invalid count. without a positive expected.
    • 500 when a chunk of marks fails: the marks before it stand, message says some may be marked already, and the log has their count.
  • 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 - false for a protected pad, with message saying so, or a file no longer vanished - and the list as for delete-vanished.
    • 400 with Invalid file ID. without a positive fileId.
  • 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. With delete_pad_with_file off it deletes nothing, and message says so.
    • Result:
      • checked: rows of files deleted for good it took; settled: pads it deleted, with their rows
      • pending_delete_count: what is left, named as in the health check

Important Response Fields

  • 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: public or protected.

  • status (sync): updated or unchanged.

  • 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 on message — 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} + optional GET /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 call POST /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; carries access_mode naming the disabled type, absent when neither type is enabled. See enable_protected_pads, enable_public_pads under "App Config Keys".
    • legacy_collision_no_access (LegacyPadCollisionException) — see docs/legacy-ownpad-migration.md.
    • legacy_protected_import_disabled (LegacyProtectedImportDisabledException) — 403; the legacy .pad names 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. See docs/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 not missing_binding, pad_missing or missing_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 code may still be machine-readable through its HTTP status and other documented fields — a locked .pad answers 503 with retryable: true, signed in and public alike, for instance (a sync answers 200 with status=locked instead), and so does a request this instance's Etherpad could not be reached for. Every error this app answers is JSON, it never answers 502 or 504, and every 503 of its own carries retryable: true; what the clients make of a 5xx that is none of these is in docs/architecture.md ("Errors of the API"). A file's row that another request made first (BindingNotCreatedException, two initialisations at once, say) answers 400 with retryable: true too: 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 answers 400 without it. The create endpoints answer it with their own sentence and neither field. One Etherpad answered and refused answers 400 without it: trying again gives the same answer. What is never a stable identifier is the message text.

Cookie Behavior (Protected Pads)

  • Controllers send an explicit Set-Cookie header 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-Cookie header 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
  • 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" in docs/architecture.md).

Frontend API Usage

  • src/viewer-init.js
    • registers the .pad MIME handler synchronously through @nextcloud/viewer from an init script.
    • hands src/viewer-main.js over as the handler's component.
  • src/public-share-main.js
    • opens / explicitly for a public single-file .pad share.
    • hands an existing compatibility link with path and files to the native Viewer once; ordinary public folder-share navigation stays with Nextcloud Files Sharing.
  • 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 without fileId.
    • 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 optional GET /api/v1/pads/find-original/{fileId} lookup and a POST /api/v1/pads/recover-from-snapshot/{fileId} action; with code=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.
  • 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 or pad_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.url directly on the internal iframe.
    • uses the returned sync_url / sync_interval_seconds to trigger the same snapshot sync contract as the native viewer.
    • listens for trusted parent-frame postMessage events:
      • epnc:host-visible
      • epnc:host-hidden
      • epnc:host-before-close
      • epnc:host-sync-now
  • 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_url after successful pad creation.
    • tells the host epnc:create-succeeded or epnc:create-failed; after reason: 'network' the outcome is not known (docs/architecture.md, the create flow).

URL Control in Files App

  • Normal start: /index.php/apps/files/files
  • .pad open 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}

Tests

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).

Nextcloud Events/Listeners

Registered in lib/AppInfo/Application.php.

  • OCP\EventDispatcher\GenericEvent (Files FullTextSearch's legacy events) -> FullTextSearchIndexingListener, FullTextSearchResultListener
  • OCP\Security\CSP\AddContentSecurityPolicyEvent -> CSPListener
  • OCA\Files\Event\LoadAdditionalScriptsEvent -> LoadFilesScriptsListener
  • OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent -> LoadPublicShareScriptsListener
  • OCP\Files\Template\RegisterTemplateCreatorEvent -> RegisterTemplateCreatorListener
  • OCP\Files\Template\BeforeGetTemplatesEvent -> BeforeGetTemplatesListener (priority -100)
  • OCP\Files\Template\FileCreatedFromTemplateEvent -> FileCreatedFromTemplateListener
  • OCA\Viewer\Event\LoadViewer -> LoadViewerListener
  • OCP\User\Events\UserLoggedOutEvent -> UserLoggedOutListener
  • OCP\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 (what occ files:scan drops), the event named \OCP\Files::postDelete (an OCP\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 by RevokeGroupSessionsJob; docs/architecture.md, "Trash/Restore")

App Config Keys

  • etherpad_host
  • etherpad_api_host (optional) — where the server calls Etherpad's API, an internal address say; empty, etherpad_host is used. A save of the settings form with the field empty stores etherpad_host here, and the form shows it from then on: a later change of etherpad_host, through occ or the form, leaves the API on the old address until the field is emptied again.
  • etherpad_api_key
  • etherpad_api_version (default 1.2.15)
  • etherpad_cookie_domain and etherpad_cookie_domain_configured (yes|no, default no) — the Domain of 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 through occ), 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.org and pad.other.org), or where one of them is an IP address, localhost, a name under .localhost or 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 no and no value is stored, and stores what its field holds, with the flag yes, 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, default yes) — whether new pads of that type are made ("Pad types switched off" above). With one type off, POST /pads and create-by-parent refuse it, while from-template and the initialize endpoints 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, default lax) and etherpad_http_only_session_cookie (auto|yes|no, default auto) — the Etherpad session cookie's attributes: docs/etherpad-integration.md, which says when to set them
  • delete_pad_with_file (yes|no, default yes) — whether the pad of a .pad file deleted for good is deleted (docs/deleting-pads.md); delete_on_trash before, taken over on upgrade
  • sync_interval_seconds (default 120, clamp 5..3600)
  • allow_external_pads (yes|no, default no)
  • allow_legacy_protected_import (yes|no, default no) — whether a legacy Ownpad .pad naming 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 absolute https://origin list, optional)
    • used for the route-specific frame-ancestors policy on:
      • /embed/by-id/{fileId}
      • /embed/create-by-parent/{parentFolderId}
    • when empty, no external embedding origin is added beyond 'self'