From 6fa81745fe1580344c3370258b30f1c0d902bd3c Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 9 Oct 2026 17:14:35 +0200 Subject: [PATCH 1/4] fix(nc35): verify App Store certificates with phpseclib 3 on Nextcloud 35 Nextcloud 35 bundles phpseclib 3 (namespace phpseclib3) instead of phpseclib 2, so every App Store install crashed with 'Class "phpseclib\File\X509" not found' before downloading anything. The certificate check now uses the X509 class the server ships; both offer the calls it makes, the same calls core's Installer makes. Co-Authored-By: Claude Opus 5.5 --- .../SelectedReleaseInstallerService.php | 24 ++++++++++++++++--- tests/stubs/server-internals.php | 23 ++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/Service/SelectedReleaseInstallerService.php b/lib/Service/SelectedReleaseInstallerService.php index c113757e..1f9dc3d0 100644 --- a/lib/Service/SelectedReleaseInstallerService.php +++ b/lib/Service/SelectedReleaseInstallerService.php @@ -32,7 +32,8 @@ use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Server; -use phpseclib\File\X509; +use phpseclib\File\X509 as X509V2; +use phpseclib3\File\X509 as X509V3; /** * @psalm-api @@ -123,6 +124,23 @@ private function splitCerts(string $cert): array { return $matches[0]; } + /** + * Returns an X509 of the phpseclib the server bundles: phpseclib 3 on + * Nextcloud 35 and later, phpseclib 2 on Nextcloud 32-34. Both offer the + * loadCA, loadX509, loadCRL, validateSignature and getRevoked calls that + * verifyCertificate() makes, the same calls core's Installer makes. + * + * @spec openspec/specs/migration-safety/spec.md + * @return X509V3|X509V2 + */ + private function newX509(): X509V3|X509V2 { + if (class_exists(X509V3::class)) { + return new X509V3(); + } + + return new X509V2(); + } + /** * Verifies app certificate against Nextcloud signing chain and CRL. * @@ -141,7 +159,7 @@ private function verifyCertificate(string $appId, string $certificate): void { throw new Exception('Unable to load Nextcloud certificate revocation list.'); } - $x509 = new X509(); + $x509 = $this->newX509(); $rootCrtList = $this->splitCerts($rootCrt); foreach ($rootCrtList as $rootCertificate) { $x509->loadCA($rootCertificate); @@ -152,7 +170,7 @@ private function verifyCertificate(string $appId, string $certificate): void { throw new Exception('Could not parse app certificate.'); } - $crl = new X509(); + $crl = $this->newX509(); foreach ($rootCrtList as $rootCertificate) { $crl->loadCA($rootCertificate); } diff --git a/tests/stubs/server-internals.php b/tests/stubs/server-internals.php index fbb1beae..e49ed5a3 100644 --- a/tests/stubs/server-internals.php +++ b/tests/stubs/server-internals.php @@ -134,6 +134,29 @@ public function getRevoked(string $serial): mixed { } } +// Nextcloud 35 bundles phpseclib 3 instead of 2; same calls, new namespace. +namespace phpseclib3\File { + class X509 { + public function loadCA(string $cert): mixed { + } + + /** + * @return array|false + */ + public function loadX509(string $cert): array|false { + } + + public function loadCRL(string $crl): mixed { + } + + public function validateSignature(): bool { + } + + public function getRevoked(string $serial): mixed { + } + } +} + namespace Doctrine\DBAL\Schema { class Table { /** From cbc53018003050291696aafe4f853c764240e1b8 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 12:05:13 +0200 Subject: [PATCH 2/4] fix(ci): regenerate openapi.json and features.json for the updates endpoint The openapi extractor refused `array` as a JSON object key type on Api#updates; the snapshot is keyed by app id, so it is now `array` like Api#advisories. openapi.json gains the /api/updates path and docs/features.json the admin-api and pending-updates features, both of which development had not regenerated. Co-Authored-By: Claude Opus 5.5 --- docs/features.json | 14 +++ lib/Controller/ApiController.php | 2 +- openapi.json | 184 +++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) diff --git a/docs/features.json b/docs/features.json index 3fd86a67..a01dbb88 100644 --- a/docs/features.json +++ b/docs/features.json @@ -1,4 +1,11 @@ [ + { + "slug": "admin-api", + "title": "Admin API", + "summary": "Everything an administrator does on the Versioniq page can also be done by a script or another system.", + "status": "stable", + "docsUrl": "openspec/specs/admin-api/spec.md" + }, { "slug": "app-discovery", "title": "App Discovery", @@ -55,6 +62,13 @@ "status": "stable", "docsUrl": "openspec/specs/pat-management/spec.md" }, + { + "slug": "pending-updates", + "title": "Pending Updates", + "summary": "An admin sees on one screen which apps run which version, which have a newer one, and which fell behind the agreed limit.", + "status": "stable", + "docsUrl": "openspec/specs/pending-updates/spec.md" + }, { "slug": "version-management", "title": "Version Management", diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 7ba4a113..96aa3a06 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -163,7 +163,7 @@ public function advisories(): DataResponse { * Like /api/advisories it reads what AvailabilityRefreshJob stored and * never calls a source (issue #160). * - * @return DataResponse, checkedAt: ?int, maxLinesBehind: ?int}, array{}>|DataResponse + * @return DataResponse, checkedAt: ?int, maxLinesBehind: ?int}, array{}>|DataResponse * * 200: Stored pending updates returned * 403: Caller is not an administrator diff --git a/openapi.json b/openapi.json index 3185647b..673ce92f 100644 --- a/openapi.json +++ b/openapi.json @@ -384,6 +384,182 @@ } } }, + "/ocs/v2.php/apps/versioniq/api/updates": { + "get": { + "operationId": "api-updates", + "summary": "Returns the stored pending-updates snapshot (admin-only, read-only): per app the installed version, the newest version, the newest one this server can run, the release lines behind, the pin and the source error. `checkedAt` is the unix time of the last sweep, null when none has run; `maxLinesBehind` is the admin's lag limit, null when the check is off. Like /api/advisories it reads what AvailabilityRefreshJob stored and never calls a source (issue #160).", + "description": "openspec/specs/pending-updates/spec.md#requirement-an-admin-sets-how-far-an-app-may-fall-behind\nThis endpoint requires admin access", + "tags": [ + "api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Stored pending updates returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "updates", + "checkedAt", + "maxLinesBehind" + ], + "properties": { + "updates": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "checkedAt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "maxLinesBehind": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "Caller is not an administrator", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/versioniq/api/update-channel": { "get": { "operationId": "api-update-channel", @@ -6195,6 +6371,7 @@ "data": { "type": "object", "additionalProperties": { + "nullable": true, "oneOf": [ { "type": "integer", @@ -6363,6 +6540,12 @@ "nullable": true, "default": null, "description": "Advisory feed URL; blank uses the published Nextcloud feed" + }, + "maxLinesBehind": { + "type": "string", + "nullable": true, + "default": null, + "description": "Release lines an app may fall behind, 0 to 10; blank turns the check off" } } } @@ -6405,6 +6588,7 @@ "data": { "type": "object", "additionalProperties": { + "nullable": true, "oneOf": [ { "type": "integer", From 77175034b4640116a1301f2935bc7fce7321a3df Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 12:06:03 +0200 Subject: [PATCH 3/4] docs(installer): anchor newX509()'s @spec to the code-signing requirement Co-Authored-By: Claude Opus 5.5 --- lib/Service/SelectedReleaseInstallerService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/SelectedReleaseInstallerService.php b/lib/Service/SelectedReleaseInstallerService.php index 1f9dc3d0..7330c9f0 100644 --- a/lib/Service/SelectedReleaseInstallerService.php +++ b/lib/Service/SelectedReleaseInstallerService.php @@ -130,8 +130,8 @@ private function splitCerts(string $cert): array { * loadCA, loadX509, loadCRL, validateSignature and getRevoked calls that * verifyCertificate() makes, the same calls core's Installer makes. * - * @spec openspec/specs/migration-safety/spec.md * @return X509V3|X509V2 + * @spec openspec/specs/migration-safety/spec.md#requirement-an-app-store-package-is-verified-against-nextcloud-code-signing-before-install */ private function newX509(): X509V3|X509V2 { if (class_exists(X509V3::class)) { From 8b40a549179d43ede02a03fed80e18c59a38d8ea Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 12:52:04 +0200 Subject: [PATCH 4/4] fix(updates): type the stored updates snapshot as keyed by app id AvailabilityResultStore::read() returned array, so Psalm refused Api#updates' array return type. Narrow it once in the store, as AdvisoryResultStore::read() already does. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Availability/AvailabilityResultStore.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/Service/Availability/AvailabilityResultStore.php b/lib/Service/Availability/AvailabilityResultStore.php index 332b5330..a0c43de8 100644 --- a/lib/Service/Availability/AvailabilityResultStore.php +++ b/lib/Service/Availability/AvailabilityResultStore.php @@ -59,7 +59,7 @@ public function save(array $updates, int $checkedAt): void { /** * @spec openspec/specs/pending-updates/spec.md#requirement-the-installed-version-and-pending-updates-are-swept-into-a-snapshot - * @return array{updates: array, checkedAt: ?int} + * @return array{updates: array, checkedAt: ?int} */ public function read(): array { $raw = $this->config->getValueString(Application::APP_ID, self::KEY, ''); @@ -83,6 +83,10 @@ public function read(): array { $checkedAt = $this->config->getValueInt(Application::APP_ID, self::KEY_CHECKED_AT, 0); - return ['updates' => $decoded, 'checkedAt' => $checkedAt > 0 ? $checkedAt : null]; + // json_decode yields array-key keys; the keys we wrote are app ids. + /** @var array $updates */ + $updates = $decoded; + + return ['updates' => $updates, 'checkedAt' => $checkedAt > 0 ? $checkedAt : null]; } }