Skip to content

Latest commit

 

History

History
606 lines (532 loc) · 51.4 KB

api-reference.md

File metadata and controls

606 lines (532 loc) · 51.4 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: Nextcloud's own origin plus the 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 launcher that, embedded, calls POST /api/v1/pads/create-by-parent same-origin with CSRF token. Opened as a top-level page, it shows an error and creates nothing
      • 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: Nextcloud's own origin plus the 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. It redirects to Nextcloud's share page with the selected file, through Nextcloud's own route, so with the installation path and any index.php the server needs. It does not look at the share: the share page checks the token and asks for a share's password. Without files_sharing it answers 404.
    • 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 target addresses that are not public (DNS/IP checks), decided on the address itself, the same way however it is written and on every supported PHP version: IPv4 outside the blocks IANA's special-purpose registry marks as not globally reachable (shared address space 100.64.0.0/10 among them) and outside multicast. A NAT64 (64:ff9b::/96) or 6to4 (2002::/16) address counts as the IPv4 address it carries; apart from these, IPv6 only in global unicast 2000::/3, outside the blocks there marked not globally reachable (2001::/23, 2001:db8::/32, 3fff::/20). Other NAT64 prefixes are not decoded: the local-use 64:ff9b:1::/48 is refused, and one a network chose inside 2000::/3 counts as plain IPv6. The refused address goes to the log at debug.
      • a pad URL may name its server by an IPv6 address in brackets, https://[2606:4700::1111]/p/pad, while the allowlist is empty: the admin page takes no IPv6 address.
      • DNS result is pinned for the outbound fetch (rebinding mitigation)
      • the pin holds through a proxy from the server's environment too (https_proxy, all_proxy; Nextcloud's own proxy setting does not apply to this fetch): the proxy is asked for the checked address, never the name. A proxy that opens a tunnel only to a host name refuses the fetch, which the user reads as the pad's server not being reachable.
      • 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; a user value fills one segment, as docs/templates.md says.
      • 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, external or empty template, an invalid target name, or Etherpad refused), 403 (the target's folder takes no new file, or no pad type is enabled: pad_type_disabled), 404 (template id not found in userspace, or the target's folder not there), 409 (filename collision, or pad_file_changed), 503 with retryable: true (Etherpad cannot be reached or is not set up, or the file is locked), 500 (any other unexpected failure).
  • 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 already_initialized to anyone who can read it, with pad_id and access_mode as meta-by-id has them.
  • 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.
    • Where the fields come from:
      • pad_id, access_mode and the addresses of a pad on this server come from the file's row, not its text. A file with no row, such as a copy, answers them empty, and Etherpad is not asked. So does a file the user may not read, which File Access Control can leave them able to find.
      • A pad on another server has no row, so its file says, and its address counts only once it passes the checks. A reader gets that address too, which edits the pad: no read-only one is known for it. A file with a row whose text names such a pad reports its row's pad here, and its open and sync answer 400.
      • A reader of a pad on this server who may not write the file gets a public pad's read-only address and no pad_id, which would edit it, and a protected pad's pad_id and no address. An open answers a reader's pad_id the same. It is no protection: the file a reader may read usually holds the id.
      • A row that cannot be read is an error; an address that cannot be built stays empty.
      • An open still reads the text. It answers 400 where the text names another pad than the row, on this server or another, so these answers can name a pad whose open is refused.
      • resolve answers the same way. So does an initialize that finds the file initialized, which has to read the file first, so a file the user may not read fails there.
    • 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, periodic=1
    • Result: snapshot sync Etherpad -> .pad, 200 with status=updated, unchanged, locked or deferred, the last two as the points below say; updated says how often the write waited for the file's lock (lock_retries).
    • A sync may write the file, so a user who may not change it - a reader of a read-only share - gets 403, as initialize answers, before the file is read or Etherpad is asked, also where nothing would be written. The right is checked once, as the sync starts: a share on this server made read-only while a sync runs does not stop that sync. The Viewer and the embed page get no sync address for such a user; one opened while the share still allowed writing gets 403 on its next sync.
    • 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.
    • periodic=1 says the sync comes from a timer, as the Viewer's and the embed page's do. Such a sync, without force=1, runs only where it can claim the file for half the sync interval, rounded up: within that time after another timed sync of the file began, or after another sync looked at the file, it answers 200 with status=deferred, the file not read and Etherpad not asked, so pad_id is empty and external false. A timed sync that fails holds the next ones off as well. Every viewer of a pad syncs on its own timer, and this keeps them from each asking Etherpad, and each writing the file, a version each time, while someone types; a later timed sync, or any other, writes what is new. It takes a memory cache (memcache.local or memcache.distributed): without one, no sync is deferred. Where each web server has a cache of its own, only the syncs one server takes are paced.
    • 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. A file that has a row is refused with 400.
  • GET /api/v1/pads/sync-status/{fileId}

    • Controller: PadLifecycleController::syncStatusById
    • Writes nothing to the file, so it answers a reader of a read-only share too.
    • 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. A file that has a row is refused with 400.
  • 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, embed_url } when an active row names another .pad in the requester's file tree that this request may read.
      • 200 with { found: false } when there is no such original, also when the copy itself cannot be read or parsed. Every miss answers alike, so the endpoint does not tell rows the requester cannot reach.
  • 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 takes it as 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. For a pad on this server, meta-by-id and an initialize that finds the file initialized answer it empty if the file has no row. They and an open answer it empty for a reader of a public pad on this server.

  • pad_url: preferred target URL for public/external pads.

  • access_mode: public or protected. Empty in meta-by-id, resolve and an initialize that finds the file initialized if the file has no row and names no pad on another server.

  • status (sync): updated, unchanged, locked or deferred; POST /api/v1/pads/sync/{fileId} above says what updated, locked and deferred carry.

  • status (sync-status): synced, out_of_sync, or unavailable with reason: external_no_revision for a pad on another server. in_sync is always there, null for unavailable.

  • snapshot_rev (sync, sync-status): the revision the .pad holds – the Etherpad revision for an own pad, the app's own count of written snapshots for a pad on another server. current_rev (sync-status, and a sync that answers unchanged for an own pad): the pad's revision in Etherpad.

  • sync_url, sync_status_url (open/open-by-id): the sync and sync-status endpoints, empty for a user who may not change the file; sync_interval_seconds: how often the Viewer and the embed page sync.

  • 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, or rejected this Nextcloud's API key for - that lasts until an admin fixes the key - and one made while no Etherpad address or key is saved. 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. A request this instance's Etherpad answered with an error answers 400 without it: a pad or parameter Etherpad refuses stays refused, and its own internal error is answered the same. 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 (the pad viewer, for a user the app is enabled for)
  • OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent -> LoadPublicShareScriptsListener (the pad viewer on a public share page, for any visitor)
  • OCP\Files\Template\RegisterTemplateCreatorEvent -> RegisterTemplateCreatorListener (for a user the app is enabled for; docs/templates.md)
  • OCP\Files\Template\BeforeGetTemplatesEvent -> BeforeGetTemplatesListener (priority -100)
  • OCP\Files\Template\FileCreatedFromTemplateEvent -> FileCreatedFromTemplateListener (refuses a user the app is not enabled for; docs/templates.md)
  • OCA\Viewer\Event\LoadViewer -> LoadViewerListener (the pad viewer, for a user the app is enabled for)
  • 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 (either way in, leaves the pad's step out for a restore done as a user the app is not enabled for; docs/architecture.md, Trash/Restore)
  • 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, whose groups' sessions go by RevokeGoneFilesSessionsJob; 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, and those its walk did not reach by RevokeFolderSessionsJob; 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 stores an address that normalizes to etherpad_host empty, so the API follows a later change of etherpad_host. A server that cannot reach Etherpad at the base URL needs its own address here; the connection test tries it before a save.
    • Older versions stored an empty field as a copy of etherpad_host. While the two match, the form shows the field empty and the next save clears it. A copy of a base URL that has moved since, through occ or the form, reads as an address of its own and is kept: check the field after an upgrade.
  • 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) — how often each visible viewer and embed page sends a timed sync; what that costs, measured: docs/architecture.md ("Open viewers")
  • 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) — the hosts the app fetches a pad on another server from. It opens no frame-src: that names only the origin of etherpad_host, as a pad on another server is shown in the app's own view.
  • 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'