Skip to content

Latest commit

 

History

History
475 lines (422 loc) · 24.5 KB

File metadata and controls

475 lines (422 loc) · 24.5 KB

API Reference

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

Nextcloud App Routes

Base: /apps/etherpad_nextcloud

  • GET /

    • Controller: ViewerController::showPad
    • Query: file=/path/to/file.pad
    • Purpose: compatibility entry route that redirects to native Files viewer URL.
  • GET /by-id/{fileId}

    • Controller: ViewerController::showPadById
    • Purpose: compatibility entry route via file ID; redirects to native Files viewer URL.
  • 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 Missing YAML 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
  • 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
  • GET /public/{token}

    • Controller: PublicViewerController::showPad
    • Query (folder share): file=/subfolder/file.pad
    • Purpose: compatibility route for public shares; redirects to /s/{token} with selected file.
    • UX behavior:
      • Errors are rendered as noviewer template (not raw JSON).
      • Error page includes back-link to share entry page (/s/{token}).
  • GET /api/v1/public/open/{token}

    • Controller: PublicViewerController::openPadData
    • Query (folder share): file=/subfolder/file.pad
    • 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_snapshot=true, empty url, snapshot_text, and sanitized snapshot_html; no Etherpad session cookie
      • public/external pad share: regular public Etherpad URL

Admins can switch either pad type off (see the admin settings). POST /pads and POST /pads/create-by-parent refuse a disabled accessMode with 403. Pads that already exist are unaffected.

POST /pads/from-template and the initialize endpoints do not refuse a disabled mode — they create the pad in the enabled mode instead, so a template or a .pad that arrived outside the UI stays usable. They only return 403 when no pad type is enabled at all. Note this can widen access: a protected template becomes a public pad when protected pads are off.

The refusal carries code: pad_type_disabled, plus access_mode naming the disabled type — that field is absent when no pad type is enabled at all. Branch on the code rather than on the message text.

POST /pads/from-url is deliberately exempt: external pads are governed solely by the separate external-pad policy, not by these two settings.

  • POST /api/v1/pads

    • Controller: PadCreateController::create
    • Params:
      • file (required)
      • accessMode (public|protected, optional, default protected)
    • Result: creates pad, file, and binding.
  • 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), 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.
  • POST /api/v1/pads/initialize

    • Controller: PadSessionController::initialize
    • Params: file=/path/file.pad
    • Purpose: explicit frontmatter initialization for empty/legacy .pad files.
  • POST /api/v1/pads/initialize-by-id/{fileId}

    • Controller: PadSessionController::initializeById
    • Purpose: explicit frontmatter initialization by stable Nextcloud fileId.
  • 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)
    • 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 (updated or unchanged).
    • force=1 requests an immediate upstream re-check, but unchanged snapshots are still not rewritten.
    • 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/trash

    • Controller: PadLifecycleController::trash
    • Params: file=/path/file.pad
    • Result:
      • 200 with status=trashed for successful trash flow.
        • includes snapshot_persisted (true|false) if file lock prevented snapshot write.
        • includes delete_pending (true|false): true when Etherpad delete is deferred to background job.
      • 409 with status=skipped + reason on invalid lifecycle state (for example already pending delete).
        • includes transition-race guard reason binding_state_transition_conflict on concurrent state updates.
  • POST /api/v1/pads/restore

    • Controller: PadLifecycleController::restore
    • Params: file=/path/file.pad
    • Result:
      • 200 with status=restored for successful restore flow.
      • 409 with status=skipped + reason on invalid lifecycle state.
        • includes transition-race guard reason binding_state_transition_conflict on concurrent state updates.
  • 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). Reuses the same "frontmatter → fresh pad" path as NodeRestoredEvent but is guarded so it refuses when a binding row already exists.
    • 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.
      • 409 with status=skipped + reason=external_pad for external (ext.*) frontmatter; recovery doesn't apply there.
      • 409 with message and the PadAlreadyHasBindingException mapping if a binding row already exists for the file.
  • 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, trashed/pending-delete binding, binding for a file not addressable in the requester's userspace, unparseable frontmatter, orphan itself not readable, self-loop). Payload shape and status are intentionally identical so the endpoint cannot be used to probe for binding rows that belong to other users.
  • 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_on_trash (yes|no)
    • 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

      • pad_count

      • latency_ms

      • target

      • pending_delete_count

      • checks — one entry per verified part, so a failure points at the field that caused it: api, api_key, base_url, protected_pads. Each 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.

        base_url is the only entry that performs additional I/O: a short GET against the browser-facing Etherpad URL, which nothing else contacts. Unlike the API host it gets no local-address exemption — that exemption is what would let an admin-supplied address probe the server's own network. Redirects are not followed and the body is not buffered, since neither is needed for a status code. The protection is Nextcloud's, so allow_local_remote_servers=true disables it instance-wide.

        The line is never an error. Nextcloud may be unable to reach the public URL by design (split-horizon DNS, egress firewall), and a blocked local address is reported as unverified rather than broken, since an internal deployment may still serve it to users. It runs even when the base URL matches the API URL, because the API call may succeed against a loopback address no browser can use; both lines then land on the same field, where the more severe verdict is the one shown.

      • protected_pads — the same verdict as 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 502 with 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.
  • POST /api/v1/admin/consistency-check

    • Controller: AdminController::consistencyCheck
    • Auth: admin only
    • Purpose: optional structural integrity check across binding table and .pad files.
    • Result includes:
      • binding_without_file_count
      • file_without_binding_count
      • invalid_frontmatter_count
      • frontmatter_scanned
      • frontmatter_skipped
      • samples (bounded debug sample lists per issue class)
  • POST /api/v1/admin/retry-pending-deletes

    • Controller: AdminController::retryPendingDeletes
    • Auth: admin only
    • Purpose: immediate retry of deferred Etherpad deletions:
      • state=pending_delete
    • Result:
      • attempted
      • resolved
      • failed
      • remaining
  • POST /api/v1/admin/test-fault

    • Controller: AdminController::setTestFault
    • Auth: admin only
    • Availability: only when Nextcloud debug mode is enabled
    • Params:
      • fault (string, optional; empty clears active fault)
    • Purpose: deterministic E2E fault injection for lifecycle error-path testing.
    • Supported fault values:
      • trash_read_lock
      • trash_write_lock
      • trash_write_fail
      • restore_read_lock
      • restore_write_lock
      • restore_write_fail

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, currently missing_binding for MissingBindingException. The viewer and embed use this to swap a dead-end error message for the recovery UI (POST /api/v1/pads/recover-from-snapshot/{fileId} + optional GET /api/v1/pads/find-original/{fileId} lookup).

