Skip to content

feat(templates): let admins share pad templates with everyone - #181

Merged
Jaggob merged 11 commits into
mainfrom
feat/global-pad-templates
Aug 8, 2026
Merged

feat(templates): let admins share pad templates with everyone#181
Jaggob merged 11 commits into
mainfrom
feat/global-pad-templates

Conversation

@Jaggob

@Jaggob Jaggob commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

An admin can now define pad templates for the whole instance: upload a .pad file under Administration settings → Pads and it appears in the template picker for every account, with no per-user setup – the same idea Collabora offers for its own document templates.

The templates are ordinary .pad files, so creating from one takes the existing path: content copied, placeholders resolved, pad provisioned, binding written. Only the storage and the listing are new.

Storage. In the app's appdata folder (IAppData owns it; the nodes come from IRootFolder, because the template API needs a real OCP\Files\File). No versioning and no trash there, so overwriting is refused unless the request says it means to replace, and the page asks first. Check and write happen together under an exclusive lock on the name – Folder::newFile() overwrites silently, so asking beforehand would leave a window for a second upload. Deleting takes the same lock. The key is hashed and namespaced: Nextcloud's DB locking stores it in a 64-character column, which the plain path exceeds.

Validation at upload time, where an admin can act on it: not a pad file, no pad in its frontmatter, external pad, empty, over 2 MiB, a name with a path, or a name the instance forbids (IFilenameValidator). A failing listing is not swallowed – an empty list must never mean "appdata unreadable". Only the picker tolerates it and logs, because a throwing provider takes every other app's templates down with it.

Two fixes that belong here: the New pad entry was tied to protected pads alone, so a public-only instance had no way to reach the templates; and a pad type switched off mid-picker left an empty .pad behind instead of failing. Also reserves Public pad.pad and Public pad from URL.pad – a later PR labels its own picker tiles with those names, and reserving them afterwards cannot undo a file that is already there.

Follow-up: the templates introduced here can be used to move the public-pad and external-pad creation onto Nextcloud's template flow in a later PR, replacing some of the app's own menu and dialog code.

Testing

  • PHPUnit 542/2,113, Psalm clean, Vitest 178, 191 translation keys identical in de/es/fr
  • Full Playwright suite on NC 33 with Collabora: 19 passed, 2 flaky (green on retry), 4 failed – all four fail identically on main (GroupFolders trashbin HTTP 500, three timeouts), verified by re-running them against main
  • New e2e: admin uploads a template → tile appears → creating from it yields the template's content and a pad id of its own

Notes for review

  • The PHPUnit stubs grow by ~170 lines although nextcloud/ocp is a dependency. The bootstrap is built on stubs throughout; switching it over belongs in its own PR.
  • Template ids are global:<file name>, not <fileId>: the name is matched against the directory listing instead of passed to get(), which is what keeps a crafted value inside the folder.

Jaggob added 9 commits August 2, 2026 13:23
Nextcloud only ever looks at one Templates folder per account, so a team
template had to be set up by every member individually – and a read-only
share is not a supported source at all. An admin can now upload .pad files
under Administration settings, and they appear in the picker for every
account with no per-user setup.

The templates are ordinary .pad files, so creating from one takes the path
that already exists: content copied, placeholders resolved, pad provisioned,
binding written. Only the storage and the listing are new.

They live in the app's appdata folder, which IAppData owns. That folder has
no versioning and no trash, so overwriting an existing name is refused
unless the request says it means to replace, and the settings page asks
first. The check and the write happen together under an exclusive lock on
the name: Folder::newFile() overwrites what is already there, so asking
"does it exist?" beforehand would leave a window for a second upload to
create the file and this one to destroy it unnoticed. Deleting takes the
same lock.

What can be rejected is rejected at upload time, where an admin can act on
it – not a pad file, no pad in its frontmatter, a pad on another Etherpad
server, empty, or over 2 MiB. A failure to list is deliberately not
swallowed on the settings page: an empty list must not be able to mean
"appdata unreadable". The picker is the one place that tolerates it, logs
and carries on, because a provider that throws would take every other app's
templates down with it.

