diff --git a/docs/features.json b/docs/features.json index 3fd86a6..a01dbb8 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 7ba4a11..96aa3a0 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/lib/Service/Availability/AvailabilityResultStore.php b/lib/Service/Availability/AvailabilityResultStore.php index 332b533..a0c43de 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]; } } diff --git a/lib/Service/SelectedReleaseInstallerService.php b/lib/Service/SelectedReleaseInstallerService.php index c113757..7330c9f 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. + * + * @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)) { + 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/openapi.json b/openapi.json index 3185647..673ce92 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", diff --git a/tests/stubs/server-internals.php b/tests/stubs/server-internals.php index fbb1bea..e49ed5a 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 { /**