Cookie Behavior (Protected Pads)

  • Controllers use explicit Set-Cookie response headers for Etherpad session bootstrap.
  • 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 render the stored .pad snapshot and do not set an Etherpad session cookie
    • no additional custom app cookies are added in the same response
  • If future changes introduce multiple app-level cookies on these responses, this must be implemented and tested explicitly.

Frontend API Usage

  • src/files-main.js
    • wires the Files/public-share frontend modules.
  • src/files/open-action.js
    • extracts fileId directly from the authenticated Files action context whenever available.
    • uses GET /api/v1/pads/resolve mainly as a fallback to convert file path -> fileId when no stable fileId is available.
  • src/files/pad-opener.js
    • opens in files view through Nextcloud router (fileid, openfile=true).
    • clears openfile/editing again when the native viewer closes.
  • src/files/public-pad-menu.js
    • registers Public pad in + Neu via API-only runtime capability checks:
      • modern: addNewFileMenuEntry / getNewFileMenu().registerEntry
      • legacy fallback: OC.Plugins.register('OCA.Files.NewFileMenu', ...)
  • src/files/public-share-pad-links.js
    • global click interception is only used on public-share routes to remap share download links to the pad viewer.
  • src/files/route-controller.js
    • normalizes stale .pad Files routes without openfile=true.
    • opens public-share pad links through the native viewer when available.
  • 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.
    • 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, renders the same recovery flow as the inline viewer (lookup + recover).
    • 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.
    • redirects to returned embed_url after successful pad creation.

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}
  • Stale URL normalization:
    • Route /apps/files/files/{fileId}?dir=... without openfile=true is normalized (for .pad) to /apps/files/files?dir=... so future .pad opens continue to work correctly.

Test Scripts

  • tests/integration/e2e-pad-flow.sh
    • happy path: create -> open -> trash -> restore -> open
  • tests/integration/e2e-sync-failure.sh
    • failure path: create -> sync(force=1) must fail with non-2xx when Etherpad is down
    • goal: no silent best-effort success on critical sync
  • tests/integration/e2e-lifecycle-state-guards.sh
    • state guards: restore(active) and trash(pending_delete) must return 409 status=skipped
  • tests/integration/e2e-lifecycle-trash-failure.sh
    • deferred-delete path: create -> trash remains 200 with delete_pending=true when Etherpad is down
    • post-condition: trash-again must return 409 status=skipped (binding_not_active)
  • tests/integration/e2e-lifecycle-restore-failure.sh
    • failure path: create -> trash -> restore must fail with non-2xx when Etherpad is down
    • post-condition: trash-again must return 409 status=skipped (binding_not_active)
  • tests/integration/e2e-lifecycle-trash-lock-tolerant.sh
    • lock path: inject trash_write_lock, trash must stay 200 and return snapshot_persisted=false
    • post-condition: restore still succeeds after fault is cleared
  • tests/integration/e2e-lifecycle-restore-write-failure.sh
    • write-failure path: inject restore_write_fail, restore must fail non-2xx
    • post-condition: restore succeeds after fault is cleared
  • tests/integration/e2e-public-share-folder.sh
    • folder share: viewer/open/download/reopen + DAV-style file parameter + route switch
  • tests/integration/e2e-public-share-single-file.sh
    • single-file share: viewer/open/download/reopen + DAV-style file parameter + route switch

Nextcloud Events/Listeners

Registered in lib/AppInfo/Application.php.

  • OCA\Files\Event\LoadAdditionalScriptsEvent -> LoadFilesScriptsListener
  • OCA\Viewer\Event\LoadViewer -> LoadViewerListener
  • OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent -> LoadPublicShareScriptsListener
  • OCA\Files_Trashbin\Events\MoveToTrashEvent -> MoveToTrashListener
  • OCA\Files_Trashbin\Events\NodeRestoredEvent -> RestoreFromTrashListener
  • OCP\Security\CSP\AddContentSecurityPolicyEvent -> CSPListener
  • OCP\Files\Template\RegisterTemplateCreatorEvent -> RegisterTemplateCreatorListener

App Config Keys

  • etherpad_host
  • etherpad_api_key
  • etherpad_api_version (default 1.2.15)
  • etherpad_cookie_domain
    • Optional explicit cookie domain for protected pad session bootstrap.
    • Fallback when empty:
      • derived from etherpad_host
      • IP/invalid hosts -> empty domain attribute
      • recommendation: set explicitly for complex proxy/subdomain setups
  • delete_on_trash (yes|no, default yes)
  • sync_interval_seconds (default 120, clamp 5..3600)
  • allow_external_pads (yes|no, default yes)
  • 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'
  • test_fault (debug-only E2E fault injection; empty by default)