The "New pad" entry is now registered while either pad type is enabled. It
was tied to protected pads alone, so an instance with only public pads had
no entry – and would have had no way to reach the shared templates.
Nextcloud shows the picker whenever there is something to pick. Until now
this instance had nothing, so "+ New pad" created the file directly and the
helper never had to confirm anything. The shared templates change that: the
picker appears, nobody presses its button, and the file is never created.

Matched by the control itself — it is an <input type="submit">, and matching
its label would also find the "+ New" menu entries still in the page behind
the modal.
The lock key was the full path. Nextcloud's database locking writes that
into a 64-character column, and an ordinary instance id plus file name is
already 68 – so on any instance not backed by Redis the upload would fail
with a database error, invisible on a Redis test server. The key is hashed
now, prefixed with the app id, and stays 63 characters for any name. Three
tests pin it: the bound, that two names never collide, and that delete takes
the very same key as the write.

Filename rules beyond our own are Nextcloud's: control characters, the
configured forbidden characters and reserved names. IFilenameValidator gets
the name before it is stored, so the admin reads a sentence instead of a 500
from deep in the storage. The .pad suffix, the leading dot and the path
check stay ours.

A name over the length limit reported "Template name is required", which is
not what happened. It has its own message, and counts characters rather than
bytes – the name is what an admin reads in the picker.

Adds the missing half of the folder race: another request creating it first
was handled but not tested, only the failure that keeps its exception.

The creator comment described a second tile the picker does not offer on
this branch – that is the follow-up.
delete() validated the name like a new upload. Those rules say what may be
stored; applying them to removal means a template that is already there
becomes unremovable the moment the rules tighten – ours or the ones
Nextcloud enforces – while it stays visible in the list and in the picker.
The name is now only trimmed. What makes that safe is the exact match
against the listing in the storage, not the shape of the name.

The lock recorder in the tests kept only the key, so an accidental
LOCK_SHARED would have passed unnoticed. It records the type too, and the
write test asserts it.
Splitting the branch left lib/Settings/AdminSettings.php behind, so the
section rendered as an empty box: the template reads its headings, button
labels and the two API URLs out of that array by name, and a missing key
renders as an empty string. Nothing failed, nothing was logged – the section
was simply blank between two others.

Neither test level could see it. The PHPUnit tests never render the
template; the Vitest tests build their own DOM around the script. So two
checks now cover exactly that seam:

- AdminSettingsTest compares every `$_['…']` and `$_['l10n']['…']` the
  template reads against what the provider hands over. Removing a single key
  fails it by name.
- An e2e case opens the settings page as an admin and asserts the heading,
  the button label and that the list actually loads – the view the bug was
  found in.

Also brings over the nine translations that came with those labels.
Nextcloud points a template tile at /core/preview, which has nothing to
render for a .pad — the request 400s and the picker falls back to its
generic document icon. The tiles carry the app icon directly now, the same
way the pad file type does everywhere else.

This covers the templates this app offers. A user's own .pad in their
Templates folder is built by Nextcloud itself and still shows the generic
icon; that would need a listener on the templates event, which is a separate
decision.
Two things this PR left to its follow-up, which each make it unstable on
its own:

The app labels its own picker tiles "Public pad" and "Public pad from URL".
Until now an admin could upload a template of exactly that name, and the
follow-up would then show two tiles reading the same — indistinguishable to
whoever picks one, and no reservation added later can undo a file that is
already there. The names are reserved from here on.

A pad type can be switched off while the picker is open. Materialising then
fails correctly, but the failure was treated as a transient one: the blank
fallback ran, failed for the very same reason, and left an empty .pad in the
user's files that never opens. Both the template path and Nextcloud's blank
entry now remove the file and let the exception travel. This is not
hypothetical for the public-only instances this PR unblocks.

Three things the settings page got wrong:

- A failed listing showed the error and "No shared templates yet" at once,
  claiming an empty list when we simply could not read one. The empty note
  is hidden while loading and after a failure, and the test that promised to
  tell the two apart now actually checks it.
- The file input is visually hidden but stayed focusable and nameless; the
  visible button operates it, so it leaves the tab order. Every delete
  button read just "Delete" — each now names its template.
- Uploading a template wiped the connection test and save results, which it
  says nothing about. The template status is its own group; verified by
  putting the coupling back and watching the test fail.

Adds the end-to-end contract this feature exists for: an admin uploads a
template, it appears as a tile, and creating from it yields a pad with the
template's content and a pad id of its own — the last part is what keeps two
files from sharing one pad. It cleans up after itself so the tile does not
leak into the next run.
The e2e spec uploaded the template before the try block, so an upload that
reached the server but failed the assertion on the page left the template on
the test instance – and the next run would have seen it as an extra tile.
Everything the cleanup covers now happens inside the try.

The accessible name of a delete button was the button label glued to the
file name, which reads "Löschen Meeting.pad" in German and cannot be fixed
from the parts. It is one translatable sentence with a placeholder now, read
from the page rather than assembled in the script — the test uses a German
sentence to prove exactly that.

The reserved-name test checked one spelling of one name. It runs over both
constants and a mixed-case variant now.
Three refinements from the second review, none of them behaviour the tests
missed:

- The lock key was 63 characters against a 64-character column — one to
  spare, and a longer prefix later would have broken it silently, on
  non-Redis instances only. Half the hash is plenty for telling file names
  in one folder apart, which brings it to 55 with the arithmetic written
  down next to it. The comment also says what the namespace does not do:
  coordinate with anything else reaching into appdata.
- addGlobalTemplate() says that it expects a validated name. The read paths
  match against the listing and need no such promise; this one hands the
  name to get() and newFile(), so a second caller bypassing validateName()
  would have direct reach.
- The 2 MiB limit existed only on the server, so a mis-picked large file was
  read into memory and posted, to fail against post_max_size with a message
  that explains nothing. The page now says the same sentence the server
  would, before reading the file — verified by removing the check and
  watching the test fail.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 3, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Missing pad_id validation ✗ Dismissed 🐞 Bug ≡ Correctness
Description
PadTemplateAdminService::add() can store a template even when PadFileService parses an empty padId
(e.g., missing pad_id key). That template later fails during creation because
PadCreationService::materializeTemplateInto() requires a non-empty pad_id, making the shared tile
unusable for everyone.
Code

lib/Service/PadTemplateAdminService.php[R78-80]

+		if ($pad->isExternal || str_starts_with($pad->padId, 'ext.')) {
+			throw new AdminValidationException('template', $this->l10n->t('A pad on another Etherpad server cannot be used as a template.'));
+		}
Relevance

●●● Strong

Team previously accepted handling missing/empty template pad_id explicitly in creation flow; likely
also wants upload-time rejection (PR #58).

PR-#58
PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The upload path parses pad metadata but never rejects an empty padId, even though the parser
defaults missing pad_id to an empty string. The downstream creation path explicitly fails for
templates with empty padId, so an admin can upload a shared template that will deterministically
fail when used.

lib/Service/PadTemplateAdminService.php[61-80]
lib/Service/PadFileService.php[167-172]
lib/Service/PadCreationService.php[322-337]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PadTemplateAdminService::add()` accepts and stores templates where the parsed `.pad` frontmatter has an empty `padId` (missing `pad_id`). Later, creating from that template fails because the materialization pipeline rejects templates with no usable `pad_id`.
## Issue Context
- `PadFileService::extractPadMetadata()` defaults missing `pad_id` to `''`.
- `PadCreationService::materializeTemplateInto()` throws if `padId === ''`.
- The admin upload path currently only rejects external pads, not empty `padId`.
## Fix Focus Areas
- lib/Service/PadTemplateAdminService.php[61-81]
- tests/phpunit/unit/PadTemplateAdminServiceTest.php[49-70]
- tests/phpunit/unit/PadTemplateAdminServiceTest.php[221-240]
### Suggested change
After `readPad($content)`, add a validation like:
- if `trim($pad->padId) === ''` => throw `AdminValidationException('template', <localized message>)`
### Tests
Add a unit test case (using `buildService($storage, '')`) asserting `addGlobalTemplate()` is never called and `AdminValidationException` is thrown.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Missing pad_id validation ✗ Dismissed 🐞 Bug ≡ Correctness
Description
PadTemplateAdminService::add() can store a template even when PadFileService parses an empty padId
(e.g., missing pad_id key). That template later fails during creation because
PadCreationService::materializeTemplateInto() requires a non-empty pad_id, making the shared tile
unusable for everyone.
Code

lib/Service/PadTemplateAdminService.php[R78-80]

+		if ($pad->isExternal || str_starts_with($pad->padId, 'ext.')) {
+			throw new AdminValidationException('template', $this->l10n->t('A pad on another Etherpad server cannot be used as a template.'));
+		}
Relevance

●●● Strong

Team previously accepted handling missing/empty template pad_id explicitly in creation flow; likely
also wants upload-time rejection (PR #58).

PR-#58
PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The upload path parses pad metadata but never rejects an empty padId, even though the parser
defaults missing pad_id to an empty string. The downstream creation path explicitly fails for
templates with empty padId, so an admin can upload a shared template that will deterministically
fail when used.

lib/Service/PadTemplateAdminService.php[61-80]
lib/Service/PadFileService.php[167-172]
lib/Service/PadCreationService.php[322-337]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PadTemplateAdminService::add()` accepts and stores templates where the parsed `.pad` frontmatter has an empty `padId` (missing `pad_id`). Later, creating from that template fails because the materialization pipeline rejects templates with no usable `pad_id`.
## Issue Context
- `PadFileService::extractPadMetadata()` defaults missing `pad_id` to `''`.
- `PadCreationService::materializeTemplateInto()` throws if `padId === ''`.
- The admin upload path currently only rejects external pads, not empty `padId`.
## Fix Focus Areas
- lib/Service/PadTemplateAdminService.php[61-81]
- tests/phpunit/unit/PadTemplateAdminServiceTest.php[49-70]
- tests/phpunit/unit/PadTemplateAdminServiceTest.php[221-240]
### Suggested change
After `readPad($content)`, add a validation like:
- if `trim($pad->padId) === ''` => throw `AdminValidationException('template', <localized message>)`
### Tests
Add a unit test case (using `buildService($storage, '')`) asserting `addGlobalTemplate()` is never called and `AdminValidationException` is thrown.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Aug 3, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Add instance-wide shared pad templates managed by admins

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add admin-managed .pad templates shown to all users in the template picker.
• Store templates in appdata with locking, validation, and explicit replace/delete semantics.
• Fix template entry visibility and delete empty .pad files when pad types disable mid-picker.
Diagram

graph TD
  UI["Admin settings UI"] --> API["Admin templates API"] --> SVC["Admin template service"] --> STORE["Template storage"] --> APPDATA[("AppData templates")]
  PICKER{{"NC template picker"}} --> PROVIDER["Template provider"] --> STORE
  PICKER --> LISTENER["Create-from-template listener"]

  subgraph Legend
    direction LR
    _ui["UI"] ~~~ _ext{{"Nextcloud"}} ~~~ _db[("Storage")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a shared Files folder (group folder) as the canonical template source
  • ➕ Gets built-in versioning/trash/search and standard file management UI
  • ➕ No appdata storage/locking implementation needed
  • ➖ Not instance-wide without per-user configuration; users can misconfigure or opt out unintentionally
  • ➖ Read-only shares are not a reliable template source in Nextcloud
2. Identify global templates by fileId instead of name-based IDs
  • ➕ Stable identity across renames; avoids name collisions in IDs
  • ➖ Harder to safely resolve arbitrary client-supplied IDs without widening access beyond the intended folder
  • ➖ Current name+listing approach intentionally constrains lookup to the app-owned directory
3. Implement multipart/WebDAV uploads instead of JSON body upload
  • ➕ Avoids reading full file into browser memory and is a more standard upload flow
  • ➖ More endpoint/UI complexity (upload handling, progress, CSRF) for a capped 2 MiB payload
  • ➖ JSON keeps implementation small and matches explicit replace-confirmation semantics

Recommendation: The PR’s approach is appropriate for truly instance-wide templates: appdata storage avoids relying on per-user template folder configuration, and the exclusive per-name lock correctly prevents overwrite races given Folder::newFile() behavior. Keeping picker failures non-fatal (log+empty) while keeping the admin page strict is the right trade-off to avoid breaking the global picker while still surfacing operational issues to admins.

Files changed (49) +2502 / -54

Enhancement (18) +971 / -21
admin-settings.cssStyle shared template list and visually-hidden file input +36/-0

Style shared template list and visually-hidden file input

• Adds styles for a simple template list with per-row delete button and a hidden file input controlled via a button.

css/admin-settings.css

etherpad_nextcloud-admin-settings.mjsUpdate built admin settings bundle for templates section +1/-1

Update built admin settings bundle for templates section

• Updates the compiled admin settings JS bundle to include shared template management UI logic.

js/etherpad_nextcloud-admin-settings.mjs

etherpad_nextcloud-admin-settings.mjs.mapUpdate source map for admin settings bundle +1/-1

Update source map for admin settings bundle

• Keeps the JS source map in sync with the updated bundle.

js/etherpad_nextcloud-admin-settings.mjs.map

de.jsAdd de translations for shared templates UI +27/-0

Add de translations for shared templates UI

• Adds German strings for shared template section labels, confirmations, and errors.

l10n/de.js

de.jsonAdd de JSON translations for shared templates UI +27/-0

Add de JSON translations for shared templates UI

• Adds the same German translation keys in JSON format.

l10n/de.json

es.jsAdd es translations for shared templates UI +27/-0

Add es translations for shared templates UI

• Adds Spanish strings for shared template section labels, confirmations, and errors.

l10n/es.js

es.jsonAdd es JSON translations for shared templates UI +27/-0

Add es JSON translations for shared templates UI

• Adds the same Spanish translation keys in JSON format.

l10n/es.json

fr.jsAdd fr translations for shared templates UI +27/-0

Add fr translations for shared templates UI

• Adds French strings for shared template section labels, confirmations, and errors.

l10n/fr.js

fr.jsonAdd fr JSON translations for shared templates UI +27/-0

Add fr JSON translations for shared templates UI

• Adds the same French translation keys in JSON format.

l10n/fr.json

Application.phpRegister custom template provider when supported +3/-0

Register custom template provider when supported

• Registers PadTemplateProvider conditionally when Nextcloud exposes ICustomTemplateProvider.

lib/AppInfo/Application.php

AdminController.phpAdd admin endpoints for list/upload/delete templates +50/-0

Add admin endpoints for list/upload/delete templates

• Adds admin-only handlers for listing templates, uploading via JSON body with replace option, and deleting by name, using the common error runner.

lib/Controller/AdminController.php

TemplateExistsException.phpAdd TemplateExistsException for overwrite conflicts +24/-0

Add TemplateExistsException for overwrite conflicts

• Defines an exception to signal name collisions at write time to avoid pre-check race windows.

lib/Exception/TemplateExistsException.php

PadTemplateAdminService.phpAdd admin validation/orchestration for shared templates +154/-0

Add admin validation/orchestration for shared templates

• Implements list/add/delete with upload-time validation (name rules, size cap, non-empty, pad frontmatter parse, external pad rejection) and Nextcloud filename validation integration.

lib/Service/PadTemplateAdminService.php

PadTemplateStorage.phpImplement appdata template storage with per-name locking +224/-0

Implement appdata template storage with per-name locking

• Stores templates under appdata/APP_ID/templates, lists .pad files case-insensitively sorted, resolves by matching against listing, and add/delete under a hashed exclusive lock key to fit DB lock key limits.

lib/Service/PadTemplateStorage.php

AdminSettings.phpExpose template URLs and l10n strings to admin settings view +18/-0

Expose template URLs and l10n strings to admin settings view

• Adds template list/delete URLs and localized strings for the new templates section, including replace/delete confirmations and status messages.

lib/Settings/AdminSettings.php

PadTemplateProvider.phpExpose global templates as Nextcloud picker tiles +89/-0

Expose global templates as Nextcloud picker tiles

• Implements ICustomTemplateProvider for pad mimetype, returns tiles with ids global:<name>, sets a custom preview URL to the app icon, logs-and-skips listing failures in the picker, and resolves ids via storage.

lib/Template/PadTemplateProvider.php

admin-settings.jsAdd admin UI for listing/uploading/deleting shared templates +185/-18

Add admin UI for listing/uploading/deleting shared templates

• Adds rendering and API calls for templates; uploads via JSON to avoid URL-encoding inflation, confirms replace on name collisions, confirms delete, and isolates template status from other admin statuses.

src/admin-settings.js

admin-settings.phpRender shared templates section and data attributes +24/-1

Render shared templates section and data attributes

• Adds the shared templates section markup (list, empty state, upload control, status line) and data-* attributes for URLs and l10n consumed by JS.

templates/admin-settings.php

Bug fix (2) +42 / -4
FileCreatedFromTemplateListener.phpDelete created .pad when pad types disabled mid-flow +35/-0

Delete created .pad when pad types disabled mid-flow

• Catches PadTypeDisabledException during template materialization or blank init, deletes the target file to avoid leaving an unopenable empty .pad, then rethrows.

lib/Listeners/FileCreatedFromTemplateListener.php

RegisterTemplateCreatorListener.phpKeep '+ New pad' entry available when any pad type is enabled +7/-4

Keep '+ New pad' entry available when any pad type is enabled

• Changes registration so the template creator stays registered if either protected or public pads are enabled, fixing public-only instances losing the entry point.

lib/Listeners/RegisterTemplateCreatorListener.php

Tests (25) +1425 / -21
nextcloud.tsHandle template picker modal on blank pad creation +14/-0

Handle template picker modal on blank pad creation

• Updates E2E helper to confirm the picker modal when it appears (now more likely because shared templates can exist).

tests/e2e/fixtures/nextcloud.ts

admin-health-check.spec.tsAdd E2E check for shared templates section rendering/loading +26/-1

Add E2E check for shared templates section rendering/loading

• Adds a Playwright spec ensuring the templates section renders and either lists templates or shows the empty-state without an error status.

tests/e2e/specs/admin-health-check.spec.ts

admin-shared-templates.spec.tsAdd E2E: upload template → tile appears → create from it +87/-0

Add E2E: upload template → tile appears → create from it

• End-to-end verifies admin upload makes a tile visible to users and creation from it copies body but provisions a distinct pad id.

tests/e2e/specs/admin-shared-templates.spec.ts

admin-settings.test.jsAdd Vitest coverage for shared templates UI flows +235/-1

Add Vitest coverage for shared templates UI flows

• Adds JS tests for list rendering, error vs empty-state, accessible delete labels, JSON upload and reload, replace confirmation, delete flow, and safe text rendering of template names.

tests/js/admin-settings.test.js

bootstrap.phpExtend PHPUnit bootstrap for new OCP stubs +9/-0

Extend PHPUnit bootstrap for new OCP stubs

• Loads additional OCP stubs required by the new appdata/template provider integrations.

tests/phpunit/bootstrap.php

IAppDataFactory.phpAdd IAppDataFactory stub +13/-0

Add IAppDataFactory stub

• Adds a stub interface for unit testing appdata-backed storage.

tests/phpunit/stubs/OCP/Files/AppData/IAppDataFactory.php

File.phpExtend File stub for template/storage usage +12/-0

Extend File stub for template/storage usage

• Adds missing signatures used by template provider and storage tests.

tests/phpunit/stubs/OCP/Files/File.php

Folder.phpExtend Folder stub for listing/get/newFile +8/-1

Extend Folder stub for listing/get/newFile

• Updates stubbed methods to match new folder interactions in storage code/tests.

tests/phpunit/stubs/OCP/Files/Folder.php

IAppData.phpAdd IAppData stub +17/-0

Add IAppData stub

• Adds stub interface for appdata folder creation/access logic.

tests/phpunit/stubs/OCP/Files/IAppData.php

IFilenameValidator.phpAdd IFilenameValidator stub +13/-0

Add IFilenameValidator stub

• Adds stub used by admin upload name validation tests.

tests/phpunit/stubs/OCP/Files/IFilenameValidator.php

IRootFolder.phpUpdate IRootFolder stub for appdata directory resolution +4/-1

Update IRootFolder stub for appdata directory resolution

• Adjusts stub to support getAppDataDirectoryName and get() usage for resolving real file nodes.

tests/phpunit/stubs/OCP/Files/IRootFolder.php

InvalidPathException.phpAdd InvalidPathException stub +10/-0

Add InvalidPathException stub

• Adds stub exception used when Nextcloud filename validation fails.

tests/phpunit/stubs/OCP/Files/InvalidPathException.php

ISimpleFolder.phpAdd ISimpleFolder stub for appdata interactions +13/-0

Add ISimpleFolder stub for appdata interactions

• Adds stub needed for appdata folder creation and retrieval flows.

tests/phpunit/stubs/OCP/Files/SimpleFS/ISimpleFolder.php

ICustomTemplateProvider.phpAdd ICustomTemplateProvider stub +19/-0

Add ICustomTemplateProvider stub

• Adds stub interface so PadTemplateProvider can be tested without full OCP runtime.

tests/phpunit/stubs/OCP/Files/Template/ICustomTemplateProvider.php

Template.phpAdd Template stub +58/-0

Add Template stub

• Adds a stub Template object supporting jsonSerialize and custom preview URLs in provider tests.

tests/phpunit/stubs/OCP/Files/Template/Template.php

IConfig.phpExtend IConfig stub for pad type policy tests +2/-0

Extend IConfig stub for pad type policy tests

• Adds/extends config stub methods needed by pad-type policy and creator registration tests.

tests/phpunit/stubs/OCP/IConfig.php

IURLGenerator.phpAdd IURLGenerator stub methods +4/-0

Add IURLGenerator stub methods

• Adds stubbed imagePath/getAbsoluteURL methods used by PadTemplateProvider.

tests/phpunit/stubs/OCP/IURLGenerator.php

ILockingProvider.phpAdd ILockingProvider stub +22/-0

Add ILockingProvider stub

• Adds locking provider stub used by PadTemplateStorage and its unit tests.

tests/phpunit/stubs/OCP/Lock/ILockingProvider.php

AdminControllerTest.phpAdd controller tests for template endpoints and admin gating +61/-0

Add controller tests for template endpoints and admin gating

• Adds unit tests for list/upload/delete endpoints, validation error mapping, and refusal for non-admin users.

tests/phpunit/unit/AdminControllerTest.php

AdminSettingsTest.phpUpdate AdminSettings tests for new templates parameters +21/-0

Update AdminSettings tests for new templates parameters

• Extends AdminSettings unit tests to cover new URLs/labels for shared templates section.

tests/phpunit/unit/AdminSettingsTest.php

FileCreatedFromTemplateListenerTest.phpTest deletion of failed creations when pad types disabled +34/-0

Test deletion of failed creations when pad types disabled

• Adds tests ensuring created files are deleted when PadTypeDisabledException occurs for template materialization or blank creation.

tests/phpunit/unit/FileCreatedFromTemplateListenerTest.php

PadTemplateAdminServiceTest.phpAdd unit tests for admin template validation +241/-0

Add unit tests for admin template validation

• Covers list mapping, upload validation (name/content/size/frontmatter/external), reserved names, Nextcloud filename validation, and TemplateExists mapping.

tests/phpunit/unit/PadTemplateAdminServiceTest.php

PadTemplateProviderTest.phpAdd unit tests for template provider tiles and resolution +127/-0

Add unit tests for template provider tiles and resolution

• Covers mimetype filtering, global id format, custom preview URL, picker-safe error handling, and rejecting unknown template ids.

tests/phpunit/unit/PadTemplateProviderTest.php

PadTemplateStorageTest.phpAdd unit tests for appdata storage and locking semantics +346/-0

Add unit tests for appdata storage and locking semantics

• Validates listing filtering/sorting, safe lookup by listing, overwrite/replace behavior, lock acquisition/release, and appdata folder resolution/creation logic.

tests/phpunit/unit/PadTemplateStorageTest.php

RegisterTemplateCreatorListenerTest.phpUpdate tests for template creator registration logic +29/-17

Update tests for template creator registration logic

• Reworks tests to assert the creator is registered when either pad type is enabled and skipped only when both are disabled.

tests/phpunit/unit/RegisterTemplateCreatorListenerTest.php

Documentation (3) +61 / -8
README.mdDocument admin shared templates and appdata storage implications +12/-0

Document admin shared templates and appdata storage implications

• Adds documentation for instance-wide templates, placeholder resolution, access-mode behavior, and the lack of versioning/trash in appdata.

README.md

api-reference.mdDocument shared template admin API endpoints +25/-0

Document shared template admin API endpoints

• Adds GET/POST template endpoints, JSON upload details, replace semantics, and validation expectations.

docs/api-reference.md

templates.mdExplain personal/group/admin template sources and behavior +24/-8

Explain personal/group/admin template sources and behavior

• Updates templates documentation to include admin (instance-wide) templates stored in appdata and clarifies pad-type fallback behavior.

docs/templates.md

Other (1) +3 / -0
routes.phpAdd admin template management routes +3/-0

Add admin template management routes

• Introduces admin-only routes to list, upload, and delete shared templates.

appinfo/routes.php

@qodo-code-review

qodo-code-review Bot commented Aug 3, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Missing pad_id validation ✗ Dismissed 🐞 Bug ≡ Correctness
Description
PadTemplateAdminService::add() can store a template even when PadFileService parses an empty padId
(e.g., missing pad_id key). That template later fails during creation because
PadCreationService::materializeTemplateInto() requires a non-empty pad_id, making the shared tile
unusable for everyone.
Code

lib/Service/PadTemplateAdminService.php[R78-80]

+		if ($pad->isExternal || str_starts_with($pad->padId, 'ext.')) {
+			throw new AdminValidationException('template', $this->l10n->t('A pad on another Etherpad server cannot be used as a template.'));
+		}
Relevance

●●● Strong

Team previously accepted handling missing/empty template pad_id explicitly in creation flow; likely
also wants upload-time rejection (PR #58).

PR-#58
PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The upload path parses pad metadata but never rejects an empty padId, even though the parser
defaults missing pad_id to an empty string. The downstream creation path explicitly fails for
templates with empty padId, so an admin can upload a shared template that will deterministically
fail when used.

lib/Service/PadTemplateAdminService.php[61-80]
lib/Service/PadFileService.php[167-172]
lib/Service/PadCreationService.php[322-337]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PadTemplateAdminService::add()` accepts and stores templates where the parsed `.pad` frontmatter has an empty `padId` (missing `pad_id`). Later, creating from that template fails because the materialization pipeline rejects templates with no usable `pad_id`.

## Issue Context
- `PadFileService::extractPadMetadata()` defaults missing `pad_id` to `''`.
- `PadCreationService::materializeTemplateInto()` throws if `padId === ''`.
- The admin upload path currently only rejects external pads, not empty `padId`.

## Fix Focus Areas
- lib/Service/PadTemplateAdminService.php[61-81]
- tests/phpunit/unit/PadTemplateAdminServiceTest.php[49-70]
- tests/phpunit/unit/PadTemplateAdminServiceTest.php[221-240]

### Suggested change
After `readPad($content)`, add a validation like:
- if `trim($pad->padId) === ''` => throw `AdminValidationException('template', <localized message>)`

### Tests
Add a unit test case (using `buildService($storage, '')`) asserting `addGlobalTemplate()` is never called and `AdminValidationException` is thrown.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread lib/Service/PadTemplateAdminService.php
Jaggob added 2 commits August 3, 2026 18:34
Review of #181 suspected an upload could store a template whose frontmatter
names no pad, which would then fail for everyone who picks the tile. It
cannot: the parser rejects both a missing and an empty pad_id, so add()
answers with its validation error long before the storage sees it.

Nothing to fix, but nothing said so either — a data provider now feeds both
shapes through the real parser at the service boundary.
…othing

Every upload and delete starts another listing while the first may still be
in flight, and each answer rendered unconditionally. Whichever arrived last
won — putting a deleted template back on the page, or replacing a fresh
result with an older error. Answers now carry a generation and a superseded
one is dropped, with a test that lets the second request answer first.

Two e2e refinements:

- The cleanup deleted the uploaded template unconditionally. When the upload
  itself failed there was no row to click, so the cleanup timed out and
  buried the real error under its own. It waits for the row and only deletes
  what is there.
- The blank-pad helper waited ten seconds for a picker that never opens on
  an instance without templates — twice per run, times retries. It races the
  picker against the created file instead, so whichever happens decides.
@Jaggob
Jaggob merged commit eb734e6 into main Aug 8, 2026
13 checks passed
@Jaggob
Jaggob deleted the feat/global-pad-templates branch August 8, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant