From 4508fe4624472cd12315a0b1a7dc29dbd1d31f9f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:07:08 -0300 Subject: [PATCH 001/283] chore: convert server to Nextcloud app --- appinfo/info.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 appinfo/info.xml diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..256d063 --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,17 @@ + + + usage_statistics_server + Usage Statistics Server + Receive and aggregate privacy-preserving usage statistics. + + 0.1.0 + agpl + Vitor Mattos + UsageStatisticsServer + tools + + + + From f22c4a4fc9c3aebe6e33001c9bac6ac695c66089 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:09:56 -0300 Subject: [PATCH 002/283] chore: use Nextcloud app dependencies --- composer.json | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/composer.json b/composer.json index 730a4ba..8e36bfc 100644 --- a/composer.json +++ b/composer.json @@ -1,50 +1,36 @@ { "name": "vitormattos/usage-statistics-server", - "description": "Generic server for privacy-preserving usage statistics.", + "description": "Nextcloud app for receiving and aggregating privacy-preserving usage statistics.", "type": "project", "license": "AGPL-3.0-or-later", "require": { - "php": ">=8.4", - "ext-ctype": "*", - "ext-iconv": "*", - "ext-pdo": "*", - "doctrine/dbal": "^4.2", - "doctrine/doctrine-bundle": "^3.0", - "doctrine/doctrine-migrations-bundle": "^4.0", - "symfony/console": "8.1.*", - "symfony/dotenv": "8.1.*", - "symfony/framework-bundle": "8.1.*", - "symfony/runtime": "8.1.*", - "symfony/yaml": "8.1.*" + "php": ">=8.3" }, "require-dev": { + "nextcloud/ocp": "dev-master", "phpunit/phpunit": "^11.5", - "symfony/browser-kit": "8.1.*", - "symfony/css-selector": "8.1.*" + "roave/security-advisories": "dev-latest" }, "autoload": { "psr-4": { - "App\\": "src/", - "DoctrineMigrations\\": "migrations/" + "OCA\\UsageStatisticsServer\\": "lib/" } }, "autoload-dev": { "psr-4": { - "App\\Tests\\": "tests/" + "OCP\\": "vendor/nextcloud/ocp/OCP", + "OCA\\UsageStatisticsServer\\Tests\\": "tests/php/" } }, "scripts": { - "test": "phpunit" + "lint": "find lib tests -name '*.php' -print0 | xargs -0 -n1 php -l", + "test:unit": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky" }, "config": { - "allow-plugins": { - "symfony/runtime": true - }, - "sort-packages": true - }, - "extra": { - "symfony": { - "require": "8.1.*" + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.3" } } } From 9d66100c6eff89f918c5a54add805e55c5096f10 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:10:34 -0300 Subject: [PATCH 003/283] feat: add report ingestion route --- appinfo/routes.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 appinfo/routes.php diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..b9477a5 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,13 @@ + [ + ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], + ], +]; From a1952d5ef8d68b0a64dc746333b4d8f95d8219b3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:10:43 -0300 Subject: [PATCH 004/283] feat: add Nextcloud application bootstrap --- lib/AppInfo/Application.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 lib/AppInfo/Application.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php new file mode 100644 index 0000000..dc04006 --- /dev/null +++ b/lib/AppInfo/Application.php @@ -0,0 +1,28 @@ + Date: Mon, 7 Sep 2026 21:11:13 -0300 Subject: [PATCH 005/283] feat: port report ingestion to Nextcloud --- appinfo/routes.php | 13 --- composer.json | 40 ++++++--- lib/AppInfo/Application.php | 28 ------- lib/Controller/ReportController.php | 48 +++++++++++ lib/Db/ReportRepository.php | 76 +++++++++++++++++ .../Version010000Date20260908001000.php | 49 +++++++++++ lib/Service/InvalidReport.php | 8 ++ lib/Service/Metric.php | 15 ++++ lib/Service/Report.php | 19 +++++ lib/Service/ReportFactory.php | 82 +++++++++++++++++++ 10 files changed, 324 insertions(+), 54 deletions(-) delete mode 100644 appinfo/routes.php delete mode 100644 lib/AppInfo/Application.php create mode 100644 lib/Controller/ReportController.php create mode 100644 lib/Db/ReportRepository.php create mode 100644 lib/Migration/Version010000Date20260908001000.php create mode 100644 lib/Service/InvalidReport.php create mode 100644 lib/Service/Metric.php create mode 100644 lib/Service/Report.php create mode 100644 lib/Service/ReportFactory.php diff --git a/appinfo/routes.php b/appinfo/routes.php deleted file mode 100644 index b9477a5..0000000 --- a/appinfo/routes.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], - ], -]; diff --git a/composer.json b/composer.json index 8e36bfc..730a4ba 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,50 @@ { "name": "vitormattos/usage-statistics-server", - "description": "Nextcloud app for receiving and aggregating privacy-preserving usage statistics.", + "description": "Generic server for privacy-preserving usage statistics.", "type": "project", "license": "AGPL-3.0-or-later", "require": { - "php": ">=8.3" + "php": ">=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "ext-pdo": "*", + "doctrine/dbal": "^4.2", + "doctrine/doctrine-bundle": "^3.0", + "doctrine/doctrine-migrations-bundle": "^4.0", + "symfony/console": "8.1.*", + "symfony/dotenv": "8.1.*", + "symfony/framework-bundle": "8.1.*", + "symfony/runtime": "8.1.*", + "symfony/yaml": "8.1.*" }, "require-dev": { - "nextcloud/ocp": "dev-master", "phpunit/phpunit": "^11.5", - "roave/security-advisories": "dev-latest" + "symfony/browser-kit": "8.1.*", + "symfony/css-selector": "8.1.*" }, "autoload": { "psr-4": { - "OCA\\UsageStatisticsServer\\": "lib/" + "App\\": "src/", + "DoctrineMigrations\\": "migrations/" } }, "autoload-dev": { "psr-4": { - "OCP\\": "vendor/nextcloud/ocp/OCP", - "OCA\\UsageStatisticsServer\\Tests\\": "tests/php/" + "App\\Tests\\": "tests/" } }, "scripts": { - "lint": "find lib tests -name '*.php' -print0 | xargs -0 -n1 php -l", - "test:unit": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky" + "test": "phpunit" }, "config": { - "optimize-autoloader": true, - "sort-packages": true, - "platform": { - "php": "8.3" + "allow-plugins": { + "symfony/runtime": true + }, + "sort-packages": true + }, + "extra": { + "symfony": { + "require": "8.1.*" } } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php deleted file mode 100644 index dc04006..0000000 --- a/lib/AppInfo/Application.php +++ /dev/null @@ -1,28 +0,0 @@ -request->getRawInput(), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($payload)) { + throw new InvalidReport('Request body must be a JSON object.'); + } + $report = $this->factory->fromPayload($payload); + $stored = $this->repository->store($report, $payload); + } catch (InvalidReport|\JsonException $e) { + return new DataResponse(['error' => 'invalid_report', 'message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'id' => $stored['id'], + 'status' => $stored['created'] ? 'created' : 'already_received', + ], $stored['created'] ? Http::STATUS_CREATED : Http::STATUS_OK); + } +} diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php new file mode 100644 index 0000000..81e561f --- /dev/null +++ b/lib/Db/ReportRepository.php @@ -0,0 +1,76 @@ +findId($report); + if ($existing !== null) { + return ['id' => $existing, 'created' => false]; + } + + $this->db->beginTransaction(); + try { + $qb = $this->db->getQueryBuilder(); + $qb->insert('usage_stats_reports')->values([ + 'protocol_version' => $qb->createNamedParameter($report->protocolVersion, IQueryBuilder::PARAM_INT), + 'application' => $qb->createNamedParameter($report->application), + 'installation_id' => $qb->createNamedParameter($report->installationId), + 'schema_version' => $qb->createNamedParameter($report->schemaVersion, IQueryBuilder::PARAM_INT), + 'period_start' => $qb->createNamedParameter($report->periodStart, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + 'period_end' => $qb->createNamedParameter($report->periodEnd, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + 'received_at' => $qb->createNamedParameter(new \DateTimeImmutable('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + 'raw_payload' => $qb->createNamedParameter(json_encode($rawPayload, JSON_THROW_ON_ERROR)), + ])->executeStatement(); + $reportId = $qb->getLastInsertId(); + + foreach ($report->metrics as $metric) { + $metricQb = $this->db->getQueryBuilder(); + $metricQb->insert('usage_stats_metrics')->values([ + 'report_id' => $metricQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), + 'category' => $metricQb->createNamedParameter($metric->category), + 'metric_key' => $metricQb->createNamedParameter($metric->key), + 'metric_type' => $metricQb->createNamedParameter($metric->type), + 'metric_value' => $metricQb->createNamedParameter(json_encode($metric->value, JSON_THROW_ON_ERROR)), + ])->executeStatement(); + } + + $this->db->commit(); + return ['id' => $reportId, 'created' => true]; + } catch (Exception $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + if (in_array($e->getReason(), [Exception::REASON_CONSTRAINT_VIOLATION, Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION], true)) { + $id = $this->findId($report); + if ($id !== null) { + return ['id' => $id, 'created' => false]; + } + } + throw $e; + } + } + + private function findId(Report $report): ?int { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('id')->from('usage_stats_reports') + ->where($qb->expr()->eq('application', $qb->createNamedParameter($report->application))) + ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))) + ->andWhere($qb->expr()->eq('schema_version', $qb->createNamedParameter($report->schemaVersion, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('period_start', $qb->createNamedParameter($report->periodStart, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->andWhere($qb->expr()->eq('period_end', $qb->createNamedParameter($report->periodEnd, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->executeQuery()->fetchOne(); + return $result === false ? null : (int)$result; + } +} diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php new file mode 100644 index 0000000..bd1dc7d --- /dev/null +++ b/lib/Migration/Version010000Date20260908001000.php @@ -0,0 +1,49 @@ +hasTable('usage_stats_reports')) { + $reports = $schema->createTable('usage_stats_reports'); + $reports->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); + $reports->addColumn('protocol_version', Types::INTEGER, ['unsigned' => true]); + $reports->addColumn('application', Types::STRING, ['length' => 128]); + $reports->addColumn('installation_id', Types::STRING, ['length' => 128]); + $reports->addColumn('schema_version', Types::INTEGER, ['unsigned' => true]); + $reports->addColumn('period_start', Types::DATETIME_IMMUTABLE); + $reports->addColumn('period_end', Types::DATETIME_IMMUTABLE); + $reports->addColumn('received_at', Types::DATETIME_IMMUTABLE); + $reports->addColumn('raw_payload', Types::TEXT); + $reports->setPrimaryKey(['id']); + $reports->addUniqueIndex(['application', 'installation_id', 'schema_version', 'period_start', 'period_end'], 'usage_stats_report_identity'); + $reports->addIndex(['application', 'received_at'], 'usage_stats_recent_reports'); + } + + if (!$schema->hasTable('usage_stats_metrics')) { + $metrics = $schema->createTable('usage_stats_metrics'); + $metrics->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); + $metrics->addColumn('report_id', Types::BIGINT, ['unsigned' => true]); + $metrics->addColumn('category', Types::STRING, ['length' => 128]); + $metrics->addColumn('metric_key', Types::STRING, ['length' => 256]); + $metrics->addColumn('metric_type', Types::STRING, ['length' => 16]); + $metrics->addColumn('metric_value', Types::TEXT); + $metrics->setPrimaryKey(['id']); + $metrics->addUniqueIndex(['report_id', 'category', 'metric_key'], 'usage_stats_metric_identity'); + $metrics->addIndex(['category', 'metric_key'], 'usage_stats_metric_lookup'); + } + + return $schema; + } +} diff --git a/lib/Service/InvalidReport.php b/lib/Service/InvalidReport.php new file mode 100644 index 0000000..8cdf823 --- /dev/null +++ b/lib/Service/InvalidReport.php @@ -0,0 +1,8 @@ + $metrics */ + public function __construct( + public int $protocolVersion, + public string $application, + public string $installationId, + public int $schemaVersion, + public \DateTimeImmutable $periodStart, + public \DateTimeImmutable $periodEnd, + public array $metrics, + ) { + } +} diff --git a/lib/Service/ReportFactory.php b/lib/Service/ReportFactory.php new file mode 100644 index 0000000..4fa9073 --- /dev/null +++ b/lib/Service/ReportFactory.php @@ -0,0 +1,82 @@ +identifier($payload['application'] ?? null, 'application', 128); + $installationId = $this->identifier($payload['installationId'] ?? null, 'installationId', 128); + $schemaVersion = $payload['schemaVersion'] ?? null; + if (!is_int($schemaVersion) || $schemaVersion < 1) { + throw new InvalidReport('schemaVersion must be a positive integer.'); + } + + $period = $payload['period'] ?? null; + if (!is_array($period)) { + throw new InvalidReport('period is required.'); + } + + try { + $start = (new \DateTimeImmutable((string)($period['start'] ?? '')))->setTimezone(new \DateTimeZone('UTC')); + $end = (new \DateTimeImmutable((string)($period['end'] ?? '')))->setTimezone(new \DateTimeZone('UTC')); + } catch (\Exception) { + throw new InvalidReport('Invalid report period.'); + } + + if ($end <= $start || ($end->getTimestamp() - $start->getTimestamp()) > 2678400) { + throw new InvalidReport('Invalid report period.'); + } + + $rawMetrics = $payload['metrics'] ?? null; + if (!is_array($rawMetrics) || $rawMetrics === [] || count($rawMetrics) > self::MAX_METRICS) { + throw new InvalidReport('metrics must be a non-empty bounded list.'); + } + + $metrics = []; + $seen = []; + foreach ($rawMetrics as $rawMetric) { + if (!is_array($rawMetric)) { + throw new InvalidReport('Invalid metric.'); + } + $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', 128); + $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', 256); + $type = $rawMetric['type'] ?? null; + $value = $rawMetric['value'] ?? null; + $identity = $category . ':' . $key; + if (isset($seen[$identity])) { + throw new InvalidReport('Duplicate metric.'); + } + $seen[$identity] = true; + + $valid = match ($type) { + 'integer' => is_int($value), + 'number' => is_int($value) || is_float($value), + 'boolean' => is_bool($value), + 'string' => is_string($value) && strlen($value) <= 1024, + default => false, + }; + if (!$valid) { + throw new InvalidReport('Metric type and value do not match.'); + } + $metrics[] = new Metric($category, $key, $type, $value); + } + + return new Report(1, $application, $installationId, $schemaVersion, $start, $end, $metrics); + } + + private function identifier(mixed $value, string $field, int $maxLength): string { + if (!is_string($value) || $value === '' || strlen($value) > $maxLength || preg_match(self::IDENTIFIER_PATTERN, $value) !== 1) { + throw new InvalidReport("Invalid {$field}."); + } + return $value; + } +} From 15bdd588668d97680dad47be34f47c4376ecdc3e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:11:52 -0300 Subject: [PATCH 006/283] chore: remove standalone Symfony runtime --- .env | 3 - appinfo/routes.php | 9 ++ composer.json | 40 ++---- config/bundles.php | 9 -- config/packages/doctrine.yaml | 4 - config/packages/doctrine_migrations.yaml | 4 - config/packages/framework.yaml | 11 -- config/routes.yaml | 5 - config/services.yaml | 9 -- lib/AppInfo/Application.php | 24 ++++ migrations/Version20260908000100.php | 55 -------- phpunit.xml.dist | 12 -- public/index.php | 9 -- src/Controller/ReportController.php | 42 ------ src/Kernel.php | 13 -- src/UsageStatistics/InvalidReport.php | 9 -- src/UsageStatistics/Metric.php | 16 --- src/UsageStatistics/Report.php | 20 --- src/UsageStatistics/ReportFactory.php | 130 ------------------- src/UsageStatistics/ReportRepository.php | 88 ------------- tests/UsageStatistics/ReportFactoryTest.php | 72 ---------- tests/php/Unit/Service/ReportFactoryTest.php | 49 +++++++ tests/php/phpunit.xml | 18 +++ 23 files changed, 113 insertions(+), 538 deletions(-) delete mode 100644 .env create mode 100644 appinfo/routes.php delete mode 100644 config/bundles.php delete mode 100644 config/packages/doctrine.yaml delete mode 100644 config/packages/doctrine_migrations.yaml delete mode 100644 config/packages/framework.yaml delete mode 100644 config/routes.yaml delete mode 100644 config/services.yaml create mode 100644 lib/AppInfo/Application.php delete mode 100644 migrations/Version20260908000100.php delete mode 100644 phpunit.xml.dist delete mode 100644 public/index.php delete mode 100644 src/Controller/ReportController.php delete mode 100644 src/Kernel.php delete mode 100644 src/UsageStatistics/InvalidReport.php delete mode 100644 src/UsageStatistics/Metric.php delete mode 100644 src/UsageStatistics/Report.php delete mode 100644 src/UsageStatistics/ReportFactory.php delete mode 100644 src/UsageStatistics/ReportRepository.php delete mode 100644 tests/UsageStatistics/ReportFactoryTest.php create mode 100644 tests/php/Unit/Service/ReportFactoryTest.php create mode 100644 tests/php/phpunit.xml diff --git a/.env b/.env deleted file mode 100644 index a07ff79..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -APP_ENV=dev -APP_SECRET=change-me -DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..17b4b52 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,9 @@ + [ + ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], + ], +]; diff --git a/composer.json b/composer.json index 730a4ba..8e36bfc 100644 --- a/composer.json +++ b/composer.json @@ -1,50 +1,36 @@ { "name": "vitormattos/usage-statistics-server", - "description": "Generic server for privacy-preserving usage statistics.", + "description": "Nextcloud app for receiving and aggregating privacy-preserving usage statistics.", "type": "project", "license": "AGPL-3.0-or-later", "require": { - "php": ">=8.4", - "ext-ctype": "*", - "ext-iconv": "*", - "ext-pdo": "*", - "doctrine/dbal": "^4.2", - "doctrine/doctrine-bundle": "^3.0", - "doctrine/doctrine-migrations-bundle": "^4.0", - "symfony/console": "8.1.*", - "symfony/dotenv": "8.1.*", - "symfony/framework-bundle": "8.1.*", - "symfony/runtime": "8.1.*", - "symfony/yaml": "8.1.*" + "php": ">=8.3" }, "require-dev": { + "nextcloud/ocp": "dev-master", "phpunit/phpunit": "^11.5", - "symfony/browser-kit": "8.1.*", - "symfony/css-selector": "8.1.*" + "roave/security-advisories": "dev-latest" }, "autoload": { "psr-4": { - "App\\": "src/", - "DoctrineMigrations\\": "migrations/" + "OCA\\UsageStatisticsServer\\": "lib/" } }, "autoload-dev": { "psr-4": { - "App\\Tests\\": "tests/" + "OCP\\": "vendor/nextcloud/ocp/OCP", + "OCA\\UsageStatisticsServer\\Tests\\": "tests/php/" } }, "scripts": { - "test": "phpunit" + "lint": "find lib tests -name '*.php' -print0 | xargs -0 -n1 php -l", + "test:unit": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky" }, "config": { - "allow-plugins": { - "symfony/runtime": true - }, - "sort-packages": true - }, - "extra": { - "symfony": { - "require": "8.1.*" + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.3" } } } diff --git a/config/bundles.php b/config/bundles.php deleted file mode 100644 index 3d084b4..0000000 --- a/config/bundles.php +++ /dev/null @@ -1,9 +0,0 @@ - ['all' => true], - Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], - Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], -]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml deleted file mode 100644 index 365248d..0000000 --- a/config/packages/doctrine.yaml +++ /dev/null @@ -1,4 +0,0 @@ -doctrine: - dbal: - url: '%env(resolve:DATABASE_URL)%' - use_savepoints: true diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml deleted file mode 100644 index 7a17beb..0000000 --- a/config/packages/doctrine_migrations.yaml +++ /dev/null @@ -1,4 +0,0 @@ -doctrine_migrations: - migrations_paths: - 'DoctrineMigrations': '%kernel.project_dir%/migrations' - enable_profiler: false diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml deleted file mode 100644 index 3f12fc9..0000000 --- a/config/packages/framework.yaml +++ /dev/null @@ -1,11 +0,0 @@ -framework: - secret: '%env(APP_SECRET)%' - handle_all_throwables: true - router: - utf8: true - php_errors: - log: true - -test: - framework: - test: true diff --git a/config/routes.yaml b/config/routes.yaml deleted file mode 100644 index 2d0ef99..0000000 --- a/config/routes.yaml +++ /dev/null @@ -1,5 +0,0 @@ -controllers: - resource: - path: ../src/Controller/ - namespace: App\Controller - type: attribute diff --git a/config/services.yaml b/config/services.yaml deleted file mode 100644 index ada9508..0000000 --- a/config/services.yaml +++ /dev/null @@ -1,9 +0,0 @@ -services: - _defaults: - autowire: true - autoconfigure: true - - App\: - resource: '../src/' - exclude: - - '../src/Kernel.php' diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php new file mode 100644 index 0000000..2277964 --- /dev/null +++ b/lib/AppInfo/Application.php @@ -0,0 +1,24 @@ +createTable('reports'); - $reports->addColumn('id', 'integer', ['autoincrement' => true]); - $reports->addColumn('application', 'string', ['length' => 128]); - $reports->addColumn('installation_id', 'string', ['length' => 128]); - $reports->addColumn('protocol_version', 'integer'); - $reports->addColumn('schema_version', 'integer'); - $reports->addColumn('period_start', 'string', ['length' => 40]); - $reports->addColumn('period_end', 'string', ['length' => 40]); - $reports->addColumn('received_at', 'string', ['length' => 40]); - $reports->addColumn('payload', 'text'); - $reports->setPrimaryKey(['id']); - $reports->addUniqueIndex( - ['application', 'installation_id', 'period_start', 'period_end', 'schema_version'], - 'uniq_logical_report' - ); - $reports->addIndex(['application', 'received_at'], 'idx_application_received'); - $reports->addIndex(['application', 'installation_id', 'received_at'], 'idx_installation_received'); - - $metrics = $schema->createTable('metrics'); - $metrics->addColumn('id', 'integer', ['autoincrement' => true]); - $metrics->addColumn('report_id', 'integer'); - $metrics->addColumn('category', 'string', ['length' => 128]); - $metrics->addColumn('metric_key', 'string', ['length' => 256]); - $metrics->addColumn('value_type', 'string', ['length' => 16]); - $metrics->addColumn('value', 'text'); - $metrics->setPrimaryKey(['id']); - $metrics->addUniqueIndex(['report_id', 'category', 'metric_key'], 'uniq_report_metric'); - $metrics->addIndex(['category', 'metric_key'], 'idx_metric_identity'); - $metrics->addForeignKeyConstraint($reports, ['report_id'], ['id'], ['onDelete' => 'CASCADE']); - } - - public function down(Schema $schema): void - { - $schema->dropTable('metrics'); - $schema->dropTable('reports'); - } -} diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 6ad4f45..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - tests - - - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index a33da65..0000000 --- a/public/index.php +++ /dev/null @@ -1,9 +0,0 @@ - new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); diff --git a/src/Controller/ReportController.php b/src/Controller/ReportController.php deleted file mode 100644 index bad57cf..0000000 --- a/src/Controller/ReportController.php +++ /dev/null @@ -1,42 +0,0 @@ -toArray(); - $report = $this->factory->fromPayload($payload); - $stored = $this->repository->store($report, $payload); - } catch (InvalidReport|JsonException $exception) { - return new JsonResponse([ - 'error' => 'invalid_report', - 'message' => $exception->getMessage(), - ], 400); - } - - return new JsonResponse([ - 'id' => $stored['id'], - 'status' => $stored['created'] ? 'created' : 'already_received', - ], $stored['created'] ? 201 : 200); - } -} diff --git a/src/Kernel.php b/src/Kernel.php deleted file mode 100644 index 4cf4eed..0000000 --- a/src/Kernel.php +++ /dev/null @@ -1,13 +0,0 @@ - $metrics */ - public function __construct( - public int $protocolVersion, - public string $application, - public string $installationId, - public int $schemaVersion, - public \DateTimeImmutable $periodStart, - public \DateTimeImmutable $periodEnd, - public array $metrics, - ) { - } -} diff --git a/src/UsageStatistics/ReportFactory.php b/src/UsageStatistics/ReportFactory.php deleted file mode 100644 index 132e5d2..0000000 --- a/src/UsageStatistics/ReportFactory.php +++ /dev/null @@ -1,130 +0,0 @@ - $payload */ - public function fromPayload(array $payload): Report - { - foreach (['protocolVersion', 'application', 'installationId', 'schemaVersion', 'period', 'metrics'] as $field) { - if (!array_key_exists($field, $payload)) { - throw new InvalidReport(sprintf('Missing required field "%s".', $field)); - } - } - - if ($payload['protocolVersion'] !== 1) { - throw new InvalidReport('Unsupported protocolVersion.'); - } - - if (!is_int($payload['schemaVersion']) || $payload['schemaVersion'] < 1) { - throw new InvalidReport('schemaVersion must be a positive integer.'); - } - - $application = $this->identifier($payload['application'], 'application', 128); - $installationId = $this->identifier($payload['installationId'], 'installationId', 128); - - if (!is_array($payload['period'])) { - throw new InvalidReport('period must be an object.'); - } - - $start = $this->date($payload['period']['start'] ?? null, 'period.start'); - $end = $this->date($payload['period']['end'] ?? null, 'period.end'); - if ($end <= $start) { - throw new InvalidReport('period.end must be after period.start.'); - } - if (($end->getTimestamp() - $start->getTimestamp()) > 93 * 86400) { - throw new InvalidReport('Reporting periods longer than 93 days are not accepted.'); - } - - if (!is_array($payload['metrics']) || array_is_list($payload['metrics']) === false) { - throw new InvalidReport('metrics must be a list.'); - } - if ($payload['metrics'] === [] || count($payload['metrics']) > 256) { - throw new InvalidReport('metrics must contain between 1 and 256 items.'); - } - - $metrics = []; - $seen = []; - foreach ($payload['metrics'] as $rawMetric) { - if (!is_array($rawMetric)) { - throw new InvalidReport('Each metric must be an object.'); - } - $metric = $this->metric($rawMetric); - $identity = $metric->category . "\0" . $metric->key; - if (isset($seen[$identity])) { - throw new InvalidReport('Duplicate metric category/key in report.'); - } - $seen[$identity] = true; - $metrics[] = $metric; - } - - return new Report( - 1, - $application, - $installationId, - $payload['schemaVersion'], - $start, - $end, - $metrics, - ); - } - - /** @param array $payload */ - private function metric(array $payload): Metric - { - foreach (['category', 'key', 'type', 'value'] as $field) { - if (!array_key_exists($field, $payload)) { - throw new InvalidReport(sprintf('Metric is missing required field "%s".', $field)); - } - } - - $category = $this->identifier($payload['category'], 'metric.category', 128); - $key = $this->identifier($payload['key'], 'metric.key', 256); - $type = $payload['type']; - if (!is_string($type) || !in_array($type, self::TYPES, true)) { - throw new InvalidReport('Unsupported metric type.'); - } - - $value = $payload['value']; - $valid = match ($type) { - 'integer' => is_int($value), - 'number' => is_int($value) || is_float($value), - 'boolean' => is_bool($value), - 'string' => is_string($value) && strlen($value) <= 1024, - default => false, - }; - if (!$valid) { - throw new InvalidReport(sprintf('Metric value is incompatible with type "%s".', $type)); - } - - return new Metric($category, $key, $type, $value); - } - - private function identifier(mixed $value, string $field, int $maxLength): string - { - if (!is_string($value) || $value === '' || strlen($value) > $maxLength || preg_match(self::IDENTIFIER_PATTERN, $value) !== 1) { - throw new InvalidReport(sprintf('%s has an invalid format.', $field)); - } - - return $value; - } - - private function date(mixed $value, string $field): \DateTimeImmutable - { - if (!is_string($value)) { - throw new InvalidReport(sprintf('%s must be an RFC 3339 timestamp.', $field)); - } - - try { - return (new \DateTimeImmutable($value))->setTimezone(new \DateTimeZone('UTC')); - } catch (\Exception) { - throw new InvalidReport(sprintf('%s must be an RFC 3339 timestamp.', $field)); - } - } -} diff --git a/src/UsageStatistics/ReportRepository.php b/src/UsageStatistics/ReportRepository.php deleted file mode 100644 index 8ef7e62..0000000 --- a/src/UsageStatistics/ReportRepository.php +++ /dev/null @@ -1,88 +0,0 @@ -findExistingId($report); - if ($existingId !== null) { - return ['id' => $existingId, 'created' => false]; - } - - try { - return $this->connection->transactional(function (Connection $connection) use ($report, $rawPayload): array { - $connection->insert('reports', [ - 'application' => $report->application, - 'installation_id' => $report->installationId, - 'protocol_version' => $report->protocolVersion, - 'schema_version' => $report->schemaVersion, - 'period_start' => $this->formatDate($report->periodStart), - 'period_end' => $this->formatDate($report->periodEnd), - 'received_at' => $this->formatDate(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))), - 'payload' => json_encode($rawPayload, JSON_THROW_ON_ERROR), - ]); - $reportId = (int) $connection->lastInsertId(); - - foreach ($report->metrics as $metric) { - $connection->insert('metrics', [ - 'report_id' => $reportId, - 'category' => $metric->category, - 'metric_key' => $metric->key, - 'value_type' => $metric->type, - 'value' => $this->serializeValue($metric), - ]); - } - - return ['id' => $reportId, 'created' => true]; - }); - } catch (UniqueConstraintViolationException) { - $existingId = $this->findExistingId($report); - if ($existingId === null) { - throw new \RuntimeException('Logical report conflict could not be resolved.'); - } - - return ['id' => $existingId, 'created' => false]; - } - } - - private function findExistingId(Report $report): ?int - { - $value = $this->connection->fetchOne( - 'SELECT id FROM reports WHERE application = ? AND installation_id = ? AND period_start = ? AND period_end = ? AND schema_version = ?', - [ - $report->application, - $report->installationId, - $this->formatDate($report->periodStart), - $this->formatDate($report->periodEnd), - $report->schemaVersion, - ], - ); - - return $value === false ? null : (int) $value; - } - - private function formatDate(\DateTimeImmutable $date): string - { - return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\\TH:i:s\\Z'); - } - - private function serializeValue(Metric $metric): string - { - return match ($metric->type) { - 'boolean' => $metric->value ? 'true' : 'false', - default => (string) $metric->value, - }; - } -} diff --git a/tests/UsageStatistics/ReportFactoryTest.php b/tests/UsageStatistics/ReportFactoryTest.php deleted file mode 100644 index d8ec3c7..0000000 --- a/tests/UsageStatistics/ReportFactoryTest.php +++ /dev/null @@ -1,72 +0,0 @@ -fromPayload($payload); - - self::assertSame('libresign', $report->application); - self::assertSame('installation-1', $report->installationId); - self::assertSame('2026-08-01T00:00:00+00:00', $report->periodStart->format(DATE_ATOM)); - self::assertCount(2, $report->metrics); - } - - #[DataProvider('invalidPayloads')] - public function testRejectsInvalidReports(array $payload): void - { - $this->expectException(InvalidReport::class); - (new ReportFactory())->fromPayload($payload); - } - - public static function invalidPayloads(): iterable - { - $base = self::payload(); - - $unsupportedProtocol = $base; - $unsupportedProtocol['protocolVersion'] = 2; - yield 'unsupported protocol' => [$unsupportedProtocol]; - - $invalidPeriod = $base; - $invalidPeriod['period']['end'] = $invalidPeriod['period']['start']; - yield 'empty period' => [$invalidPeriod]; - - $wrongType = $base; - $wrongType['metrics'][1]['value'] = '72'; - yield 'wrong metric type' => [$wrongType]; - - $duplicateMetric = $base; - $duplicateMetric['metrics'][] = $duplicateMetric['metrics'][0]; - yield 'duplicate metric' => [$duplicateMetric]; - } - - private static function payload(): array - { - return [ - 'protocolVersion' => 1, - 'application' => 'libresign', - 'installationId' => 'installation-1', - 'schemaVersion' => 1, - 'period' => [ - 'start' => '2026-08-01T00:00:00+00:00', - 'end' => '2026-09-01T00:00:00+00:00', - ], - 'metrics' => [ - ['category' => 'environment', 'key' => 'version', 'type' => 'string', 'value' => '12.0.0'], - ['category' => 'usage', 'key' => 'requests_completed', 'type' => 'integer', 'value' => 72], - ], - ]; - } -} diff --git a/tests/php/Unit/Service/ReportFactoryTest.php b/tests/php/Unit/Service/ReportFactoryTest.php new file mode 100644 index 0000000..947f0db --- /dev/null +++ b/tests/php/Unit/Service/ReportFactoryTest.php @@ -0,0 +1,49 @@ +fromPayload([ + 'protocolVersion' => 1, + 'application' => 'libresign', + 'installationId' => str_repeat('a', 64), + 'schemaVersion' => 1, + 'period' => [ + 'start' => '2026-08-01T00:00:00-03:00', + 'end' => '2026-09-01T00:00:00-03:00', + ], + 'metrics' => [[ + 'category' => 'usage', + 'key' => 'requests_completed', + 'type' => 'integer', + 'value' => 72, + ]], + ]); + + self::assertSame('libresign', $report->application); + self::assertSame('UTC', $report->periodStart->getTimezone()->getName()); + self::assertSame(72, $report->metrics[0]->value); + } + + public function testRejectsDuplicateMetric(): void { + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload([ + 'protocolVersion' => 1, + 'application' => 'libresign', + 'installationId' => str_repeat('a', 64), + 'schemaVersion' => 1, + 'period' => ['start' => '2026-08-01T00:00:00Z', 'end' => '2026-09-01T00:00:00Z'], + 'metrics' => [ + ['category' => 'usage', 'key' => 'requests', 'type' => 'integer', 'value' => 1], + ['category' => 'usage', 'key' => 'requests', 'type' => 'integer', 'value' => 2], + ], + ]); + } +} diff --git a/tests/php/phpunit.xml b/tests/php/phpunit.xml new file mode 100644 index 0000000..99ffd8f --- /dev/null +++ b/tests/php/phpunit.xml @@ -0,0 +1,18 @@ + + + + + Unit + + + + + ../../lib + + + From 097960e1ea99303112eac2b97d91ab3465585c2e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:12:08 -0300 Subject: [PATCH 007/283] ci: test Nextcloud 35 and 36 --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f644be..4f72ec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,14 +10,61 @@ permissions: contents: read jobs: - test: + php: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - php-version: '8.4' + persist-credentials: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' coverage: none - - uses: ramsey/composer-install@v3 + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - run: composer validate --strict - - run: vendor/bin/phpunit + - run: composer lint + + nextcloud: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + server-version: + - stable35 + - master + steps: + - name: Checkout Nextcloud + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: nextcloud/server + ref: ${{ matrix.server-version }} + submodules: true + + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/usage_statistics_server + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' + coverage: none + extensions: ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sqlite3, pdo_sqlite, xmlreader, xmlwriter, zip, zlib + + - name: Install app dependencies + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + working-directory: apps/usage_statistics_server + + - name: Install Nextcloud + run: | + mkdir data + ./occ maintenance:install --database=sqlite --admin-user=admin --admin-pass=admin + ./occ app:enable --force usage_statistics_server + + - name: Run unit tests + working-directory: apps/usage_statistics_server + run: composer test:unit From 2cc07c7ee03ebe9c7b8a02ba618345a2dcf65056 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:16:49 -0300 Subject: [PATCH 008/283] test: add Nextcloud PHPUnit bootstrap --- tests/php/bootstrap.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/php/bootstrap.php diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..d56340a --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,21 @@ +loadApp('usage_statistics_server'); From a6e416618b1d90c665c6ecc10e40ba1e9bdca969 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:16:59 -0300 Subject: [PATCH 009/283] chore: align Composer scripts with Nextcloud workflows --- composer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 8e36bfc..fa30a88 100644 --- a/composer.json +++ b/composer.json @@ -23,8 +23,9 @@ } }, "scripts": { - "lint": "find lib tests -name '*.php' -print0 | xargs -0 -n1 php -l", - "test:unit": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky" + "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", + "test:unit": "phpunit -c tests/php/phpunit.xml --testsuite unit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", + "test:php": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations" }, "config": { "optimize-autoloader": true, From 152d939212dea3ca82377bae64b0e2217db8b866 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:17:14 -0300 Subject: [PATCH 010/283] test: use app PHPUnit bootstrap --- tests/php/phpunit.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/phpunit.xml b/tests/php/phpunit.xml index 99ffd8f..69692c3 100644 --- a/tests/php/phpunit.xml +++ b/tests/php/phpunit.xml @@ -1,7 +1,7 @@ From 0c4f1e5b937ebb23390c8e15ebc16e83db5c167a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:17:19 -0300 Subject: [PATCH 011/283] ci: replace custom workflow with Nextcloud-style checks --- .github/workflows/ci.yml | 70 ---------------------------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4f72ec5..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -permissions: - contents: read - -jobs: - php: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: '8.3' - coverage: none - - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - - run: composer validate --strict - - run: composer lint - - nextcloud: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - server-version: - - stable35 - - master - steps: - - name: Checkout Nextcloud - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: nextcloud/server - ref: ${{ matrix.server-version }} - submodules: true - - - name: Checkout app - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - path: apps/usage_statistics_server - - - name: Set up PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: '8.3' - coverage: none - extensions: ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sqlite3, pdo_sqlite, xmlreader, xmlwriter, zip, zlib - - - name: Install app dependencies - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - with: - working-directory: apps/usage_statistics_server - - - name: Install Nextcloud - run: | - mkdir data - ./occ maintenance:install --database=sqlite --admin-user=admin --admin-pass=admin - ./occ app:enable --force usage_statistics_server - - - name: Run unit tests - working-directory: apps/usage_statistics_server - run: composer test:unit From bdffa97e79de22174d78daaf1a3ab960e498934e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:17:29 -0300 Subject: [PATCH 012/283] ci: add Nextcloud-style PHPUnit workflow --- .github/workflows/phpunit-sqlite.yml | 135 +++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 .github/workflows/phpunit-sqlite.yml diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml new file mode 100644 index 0000000..a5084ea --- /dev/null +++ b/.github/workflows/phpunit-sqlite.yml @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: PHPUnit SQLite + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: phpunit-sqlite-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.versions.outputs.php-available-list }} + server-max: ${{ steps.versions.outputs.branches-max-list }} + + steps: + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2 + + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + + outputs: + src: ${{ steps.changes.outputs.src }} + + steps: + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/phpunit*' + - 'appinfo/**' + - 'lib/**' + - 'tests/**' + - 'composer.json' + - 'composer.lock' + + phpunit-sqlite: + runs-on: ubuntu-latest + needs: [changes, matrix] + if: needs.changes.outputs.src != 'false' + + strategy: + fail-fast: false + matrix: + php-versions: ${{ fromJson(needs.matrix.outputs.php-version) }} + server-versions: ${{ fromJson(needs.matrix.outputs.server-max) }} + + env: + APP_NAME: usage_statistics_server + + name: SQLite PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }} + + steps: + - name: Checkout server + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install app dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install \ + --verbose \ + --database=sqlite \ + --database-name=nextcloud \ + --admin-user admin \ + --admin-pass admin + ./occ --version + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: Run PHPUnit suites + working-directory: apps/${{ env.APP_NAME }} + run: composer run test:php + + - name: Print logs + if: always() + run: cat data/nextcloud.log || true + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: [changes, phpunit-sqlite] + if: always() + + name: phpunit-sqlite-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-sqlite.result != 'success' }}; then exit 1; fi From ec11d48e70e44368b8a005103e2b7c82716bd363 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:17:36 -0300 Subject: [PATCH 013/283] ci: add info.xml validation --- .github/workflows/lint-info-xml.yml | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/lint-info-xml.yml diff --git a/.github/workflows/lint-info-xml.yml b/.github/workflows/lint-info-xml.yml new file mode 100644 index 0000000..44a320a --- /dev/null +++ b/.github/workflows/lint-info-xml.yml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Lint info.xml + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: lint-info-xml-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + xml-linters: + runs-on: ubuntu-latest + name: info.xml lint + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download schema + run: wget https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd + + - name: Lint info.xml + uses: ChristophWurst/xmllint-action@36f2a302f84f8c83fceea0b9c59e1eb4a616d3c1 # v1.2 + with: + xml-file: ./appinfo/info.xml + xml-schema-file: ./info.xsd From d887a106ee9792e81768815008a356da77443989 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:17:45 -0300 Subject: [PATCH 014/283] ci: add PHP lint workflow --- .github/workflows/lint-php.yml | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/lint-php.yml diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml new file mode 100644 index 0000000..15b415a --- /dev/null +++ b/.github/workflows/lint-php.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Lint php + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: lint-php-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + php-min: ${{ steps.versions.outputs.php-min }} + php-max: ${{ steps.versions.outputs.php-max }} + + steps: + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2 + + php-lint: + runs-on: ubuntu-latest + needs: matrix + name: php-lint + + strategy: + matrix: + php-versions: ['${{ needs.matrix.outputs.php-min }}', '${{ needs.matrix.outputs.php-max }}'] + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up php ${{ matrix.php-versions }} + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 + with: + php-version: ${{ matrix.php-versions }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install + + - name: Lint + run: composer run lint + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: php-lint + if: always() + + name: php-lint-summary + + steps: + - name: Summary status + run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi From 54b977a741566a3b6f490108d4786d3c4b51662e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:18:45 -0300 Subject: [PATCH 015/283] ci: test supported Nextcloud version matrix --- .github/workflows/phpunit-sqlite.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index a5084ea..dc7693f 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -17,8 +17,7 @@ jobs: matrix: runs-on: ubuntu-latest outputs: - php-version: ${{ steps.versions.outputs.php-available-list }} - server-max: ${{ steps.versions.outputs.branches-max-list }} + matrix: ${{ steps.versions.outputs.sparse-matrix }} steps: - name: Checkout app @@ -60,9 +59,7 @@ jobs: strategy: fail-fast: false - matrix: - php-versions: ${{ fromJson(needs.matrix.outputs.php-version) }} - server-versions: ${{ fromJson(needs.matrix.outputs.server-max) }} + matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} env: APP_NAME: usage_statistics_server From 19c4199c8f327545f2792c6d2856b5c08d3d6cd3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:19:34 -0300 Subject: [PATCH 016/283] fix: align app metadata with Nextcloud schema --- appinfo/info.xml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 256d063..d7d5831 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,5 +1,12 @@ - + + + + usage_statistics_server Usage Statistics Server Receive and aggregate privacy-preserving usage statistics. @@ -11,6 +18,8 @@ A generic Nextcloud app for receiving, storing, aggregating, and exposing privac Vitor Mattos UsageStatisticsServer tools + https://github.com/vitormattos/usage-statistics-server + https://github.com/vitormattos/usage-statistics-server/issues From 8b6ba7b305d8ad63138f57c69c298510619bd366 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:23:43 -0300 Subject: [PATCH 017/283] fix: align app metadata with schema --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index d7d5831..c824d7a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -18,8 +18,8 @@ A generic Nextcloud app for receiving, storing, aggregating, and exposing privac Vitor Mattos UsageStatisticsServer tools - https://github.com/vitormattos/usage-statistics-server https://github.com/vitormattos/usage-statistics-server/issues + https://github.com/vitormattos/usage-statistics-server From d10dd712cfb4a4e90a4280e2c5d0c411bcdabbb0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:24:15 -0300 Subject: [PATCH 018/283] feat: track installation state --- lib/Migration/Version010000Date20260908001000.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index bd1dc7d..6f07c78 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -15,6 +15,19 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); + if (!$schema->hasTable('usage_stats_installations')) { + $installations = $schema->createTable('usage_stats_installations'); + $installations->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); + $installations->addColumn('application', Types::STRING, ['length' => 128]); + $installations->addColumn('installation_id', Types::STRING, ['length' => 128]); + $installations->addColumn('first_seen_at', Types::DATETIME_IMMUTABLE); + $installations->addColumn('last_seen_at', Types::DATETIME_IMMUTABLE); + $installations->addColumn('last_report_id', Types::BIGINT, ['unsigned' => true]); + $installations->setPrimaryKey(['id']); + $installations->addUniqueIndex(['application', 'installation_id'], 'usage_stats_installation_identity'); + $installations->addIndex(['application', 'last_seen_at'], 'usage_stats_active_installations'); + } + if (!$schema->hasTable('usage_stats_reports')) { $reports = $schema->createTable('usage_stats_reports'); $reports->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); From 1c5c32cbf55b860ad47cd6317110a2e0ae66594e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:24:26 -0300 Subject: [PATCH 019/283] feat: maintain latest installation state --- lib/Db/ReportRepository.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index 81e561f..8edcbf7 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -20,6 +20,8 @@ public function store(Report $report, array $rawPayload): array { return ['id' => $existing, 'created' => false]; } + $receivedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $this->db->beginTransaction(); try { $qb = $this->db->getQueryBuilder(); @@ -30,7 +32,7 @@ public function store(Report $report, array $rawPayload): array { 'schema_version' => $qb->createNamedParameter($report->schemaVersion, IQueryBuilder::PARAM_INT), 'period_start' => $qb->createNamedParameter($report->periodStart, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), 'period_end' => $qb->createNamedParameter($report->periodEnd, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), - 'received_at' => $qb->createNamedParameter(new \DateTimeImmutable('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + 'received_at' => $qb->createNamedParameter($receivedAt, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), 'raw_payload' => $qb->createNamedParameter(json_encode($rawPayload, JSON_THROW_ON_ERROR)), ])->executeStatement(); $reportId = $qb->getLastInsertId(); @@ -46,6 +48,22 @@ public function store(Report $report, array $rawPayload): array { ])->executeStatement(); } + $this->db->setValues( + 'usage_stats_installations', + [ + 'application' => $report->application, + 'installation_id' => $report->installationId, + ], + [ + 'first_seen_at' => $receivedAt, + 'last_seen_at' => $receivedAt, + 'last_report_id' => $reportId, + ], + [ + 'first_seen_at' => $receivedAt, + ], + ); + $this->db->commit(); return ['id' => $reportId, 'created' => true]; } catch (Exception $e) { From d55b267f9e782f7e59100d875d7d7fed6ef9d30f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:24:43 -0300 Subject: [PATCH 020/283] refactor: keep only current installation state --- lib/Migration/Version010000Date20260908001000.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index 6f07c78..ec75eee 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -20,7 +20,6 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $installations->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); $installations->addColumn('application', Types::STRING, ['length' => 128]); $installations->addColumn('installation_id', Types::STRING, ['length' => 128]); - $installations->addColumn('first_seen_at', Types::DATETIME_IMMUTABLE); $installations->addColumn('last_seen_at', Types::DATETIME_IMMUTABLE); $installations->addColumn('last_report_id', Types::BIGINT, ['unsigned' => true]); $installations->setPrimaryKey(['id']); From ff50a351c1f41b91336ee0510764222b2e3460bc Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:24:55 -0300 Subject: [PATCH 021/283] fix: preserve installation state semantics --- lib/Db/ReportRepository.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index 8edcbf7..ef29418 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -55,13 +55,9 @@ public function store(Report $report, array $rawPayload): array { 'installation_id' => $report->installationId, ], [ - 'first_seen_at' => $receivedAt, 'last_seen_at' => $receivedAt, 'last_report_id' => $reportId, ], - [ - 'first_seen_at' => $receivedAt, - ], ); $this->db->commit(); From 6b587d6d92c74fb2a87f232c5826e9f2e8bb0c4b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:25:12 -0300 Subject: [PATCH 022/283] feat: add statistics queries --- lib/Db/StatisticsRepository.php | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/Db/StatisticsRepository.php diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php new file mode 100644 index 0000000..80fed0e --- /dev/null +++ b/lib/Db/StatisticsRepository.php @@ -0,0 +1,84 @@ +db->getQueryBuilder(); + $result = $qb->select('COUNT(*) AS active_count') + ->from('usage_stats_installations') + ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->gte('last_seen_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->executeQuery() + ->fetchOne(); + + return (int)$result; + } + + /** @return list */ + public function currentDistribution(string $application, string $category, string $key): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('m.metric_value', 'COUNT(*) AS value_count') + ->from('usage_stats_installations', 'i') + ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) + ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) + ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) + ->groupBy('m.metric_value') + ->orderBy('value_count', 'DESC') + ->executeQuery(); + + $distribution = []; + while ($row = $result->fetch()) { + $distribution[] = [ + 'value' => json_decode((string)$row['metric_value'], true, 512, JSON_THROW_ON_ERROR), + 'count' => (int)$row['value_count'], + ]; + } + $result->closeCursor(); + + return $distribution; + } + + /** @return list */ + public function metricHistory( + string $application, + string $category, + string $key, + \DateTimeImmutable $from, + \DateTimeImmutable $to, + ): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('r.period_start', 'r.period_end', 'm.metric_value', 'm.metric_type') + ->from('usage_stats_reports', 'r') + ->innerJoin('r', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'r.id')) + ->where($qb->expr()->eq('r.application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) + ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) + ->andWhere($qb->expr()->gte('r.period_end', $qb->createNamedParameter($from, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->andWhere($qb->expr()->lte('r.period_end', $qb->createNamedParameter($to, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->orderBy('r.period_end', 'ASC') + ->executeQuery(); + + $history = []; + while ($row = $result->fetch()) { + $history[] = [ + 'periodStart' => (string)$row['period_start'], + 'periodEnd' => (string)$row['period_end'], + 'value' => json_decode((string)$row['metric_value'], true, 512, JSON_THROW_ON_ERROR), + 'type' => (string)$row['metric_type'], + ]; + } + $result->closeCursor(); + + return $history; + } +} From 154e031e873c92d7af87b57da4f5847dab99bc4d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:25:25 -0300 Subject: [PATCH 023/283] test: add database integration suite --- tests/php/phpunit.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/php/phpunit.xml b/tests/php/phpunit.xml index 69692c3..9ccf245 100644 --- a/tests/php/phpunit.xml +++ b/tests/php/phpunit.xml @@ -9,6 +9,9 @@ Unit + + Integration + From 73fa7a6fa69963e8af484359b4b5bec4167a3794 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:25:35 -0300 Subject: [PATCH 024/283] test: cover current and historical statistics --- .../Db/StatisticsRepositoryTest.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/php/Integration/Db/StatisticsRepositoryTest.php diff --git a/tests/php/Integration/Db/StatisticsRepositoryTest.php b/tests/php/Integration/Db/StatisticsRepositoryTest.php new file mode 100644 index 0000000..97968b8 --- /dev/null +++ b/tests/php/Integration/Db/StatisticsRepositoryTest.php @@ -0,0 +1,89 @@ +db = Server::get(IDBConnection::class); + $this->reports = new ReportRepository($this->db); + $this->statistics = new StatisticsRepository($this->db); + $this->factory = new ReportFactory(); + + $this->truncateTables(); + } + + protected function tearDown(): void { + $this->truncateTables(); + parent::tearDown(); + } + + public function testCurrentDistributionUsesLatestReportWhileHistoryKeepsAllReports(): void { + $this->store('installation-a', '2026-06-01T00:00:00Z', '2026-07-01T00:00:00Z', '1.0.0'); + $this->store('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0'); + $this->store('installation-b', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0'); + + self::assertSame(2, $this->statistics->countActiveInstallations( + 'libresign', + new \DateTimeImmutable('-1 day', new \DateTimeZone('UTC')), + )); + + self::assertSame([ + ['value' => '1.0.0', 'count' => 1], + ['value' => '1.1.0', 'count' => 1], + ], $this->statistics->currentDistribution('libresign', 'server', 'version')); + + $history = $this->statistics->metricHistory( + 'libresign', + 'server', + 'version', + new \DateTimeImmutable('2026-06-01T00:00:00Z'), + new \DateTimeImmutable('2026-08-02T00:00:00Z'), + ); + + self::assertCount(3, $history); + self::assertSame(['1.0.0', '1.1.0', '1.0.0'], array_column($history, 'value')); + } + + private function store(string $installationId, string $start, string $end, string $version): void { + $payload = [ + 'protocolVersion' => 1, + 'application' => 'libresign', + 'installationId' => $installationId, + 'schemaVersion' => 1, + 'period' => [ + 'start' => $start, + 'end' => $end, + ], + 'metrics' => [[ + 'category' => 'server', + 'key' => 'version', + 'type' => 'string', + 'value' => $version, + ]], + ]; + + $this->reports->store($this->factory->fromPayload($payload), $payload); + } + + private function truncateTables(): void { + $this->db->getQueryBuilder()->delete('usage_stats_metrics')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_installations')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_reports')->executeStatement(); + } +} From 15d7fa51a01b9bab0a3caf7e395459e0abf97625 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:33:48 -0300 Subject: [PATCH 025/283] test: allow database access in statistics integration test --- tests/php/Integration/Db/StatisticsRepositoryTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/php/Integration/Db/StatisticsRepositoryTest.php b/tests/php/Integration/Db/StatisticsRepositoryTest.php index 97968b8..7cb23fb 100644 --- a/tests/php/Integration/Db/StatisticsRepositoryTest.php +++ b/tests/php/Integration/Db/StatisticsRepositoryTest.php @@ -9,8 +9,10 @@ use OCA\UsageStatisticsServer\Service\ReportFactory; use OCP\IDBConnection; use OCP\Server; +use PHPUnit\Framework\Attributes\Group; use Test\TestCase; +#[Group('DB')] final class StatisticsRepositoryTest extends TestCase { private IDBConnection $db; private ReportRepository $reports; From 917ff458f55d487001e651ef6577056e4723553e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:34:08 -0300 Subject: [PATCH 026/283] feat: add administrative statistics routes --- appinfo/routes.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index 17b4b52..a86b19b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -5,5 +5,8 @@ return [ 'ocs' => [ ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], + ['name' => 'statistics#summary', 'url' => '/api/v1/admin/{application}', 'verb' => 'GET'], + ['name' => 'statistics#distribution', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/distribution', 'verb' => 'GET'], + ['name' => 'statistics#history', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/history', 'verb' => 'GET'], ], ]; From 5b67ae8ae805b83b17e81ee8c0c0f02b069bcb5d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:34:17 -0300 Subject: [PATCH 027/283] feat: expose administrative statistics API --- lib/Controller/StatisticsController.php | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 lib/Controller/StatisticsController.php diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php new file mode 100644 index 0000000..baad0b5 --- /dev/null +++ b/lib/Controller/StatisticsController.php @@ -0,0 +1,98 @@ + $application, + 'activeWindowDays' => self::ACTIVE_WINDOW_DAYS, + 'activeInstallations' => $this->statistics->countActiveInstallations($application, $since), + ]); + } + + public function distribution(string $application, string $category, string $key): DataResponse { + return new DataResponse([ + 'application' => $application, + 'category' => $category, + 'key' => $key, + 'values' => $this->statistics->currentDistribution($application, $category, $key), + ]); + } + + public function history( + string $application, + string $category, + string $key, + string $from = '', + string $to = '', + ): DataResponse { + try { + $range = $this->parseRange($from, $to); + } catch (\InvalidArgumentException $e) { + return new DataResponse([ + 'error' => 'invalid_range', + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'application' => $application, + 'category' => $category, + 'key' => $key, + 'from' => $range['from']->format(DATE_ATOM), + 'to' => $range['to']->format(DATE_ATOM), + 'values' => $this->statistics->metricHistory( + $application, + $category, + $key, + $range['from'], + $range['to'], + ), + ]); + } + + /** @return array{from:\DateTimeImmutable,to:\DateTimeImmutable} */ + private function parseRange(string $from, string $to): array { + $utc = new \DateTimeZone('UTC'); + $rangeTo = $to === '' ? new \DateTimeImmutable('now', $utc) : $this->parseDate($to); + $rangeFrom = $from === '' ? $rangeTo->modify('-1 year') : $this->parseDate($from); + + if ($rangeFrom > $rangeTo) { + throw new \InvalidArgumentException('The from value must be before or equal to the to value.'); + } + + return ['from' => $rangeFrom, 'to' => $rangeTo]; + } + + private function parseDate(string $value): \DateTimeImmutable { + try { + return (new \DateTimeImmutable($value))->setTimezone(new \DateTimeZone('UTC')); + } catch (\Exception) { + throw new \InvalidArgumentException('Dates must use a valid ISO 8601 value.'); + } + } +} From 9b603629e208aa13df0a5880168caf61f1667c41 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:34:35 -0300 Subject: [PATCH 028/283] docs: document administrative statistics API --- docs/admin-api.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/admin-api.md diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..b13c935 --- /dev/null +++ b/docs/admin-api.md @@ -0,0 +1,84 @@ +# Administrative statistics API + +The administrative API exposes aggregated and historical usage statistics to authenticated Nextcloud administrators. These endpoints are not public by default. + +All endpoints use the OCS namespace of the `usage_statistics_server` app. + +## Summary + +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}` + +Returns the number of installations that submitted a valid report during the active window. + +The active window is currently fixed at 45 days. + +Example response data: + +```json +{ + "application": "libresign", + "activeWindowDays": 45, + "activeInstallations": 142 +} +``` + +`activeInstallations` means installations reporting usage statistics within the last 45 days. It must not be presented as the total number of installations of the application. + +## Current distribution + +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/distribution` + +Returns the distribution of a metric using only the latest report for every known installation. + +Example: + +```json +{ + "application": "libresign", + "category": "server", + "key": "version", + "values": [ + {"value": "12.0.0", "count": 80}, + {"value": "11.0.2", "count": 45} + ] +} +``` + +This endpoint represents current state. Older reports from the same installation do not contribute to the distribution. + +## Metric history + +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/history` + +Optional query parameters: + +- `from`: ISO 8601 timestamp; +- `to`: ISO 8601 timestamp. + +When omitted, `to` defaults to the current time and `from` defaults to one year before `to`. + +Example response data: + +```json +{ + "application": "libresign", + "category": "signing", + "key": "requests_completed", + "from": "2026-01-01T00:00:00+00:00", + "to": "2026-09-01T00:00:00+00:00", + "values": [ + { + "periodStart": "2026-07-01 00:00:00", + "periodEnd": "2026-08-01 00:00:00", + "value": 120, + "type": "integer" + } + ] +} +``` + +This endpoint returns historical report values. It deliberately does not collapse reports into the latest installation state. + +## Interpretation + +Reports are voluntary self-reported data. These APIs provide usage indicators and operational statistics, not an authenticated census. Consumers must preserve this distinction when displaying or publishing derived numbers. From 9dcd368c5c6a52bf6a2bd8788bbcbc5b4035214f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:34:47 -0300 Subject: [PATCH 029/283] docs: align README with Nextcloud app architecture --- README.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 55fb13c..1537a51 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,49 @@ # Usage Statistics Server -A generic server for receiving, storing, aggregating, and exposing privacy-preserving usage statistics from participating applications. +A generic Nextcloud app for receiving, storing, aggregating, and exposing privacy-preserving usage statistics from participating applications. -This project is intentionally application-agnostic. LibreSign is expected to be its first real consumer, but the protocol and storage model must not depend on LibreSign-specific concepts. +LibreSign is expected to be its first real consumer, but the protocol and storage model are application-agnostic so other Nextcloud apps can use the same server. ## Goals - Receive opt-in, self-reported usage statistics from applications. - Preserve historical reports instead of only the latest snapshot. - Support both current-state and time-series aggregations. -- Keep the ingestion protocol small, versioned, and implementation-independent. +- Keep the ingestion protocol small and versioned. - Provide strict schema and payload validation. - Make repeated submissions for the same reporting period idempotent. - Treat submitted data as voluntary self-reported statistics, not as an authoritative census. - Provide operational abuse protection without pretending to solve client-side data falsification. +- Use native Nextcloud APIs for persistence, migrations, routing, administration, and background processing. ## Non-goals - Collect personal data or user-level event streams. - Prove that a self-hosted client reports truthful values. - Require a central registration or handshake before a client can submit a report. -- Couple the protocol to Nextcloud, LibreSign, or any specific frontend framework. +- Depend on LibreSign-specific concepts in the protocol or database model. -## Planned architecture +## Architecture -The initial design separates reports from their individual metric values: +The server is a native Nextcloud app supporting Nextcloud 35 and 36. -- `applications`: logical producers of statistics; -- `reports`: immutable reporting-period submissions associated with an installation; -- `metrics`: typed values belonging to a report; -- aggregation/query services for latest-state and historical views. +The storage model separates current installation state from immutable report history: -The exact persistence and application framework will be selected after the v1 protocol and threat model are reviewed. +- `usage_stats_installations`: materialized current state for each application/installation pair; +- `usage_stats_reports`: immutable reporting-period submissions; +- `usage_stats_metrics`: typed metric values belonging to reports. + +This allows current-state queries to use only the latest report for each installation while historical queries continue to use all reports. ## Protocol -The protocol specification lives in [`docs/protocol-v1.md`](docs/protocol-v1.md). +The ingestion protocol specification lives in [`docs/protocol-v1.md`](docs/protocol-v1.md). + +Administrative query endpoints are documented in [`docs/admin-api.md`](docs/admin-api.md). ## Data reliability -Reports are self-declared by participating installations. Server-side validation can verify protocol conformance, reject malformed values, make retries idempotent, limit abuse, and identify statistical anomalies. It cannot prove that software running on infrastructure controlled by the sender reported truthful application data. +Reports are self-declared by participating installations. Server-side validation can verify protocol conformance, reject malformed values, make retries idempotent, limit operational abuse, and identify statistical anomalies. It cannot prove that software running on infrastructure controlled by the sender reported truthful application data. Public or product-facing statistics must therefore be described as statistics reported by participating installations, not as an audited count of all installations. From bf1bc801fb87514e0f7c35b92d78c504b9d21fde Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:56:15 -0300 Subject: [PATCH 030/283] feat: store normalized numeric metric values --- lib/Migration/Version010000Date20260908001000.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index ec75eee..4e96abc 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -48,9 +48,10 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $metrics->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); $metrics->addColumn('report_id', Types::BIGINT, ['unsigned' => true]); $metrics->addColumn('category', Types::STRING, ['length' => 128]); - $metrics->addColumn('metric_key', Types::STRING, ['length' => 256]); + $metrics->addColumn('metric_key', Types::STRING, ['length' => 512]); $metrics->addColumn('metric_type', Types::STRING, ['length' => 16]); $metrics->addColumn('metric_value', Types::TEXT); + $metrics->addColumn('numeric_value', Types::FLOAT, ['notnull' => false]); $metrics->setPrimaryKey(['id']); $metrics->addUniqueIndex(['report_id', 'category', 'metric_key'], 'usage_stats_metric_identity'); $metrics->addIndex(['category', 'metric_key'], 'usage_stats_metric_lookup'); From d038e1710f5202bbfef79cff1a92953857e1c636 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:56:29 -0300 Subject: [PATCH 031/283] feat: persist normalized numeric metric values --- lib/Db/ReportRepository.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index ef29418..de96eeb 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -39,12 +39,17 @@ public function store(Report $report, array $rawPayload): array { foreach ($report->metrics as $metric) { $metricQb = $this->db->getQueryBuilder(); + $numericValue = in_array($metric->type, ['integer', 'number'], true) + ? (float)$metric->value + : null; + $metricQb->insert('usage_stats_metrics')->values([ 'report_id' => $metricQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), 'category' => $metricQb->createNamedParameter($metric->category), 'metric_key' => $metricQb->createNamedParameter($metric->key), 'metric_type' => $metricQb->createNamedParameter($metric->type), 'metric_value' => $metricQb->createNamedParameter(json_encode($metric->value, JSON_THROW_ON_ERROR)), + 'numeric_value' => $metricQb->createNamedParameter($numericValue), ])->executeStatement(); } From b8c99a5e7d7980abc9f9188705eef1573b3cb6ca Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:56:45 -0300 Subject: [PATCH 032/283] feat: add numerical statistics aggregation --- lib/Db/StatisticsRepository.php | 51 +++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index 80fed0e..712a363 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -48,8 +48,39 @@ public function currentDistribution(string $application, string $category, strin return $distribution; } - /** @return list */ - public function metricHistory( + /** @return array{count:int,average:float|null,min:float|null,max:float|null,total:float|null} */ + public function currentNumericalEvaluation(string $application, string $category, string $key): array { + $qb = $this->db->getQueryBuilder(); + $row = $qb + ->select('COUNT(m.numeric_value) AS value_count') + ->addSelect($qb->createFunction('AVG(m.numeric_value) AS average_value')) + ->addSelect($qb->createFunction('MIN(m.numeric_value) AS min_value')) + ->addSelect($qb->createFunction('MAX(m.numeric_value) AS max_value')) + ->addSelect($qb->createFunction('SUM(m.numeric_value) AS total_value')) + ->from('usage_stats_installations', 'i') + ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) + ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) + ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) + ->andWhere($qb->expr()->isNotNull('m.numeric_value')) + ->executeQuery() + ->fetch(); + + if ($row === false) { + return ['count' => 0, 'average' => null, 'min' => null, 'max' => null, 'total' => null]; + } + + return [ + 'count' => (int)$row['value_count'], + 'average' => $row['average_value'] === null ? null : round((float)$row['average_value'], 2), + 'min' => $row['min_value'] === null ? null : (float)$row['min_value'], + 'max' => $row['max_value'] === null ? null : (float)$row['max_value'], + 'total' => $row['total_value'] === null ? null : (float)$row['total_value'], + ]; + } + + /** @return list */ + public function numericalHistory( string $application, string $category, string $key, @@ -57,14 +88,21 @@ public function metricHistory( \DateTimeImmutable $to, ): array { $qb = $this->db->getQueryBuilder(); - $result = $qb->select('r.period_start', 'r.period_end', 'm.metric_value', 'm.metric_type') + $result = $qb + ->select('r.period_start', 'r.period_end', 'COUNT(m.numeric_value) AS value_count') + ->addSelect($qb->createFunction('AVG(m.numeric_value) AS average_value')) + ->addSelect($qb->createFunction('MIN(m.numeric_value) AS min_value')) + ->addSelect($qb->createFunction('MAX(m.numeric_value) AS max_value')) + ->addSelect($qb->createFunction('SUM(m.numeric_value) AS total_value')) ->from('usage_stats_reports', 'r') ->innerJoin('r', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'r.id')) ->where($qb->expr()->eq('r.application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) + ->andWhere($qb->expr()->isNotNull('m.numeric_value')) ->andWhere($qb->expr()->gte('r.period_end', $qb->createNamedParameter($from, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) ->andWhere($qb->expr()->lte('r.period_end', $qb->createNamedParameter($to, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->groupBy('r.period_start', 'r.period_end') ->orderBy('r.period_end', 'ASC') ->executeQuery(); @@ -73,8 +111,11 @@ public function metricHistory( $history[] = [ 'periodStart' => (string)$row['period_start'], 'periodEnd' => (string)$row['period_end'], - 'value' => json_decode((string)$row['metric_value'], true, 512, JSON_THROW_ON_ERROR), - 'type' => (string)$row['metric_type'], + 'count' => (int)$row['value_count'], + 'average' => $row['average_value'] === null ? null : round((float)$row['average_value'], 2), + 'min' => $row['min_value'] === null ? null : (float)$row['min_value'], + 'max' => $row['max_value'] === null ? null : (float)$row['max_value'], + 'total' => $row['total_value'] === null ? null : (float)$row['total_value'], ]; } $result->closeCursor(); From 9b625842866c6ed0946d861463591a69d747a671 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:56:55 -0300 Subject: [PATCH 033/283] feat: expose numerical statistics endpoints --- lib/Controller/StatisticsController.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php index baad0b5..6ef5a56 100644 --- a/lib/Controller/StatisticsController.php +++ b/lib/Controller/StatisticsController.php @@ -43,7 +43,16 @@ public function distribution(string $application, string $category, string $key) ]); } - public function history( + public function numerical(string $application, string $category, string $key): DataResponse { + return new DataResponse([ + 'application' => $application, + 'category' => $category, + 'key' => $key, + 'statistics' => $this->statistics->currentNumericalEvaluation($application, $category, $key), + ]); + } + + public function numericalHistory( string $application, string $category, string $key, @@ -65,7 +74,7 @@ public function history( 'key' => $key, 'from' => $range['from']->format(DATE_ATOM), 'to' => $range['to']->format(DATE_ATOM), - 'values' => $this->statistics->metricHistory( + 'periods' => $this->statistics->numericalHistory( $application, $category, $key, From 85b438385a8ed8cb9360f848fc07d5a629a8cfd4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:57:05 -0300 Subject: [PATCH 034/283] feat: add numerical statistics routes --- appinfo/routes.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index a86b19b..76f57d0 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -7,6 +7,7 @@ ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], ['name' => 'statistics#summary', 'url' => '/api/v1/admin/{application}', 'verb' => 'GET'], ['name' => 'statistics#distribution', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/distribution', 'verb' => 'GET'], - ['name' => 'statistics#history', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/history', 'verb' => 'GET'], + ['name' => 'statistics#numerical', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/numerical', 'verb' => 'GET'], + ['name' => 'statistics#numericalHistory', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/numerical/history', 'verb' => 'GET'], ], ]; From ff38000eeaa3f89b5f36589c5108f8ba7547bd05 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:57:22 -0300 Subject: [PATCH 035/283] test: cover numerical statistics aggregation --- .../Db/StatisticsRepositoryTest.php | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/tests/php/Integration/Db/StatisticsRepositoryTest.php b/tests/php/Integration/Db/StatisticsRepositoryTest.php index 7cb23fb..36582b7 100644 --- a/tests/php/Integration/Db/StatisticsRepositoryTest.php +++ b/tests/php/Integration/Db/StatisticsRepositoryTest.php @@ -35,10 +35,10 @@ protected function tearDown(): void { parent::tearDown(); } - public function testCurrentDistributionUsesLatestReportWhileHistoryKeepsAllReports(): void { - $this->store('installation-a', '2026-06-01T00:00:00Z', '2026-07-01T00:00:00Z', '1.0.0'); - $this->store('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0'); - $this->store('installation-b', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0'); + public function testCurrentStateAndHistoricalNumericalAggregations(): void { + $this->store('installation-a', '2026-06-01T00:00:00Z', '2026-07-01T00:00:00Z', '1.0.0', 10); + $this->store('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0', 20); + $this->store('installation-b', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0', 40); self::assertSame(2, $this->statistics->countActiveInstallations( 'libresign', @@ -50,19 +50,37 @@ public function testCurrentDistributionUsesLatestReportWhileHistoryKeepsAllRepor ['value' => '1.1.0', 'count' => 1], ], $this->statistics->currentDistribution('libresign', 'server', 'version')); - $history = $this->statistics->metricHistory( + self::assertSame([ + 'count' => 2, + 'average' => 30.0, + 'min' => 20.0, + 'max' => 40.0, + 'total' => 60.0, + ], $this->statistics->currentNumericalEvaluation('libresign', 'usage', 'requests_completed')); + + $history = $this->statistics->numericalHistory( 'libresign', - 'server', - 'version', + 'usage', + 'requests_completed', new \DateTimeImmutable('2026-06-01T00:00:00Z'), new \DateTimeImmutable('2026-08-02T00:00:00Z'), ); - self::assertCount(3, $history); - self::assertSame(['1.0.0', '1.1.0', '1.0.0'], array_column($history, 'value')); + self::assertCount(2, $history); + self::assertSame(1, $history[0]['count']); + self::assertSame(10.0, $history[0]['total']); + self::assertSame(2, $history[1]['count']); + self::assertSame(30.0, $history[1]['average']); + self::assertSame(60.0, $history[1]['total']); } - private function store(string $installationId, string $start, string $end, string $version): void { + private function store( + string $installationId, + string $start, + string $end, + string $version, + int $requestsCompleted, + ): void { $payload = [ 'protocolVersion' => 1, 'application' => 'libresign', @@ -72,12 +90,20 @@ private function store(string $installationId, string $start, string $end, strin 'start' => $start, 'end' => $end, ], - 'metrics' => [[ - 'category' => 'server', - 'key' => 'version', - 'type' => 'string', - 'value' => $version, - ]], + 'metrics' => [ + [ + 'category' => 'server', + 'key' => 'version', + 'type' => 'string', + 'value' => $version, + ], + [ + 'category' => 'usage', + 'key' => 'requests_completed', + 'type' => 'integer', + 'value' => $requestsCompleted, + ], + ], ]; $this->reports->store($this->factory->fromPayload($payload), $payload); From c11fb351bc867e4e178ae258a60e6b58a3f5c77a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:57:38 -0300 Subject: [PATCH 036/283] docs: document aggregate statistics API --- docs/admin-api.md | 58 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/docs/admin-api.md b/docs/admin-api.md index b13c935..5f4b351 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -1,9 +1,11 @@ # Administrative statistics API -The administrative API exposes aggregated and historical usage statistics to authenticated Nextcloud administrators. These endpoints are not public by default. +The administrative API exposes aggregated usage statistics to authenticated Nextcloud administrators. These endpoints are not public by default. All endpoints use the OCS namespace of the `usage_statistics_server` app. +The API intentionally exposes aggregates rather than individual installation reports. Raw reports remain internal so later privacy thresholds and abuse filtering can be applied without changing the public contract. + ## Summary `GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}` @@ -28,7 +30,7 @@ Example response data: `GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/distribution` -Returns the distribution of a metric using only the latest report for every known installation. +Returns a categorical distribution using only the latest report for every known installation. Example: @@ -46,9 +48,32 @@ Example: This endpoint represents current state. Older reports from the same installation do not contribute to the distribution. -## Metric history +## Current numerical evaluation + +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/numerical` + +Returns the standard numerical evaluation for a numeric metric using only the latest report from each installation. -`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/history` +The shape follows the same useful aggregation set used by Nextcloud's `survey_server`: count, average, minimum, maximum, and total. + +```json +{ + "application": "libresign", + "category": "usage", + "key": "requests_completed", + "statistics": { + "count": 142, + "average": 26.34, + "min": 0, + "max": 820, + "total": 3740 + } +} +``` + +## Numerical history + +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/numerical/history` Optional query parameters: @@ -57,27 +82,40 @@ Optional query parameters: When omitted, `to` defaults to the current time and `from` defaults to one year before `to`. -Example response data: +Reports are grouped by their reporting period. Individual installation values are not returned by this endpoint. ```json { "application": "libresign", - "category": "signing", + "category": "usage", "key": "requests_completed", "from": "2026-01-01T00:00:00+00:00", "to": "2026-09-01T00:00:00+00:00", - "values": [ + "periods": [ { "periodStart": "2026-07-01 00:00:00", "periodEnd": "2026-08-01 00:00:00", - "value": 120, - "type": "integer" + "count": 120, + "average": 22.4, + "min": 0, + "max": 820, + "total": 2688 } ] } ``` -This endpoint returns historical report values. It deliberately does not collapse reports into the latest installation state. +## Reference to survey_server + +The Nextcloud `survey_server` is used as prior art for OCS ingestion and aggregation conventions. In particular, its distinction between diagram-like categorical statistics and numerical evaluation is useful and is retained here. + +This app differs intentionally in important areas: + +- historical reports are preserved instead of replacing the previous report from an installation; +- latest installation state is materialized separately from report history; +- metrics are explicitly typed by the protocol; +- application schemas are versioned; +- raw report values are not exposed through the administrative statistics API. ## Interpretation From a8eb2e44680ee1d01a8d63e683fe9d9dbf95fa33 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:57:57 -0300 Subject: [PATCH 037/283] refactor: align metric validation limits --- lib/Service/ReportFactory.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/Service/ReportFactory.php b/lib/Service/ReportFactory.php index 4fa9073..fb61b04 100644 --- a/lib/Service/ReportFactory.php +++ b/lib/Service/ReportFactory.php @@ -6,6 +6,11 @@ final class ReportFactory { private const IDENTIFIER_PATTERN = '/^[A-Za-z0-9_.:-]+$/'; + private const MAX_APPLICATION_LENGTH = 128; + private const MAX_INSTALLATION_ID_LENGTH = 128; + private const MAX_CATEGORY_LENGTH = 128; + private const MAX_KEY_LENGTH = 512; + private const MAX_STRING_VALUE_LENGTH = 1024; private const MAX_METRICS = 256; public function fromPayload(array $payload): Report { @@ -13,8 +18,8 @@ public function fromPayload(array $payload): Report { throw new InvalidReport('Unsupported protocol version.'); } - $application = $this->identifier($payload['application'] ?? null, 'application', 128); - $installationId = $this->identifier($payload['installationId'] ?? null, 'installationId', 128); + $application = $this->identifier($payload['application'] ?? null, 'application', self::MAX_APPLICATION_LENGTH); + $installationId = $this->identifier($payload['installationId'] ?? null, 'installationId', self::MAX_INSTALLATION_ID_LENGTH); $schemaVersion = $payload['schemaVersion'] ?? null; if (!is_int($schemaVersion) || $schemaVersion < 1) { throw new InvalidReport('schemaVersion must be a positive integer.'); @@ -47,8 +52,8 @@ public function fromPayload(array $payload): Report { if (!is_array($rawMetric)) { throw new InvalidReport('Invalid metric.'); } - $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', 128); - $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', 256); + $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', self::MAX_CATEGORY_LENGTH); + $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', self::MAX_KEY_LENGTH); $type = $rawMetric['type'] ?? null; $value = $rawMetric['value'] ?? null; $identity = $category . ':' . $key; @@ -59,9 +64,9 @@ public function fromPayload(array $payload): Report { $valid = match ($type) { 'integer' => is_int($value), - 'number' => is_int($value) || is_float($value), + 'number' => is_int($value) || (is_float($value) && is_finite($value)), 'boolean' => is_bool($value), - 'string' => is_string($value) && strlen($value) <= 1024, + 'string' => is_string($value) && strlen($value) <= self::MAX_STRING_VALUE_LENGTH, default => false, }; if (!$valid) { From cfa97dbe54a1edbae77f6dd5dc756a75be9f623a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:58:28 -0300 Subject: [PATCH 038/283] feat: add application metric schema storage --- lib/Migration/Version010000Date20260908001000.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index 4e96abc..7880be3 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -15,6 +15,17 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); + if (!$schema->hasTable('usage_stats_schemas')) { + $schemas = $schema->createTable('usage_stats_schemas'); + $schemas->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); + $schemas->addColumn('application', Types::STRING, ['length' => 128]); + $schemas->addColumn('schema_version', Types::INTEGER, ['unsigned' => true]); + $schemas->addColumn('definition', Types::TEXT); + $schemas->addColumn('created_at', Types::DATETIME_IMMUTABLE); + $schemas->setPrimaryKey(['id']); + $schemas->addUniqueIndex(['application', 'schema_version'], 'usage_stats_schema_identity'); + } + if (!$schema->hasTable('usage_stats_installations')) { $installations = $schema->createTable('usage_stats_installations'); $installations->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'unsigned' => true]); From 7e10d1525b228037ce36503e88c60b9b784d7f78 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:58:37 -0300 Subject: [PATCH 039/283] feat: add application schema repository --- lib/Db/SchemaRepository.php | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 lib/Db/SchemaRepository.php diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php new file mode 100644 index 0000000..667a61f --- /dev/null +++ b/lib/Db/SchemaRepository.php @@ -0,0 +1,48 @@ +|null */ + public function find(string $application, int $schemaVersion): ?array { + $qb = $this->db->getQueryBuilder(); + $definition = $qb->select('definition') + ->from('usage_stats_schemas') + ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->eq('schema_version', $qb->createNamedParameter($schemaVersion, IQueryBuilder::PARAM_INT))) + ->executeQuery() + ->fetchOne(); + + if ($definition === false) { + return null; + } + + $decoded = json_decode((string)$definition, true, 512, JSON_THROW_ON_ERROR); + return is_array($decoded) ? $decoded : null; + } + + public function store(string $application, int $schemaVersion, array $definition): void { + $this->db->setValues( + 'usage_stats_schemas', + [ + 'application' => $application, + 'schema_version' => $schemaVersion, + ], + [ + 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), + ], + [ + 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), + 'created_at' => new \DateTimeImmutable('now', new \DateTimeZone('UTC')), + ], + ); + } +} From 4d622a54616c6e9b916181b50d7bf0d38f6e5d50 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:59:05 -0300 Subject: [PATCH 040/283] fix: preserve application schema creation time --- lib/Db/SchemaRepository.php | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php index 667a61f..74af44b 100644 --- a/lib/Db/SchemaRepository.php +++ b/lib/Db/SchemaRepository.php @@ -30,19 +30,27 @@ public function find(string $application, int $schemaVersion): ?array { } public function store(string $application, int $schemaVersion, array $definition): void { - $this->db->setValues( - 'usage_stats_schemas', - [ - 'application' => $application, - 'schema_version' => $schemaVersion, - ], - [ - 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), - ], - [ - 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), - 'created_at' => new \DateTimeImmutable('now', new \DateTimeZone('UTC')), - ], - ); + $encoded = json_encode($definition, JSON_THROW_ON_ERROR); + + if ($this->find($application, $schemaVersion) !== null) { + $qb = $this->db->getQueryBuilder(); + $qb->update('usage_stats_schemas') + ->set('definition', $qb->createNamedParameter($encoded)) + ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->eq('schema_version', $qb->createNamedParameter($schemaVersion, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + return; + } + + $qb = $this->db->getQueryBuilder(); + $qb->insert('usage_stats_schemas')->values([ + 'application' => $qb->createNamedParameter($application), + 'schema_version' => $qb->createNamedParameter($schemaVersion, IQueryBuilder::PARAM_INT), + 'definition' => $qb->createNamedParameter($encoded), + 'created_at' => $qb->createNamedParameter( + new \DateTimeImmutable('now', new \DateTimeZone('UTC')), + IQueryBuilder::PARAM_DATETIME_IMMUTABLE, + ), + ])->executeStatement(); } } From fe00dedc2418abfd158076f9023b952736186ce6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:59:22 -0300 Subject: [PATCH 041/283] feat: add versioned metric schema validation --- lib/Service/SchemaValidator.php | 126 ++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 lib/Service/SchemaValidator.php diff --git a/lib/Service/SchemaValidator.php b/lib/Service/SchemaValidator.php new file mode 100644 index 0000000..8a7de22 --- /dev/null +++ b/lib/Service/SchemaValidator.php @@ -0,0 +1,126 @@ + */ + public function validateDefinition(array $definition): array { + $application = $this->identifier($definition['application'] ?? null, 'application', 128); + $schemaVersion = $definition['schemaVersion'] ?? null; + if (!is_int($schemaVersion) || $schemaVersion < 1) { + throw new InvalidReport('schemaVersion must be a positive integer.'); + } + + $rawMetrics = $definition['metrics'] ?? null; + if (!is_array($rawMetrics) || $rawMetrics === [] || count($rawMetrics) > 256) { + throw new InvalidReport('Schema metrics must be a non-empty bounded list.'); + } + + $metrics = []; + $seen = []; + foreach ($rawMetrics as $rawMetric) { + if (!is_array($rawMetric)) { + throw new InvalidReport('Invalid schema metric.'); + } + + $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', 128); + $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', 512); + $identity = $category . ':' . $key; + if (isset($seen[$identity])) { + throw new InvalidReport('Duplicate schema metric.'); + } + $seen[$identity] = true; + + $type = $rawMetric['type'] ?? null; + $kind = $rawMetric['kind'] ?? null; + $aggregation = $rawMetric['aggregation'] ?? null; + $description = $rawMetric['description'] ?? ''; + $required = $rawMetric['required'] ?? false; + + if (!is_string($type) || !in_array($type, self::TYPES, true)) { + throw new InvalidReport('Invalid schema metric type.'); + } + if (!is_string($kind) || !in_array($kind, self::KINDS, true)) { + throw new InvalidReport('Invalid schema metric kind.'); + } + if (!is_string($aggregation) || !in_array($aggregation, self::AGGREGATIONS, true)) { + throw new InvalidReport('Invalid schema metric aggregation.'); + } + if ($aggregation === 'numerical' && !in_array($type, ['integer', 'number'], true)) { + throw new InvalidReport('Numerical aggregation requires an integer or number metric.'); + } + if (!is_string($description) || strlen($description) > 512) { + throw new InvalidReport('Invalid schema metric description.'); + } + if (!is_bool($required)) { + throw new InvalidReport('Schema metric required must be boolean.'); + } + + $metrics[] = [ + 'category' => $category, + 'key' => $key, + 'type' => $type, + 'kind' => $kind, + 'aggregation' => $aggregation, + 'description' => $description, + 'required' => $required, + ]; + } + + return [ + 'application' => $application, + 'schemaVersion' => $schemaVersion, + 'metrics' => $metrics, + ]; + } + + public function validateReport(Report $report, array $definition): void { + if (($definition['application'] ?? null) !== $report->application + || ($definition['schemaVersion'] ?? null) !== $report->schemaVersion) { + throw new InvalidReport('Report does not match the registered application schema.'); + } + + $allowed = []; + $required = []; + foreach ($definition['metrics'] ?? [] as $metric) { + if (!is_array($metric)) { + throw new InvalidReport('Invalid registered application schema.'); + } + $identity = ($metric['category'] ?? '') . ':' . ($metric['key'] ?? ''); + $allowed[$identity] = $metric; + if (($metric['required'] ?? false) === true) { + $required[$identity] = true; + } + } + + foreach ($report->metrics as $metric) { + $identity = $metric->category . ':' . $metric->key; + $schemaMetric = $allowed[$identity] ?? null; + if (!is_array($schemaMetric)) { + throw new InvalidReport('Report contains a metric that is not registered in the application schema.'); + } + if (($schemaMetric['type'] ?? null) !== $metric->type) { + throw new InvalidReport('Report metric type does not match the application schema.'); + } + unset($required[$identity]); + } + + if ($required !== []) { + throw new InvalidReport('Report is missing a required metric.'); + } + } + + private function identifier(mixed $value, string $field, int $maxLength): string { + if (!is_string($value) || $value === '' || strlen($value) > $maxLength || preg_match(self::IDENTIFIER_PATTERN, $value) !== 1) { + throw new InvalidReport("Invalid {$field}."); + } + return $value; + } +} From 95f785964b2b240a35cd9a2ef3f6503f2d662dec Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:59:32 -0300 Subject: [PATCH 042/283] feat: validate reports against registered schemas --- lib/Controller/ReportController.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/Controller/ReportController.php b/lib/Controller/ReportController.php index 1ad8a61..3021881 100644 --- a/lib/Controller/ReportController.php +++ b/lib/Controller/ReportController.php @@ -5,8 +5,10 @@ namespace OCA\UsageStatisticsServer\Controller; use OCA\UsageStatisticsServer\Db\ReportRepository; +use OCA\UsageStatisticsServer\Db\SchemaRepository; use OCA\UsageStatisticsServer\Service\InvalidReport; use OCA\UsageStatisticsServer\Service\ReportFactory; +use OCA\UsageStatisticsServer\Service\SchemaValidator; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -21,6 +23,8 @@ public function __construct( IRequest $request, private readonly ReportFactory $factory, private readonly ReportRepository $repository, + private readonly SchemaRepository $schemas, + private readonly SchemaValidator $schemaValidator, ) { parent::__construct($appName, $request); } @@ -34,7 +38,14 @@ public function create(): DataResponse { if (!is_array($payload)) { throw new InvalidReport('Request body must be a JSON object.'); } + $report = $this->factory->fromPayload($payload); + $schema = $this->schemas->find($report->application, $report->schemaVersion); + if ($schema === null) { + throw new InvalidReport('Application schema is not registered.'); + } + $this->schemaValidator->validateReport($report, $schema); + $stored = $this->repository->store($report, $payload); } catch (InvalidReport|\JsonException $e) { return new DataResponse(['error' => 'invalid_report', 'message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); From 598ad350eeb4f8a7cb00cd8c36fb629cf6ebe95c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:59:42 -0300 Subject: [PATCH 043/283] feat: add application schema administration API --- lib/Controller/SchemaController.php | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 lib/Controller/SchemaController.php diff --git a/lib/Controller/SchemaController.php b/lib/Controller/SchemaController.php new file mode 100644 index 0000000..0af906e --- /dev/null +++ b/lib/Controller/SchemaController.php @@ -0,0 +1,62 @@ +request->getRawInput(), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($payload)) { + throw new InvalidReport('Request body must be a JSON object.'); + } + + $definition = $this->validator->validateDefinition($payload); + $this->schemas->store( + $definition['application'], + $definition['schemaVersion'], + $definition, + ); + } catch (InvalidReport|\JsonException $e) { + return new DataResponse([ + 'error' => 'invalid_schema', + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'application' => $definition['application'], + 'schemaVersion' => $definition['schemaVersion'], + 'status' => 'stored', + ], Http::STATUS_CREATED); + } + + public function get(string $application, int $schemaVersion): DataResponse { + $definition = $this->schemas->find($application, $schemaVersion); + if ($definition === null) { + return new DataResponse([ + 'error' => 'schema_not_found', + ], Http::STATUS_NOT_FOUND); + } + + return new DataResponse($definition); + } +} From eea554a4970887aee3a6f24ae9ed0354b0c7a559 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 21:59:52 -0300 Subject: [PATCH 044/283] feat: add application schema routes --- appinfo/routes.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index 76f57d0..ed8a43c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -5,6 +5,8 @@ return [ 'ocs' => [ ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], + ['name' => 'schema#create', 'url' => '/api/v1/admin/schemas', 'verb' => 'POST'], + ['name' => 'schema#get', 'url' => '/api/v1/admin/schemas/{application}/{schemaVersion}', 'verb' => 'GET'], ['name' => 'statistics#summary', 'url' => '/api/v1/admin/{application}', 'verb' => 'GET'], ['name' => 'statistics#distribution', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/distribution', 'verb' => 'GET'], ['name' => 'statistics#numerical', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/numerical', 'verb' => 'GET'], From be136cff7e5e6ad8b8610de70f855eff1796468b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:00:08 -0300 Subject: [PATCH 045/283] test: cover application schema validation --- .../php/Unit/Service/SchemaValidatorTest.php | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/php/Unit/Service/SchemaValidatorTest.php diff --git a/tests/php/Unit/Service/SchemaValidatorTest.php b/tests/php/Unit/Service/SchemaValidatorTest.php new file mode 100644 index 0000000..b0e3f0c --- /dev/null +++ b/tests/php/Unit/Service/SchemaValidatorTest.php @@ -0,0 +1,107 @@ +validator = new SchemaValidator(); + } + + public function testValidDefinitionAndReport(): void { + $definition = $this->validator->validateDefinition($this->definition()); + + $report = new Report( + 1, + 'libresign', + 'installation-1', + 1, + new \DateTimeImmutable('2026-08-01T00:00:00Z'), + new \DateTimeImmutable('2026-09-01T00:00:00Z'), + [ + new Metric('server', 'version', 'string', '12.0.0'), + new Metric('usage', 'requests_completed', 'integer', 42), + ], + ); + + $this->validator->validateReport($report, $definition); + self::assertTrue(true); + } + + public function testRejectsUnknownMetric(): void { + $definition = $this->validator->validateDefinition($this->definition()); + $report = new Report( + 1, + 'libresign', + 'installation-1', + 1, + new \DateTimeImmutable('2026-08-01T00:00:00Z'), + new \DateTimeImmutable('2026-09-01T00:00:00Z'), + [new Metric('usage', 'unknown', 'integer', 1)], + ); + + $this->expectException(InvalidReport::class); + $this->validator->validateReport($report, $definition); + } + + public function testRejectsMissingRequiredMetric(): void { + $definition = $this->validator->validateDefinition($this->definition()); + $report = new Report( + 1, + 'libresign', + 'installation-1', + 1, + new \DateTimeImmutable('2026-08-01T00:00:00Z'), + new \DateTimeImmutable('2026-09-01T00:00:00Z'), + [new Metric('server', 'version', 'string', '12.0.0')], + ); + + $this->expectException(InvalidReport::class); + $this->validator->validateReport($report, $definition); + } + + public function testRejectsNumericalAggregationForStringMetric(): void { + $definition = $this->definition(); + $definition['metrics'][0]['aggregation'] = 'numerical'; + + $this->expectException(InvalidReport::class); + $this->validator->validateDefinition($definition); + } + + /** @return array */ + private function definition(): array { + return [ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [ + [ + 'category' => 'server', + 'key' => 'version', + 'type' => 'string', + 'kind' => 'snapshot', + 'aggregation' => 'distribution', + 'description' => 'LibreSign version', + 'required' => true, + ], + [ + 'category' => 'usage', + 'key' => 'requests_completed', + 'type' => 'integer', + 'kind' => 'period', + 'aggregation' => 'numerical', + 'description' => 'Completed signing requests', + 'required' => true, + ], + ], + ]; + } +} From ead1deb8a448d8055806196b51b1ed4db103972c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:00:24 -0300 Subject: [PATCH 046/283] docs: document application metric schemas --- docs/application-schemas.md | 83 +++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/application-schemas.md diff --git a/docs/application-schemas.md b/docs/application-schemas.md new file mode 100644 index 0000000..c1084b9 --- /dev/null +++ b/docs/application-schemas.md @@ -0,0 +1,83 @@ +# Application metric schemas + +The server uses versioned application schemas as an allowlist and interpretation contract for submitted metrics. + +This is inspired by the role of `data.json` in Nextcloud's `survey_server`, but the schema is scoped to an application and version so unrelated applications can evolve independently. + +## Definition + +Example: + +```json +{ + "application": "libresign", + "schemaVersion": 1, + "metrics": [ + { + "category": "server", + "key": "version", + "type": "string", + "kind": "snapshot", + "aggregation": "distribution", + "description": "LibreSign version", + "required": true + }, + { + "category": "usage", + "key": "requests_completed", + "type": "integer", + "kind": "period", + "aggregation": "numerical", + "description": "Completed signing requests during the reporting period", + "required": true + } + ] +} +``` + +## Metric fields + +- `category`: stable metric namespace; +- `key`: stable metric identifier inside the category; +- `type`: `integer`, `number`, `boolean`, or `string`; +- `kind`: `snapshot`, `period`, `counter`, or `categorical`; +- `aggregation`: `distribution`, `numerical`, or `none`; +- `description`: human-readable meaning; +- `required`: whether every report using this schema version must contain the metric. + +`numerical` aggregation is only valid for `integer` and `number` metrics. + +## Registration + +Schemas are registered by a Nextcloud administrator: + +```text +POST /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/schemas +``` + +The request body is the schema definition JSON. + +A stored schema can be inspected with: + +```text +GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/schemas/{application}/{schemaVersion} +``` + +## Ingestion behavior + +A report is accepted only when its `(application, schemaVersion)` is registered. + +The server rejects a report when: + +- its schema is not registered; +- it contains a metric not present in the schema; +- a metric type does not match the schema; +- a required metric is missing. + +This makes the schema an explicit allowlist rather than allowing arbitrary metric names into storage. + +## Evolution + +Schemas are immutable from the client's point of view. If the meaning, type, or required set of metrics changes incompatibly, the application should publish a new `schemaVersion`. + +The server currently allows an administrator to replace a stored definition for operational correction. This should be used carefully once reports for that version exist, because historical reports are interpreted using the registered definition. From 178964b7bdebf964d67e5319bda161dec4806909 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:00:57 -0300 Subject: [PATCH 047/283] fix: make application schema versions immutable --- lib/Db/SchemaRepository.php | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php index 74af44b..7383666 100644 --- a/lib/Db/SchemaRepository.php +++ b/lib/Db/SchemaRepository.php @@ -29,28 +29,30 @@ public function find(string $application, int $schemaVersion): ?array { return is_array($decoded) ? $decoded : null; } - public function store(string $application, int $schemaVersion, array $definition): void { - $encoded = json_encode($definition, JSON_THROW_ON_ERROR); - - if ($this->find($application, $schemaVersion) !== null) { - $qb = $this->db->getQueryBuilder(); - $qb->update('usage_stats_schemas') - ->set('definition', $qb->createNamedParameter($encoded)) - ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) - ->andWhere($qb->expr()->eq('schema_version', $qb->createNamedParameter($schemaVersion, IQueryBuilder::PARAM_INT))) - ->executeStatement(); - return; + /** + * @return bool true when created, false when the same definition already exists + * @throws \LogicException when the version already exists with a different definition + */ + public function store(string $application, int $schemaVersion, array $definition): bool { + $existing = $this->find($application, $schemaVersion); + if ($existing !== null) { + if ($existing === $definition) { + return false; + } + throw new \LogicException('Schema version already exists with a different definition.'); } $qb = $this->db->getQueryBuilder(); $qb->insert('usage_stats_schemas')->values([ 'application' => $qb->createNamedParameter($application), 'schema_version' => $qb->createNamedParameter($schemaVersion, IQueryBuilder::PARAM_INT), - 'definition' => $qb->createNamedParameter($encoded), + 'definition' => $qb->createNamedParameter(json_encode($definition, JSON_THROW_ON_ERROR)), 'created_at' => $qb->createNamedParameter( new \DateTimeImmutable('now', new \DateTimeZone('UTC')), IQueryBuilder::PARAM_DATETIME_IMMUTABLE, ), ])->executeStatement(); + + return true; } } From b3c0ab52aa014ab516a5a2e0c974a59272fe2096 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:01:11 -0300 Subject: [PATCH 048/283] fix: make schema registration idempotent --- lib/Controller/SchemaController.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/Controller/SchemaController.php b/lib/Controller/SchemaController.php index 0af906e..eb2fdcb 100644 --- a/lib/Controller/SchemaController.php +++ b/lib/Controller/SchemaController.php @@ -30,7 +30,7 @@ public function create(): DataResponse { } $definition = $this->validator->validateDefinition($payload); - $this->schemas->store( + $created = $this->schemas->store( $definition['application'], $definition['schemaVersion'], $definition, @@ -40,13 +40,18 @@ public function create(): DataResponse { 'error' => 'invalid_schema', 'message' => $e->getMessage(), ], Http::STATUS_BAD_REQUEST); + } catch (\LogicException $e) { + return new DataResponse([ + 'error' => 'schema_conflict', + 'message' => $e->getMessage(), + ], Http::STATUS_CONFLICT); } return new DataResponse([ 'application' => $definition['application'], 'schemaVersion' => $definition['schemaVersion'], - 'status' => 'stored', - ], Http::STATUS_CREATED); + 'status' => $created ? 'created' : 'already_registered', + ], $created ? Http::STATUS_CREATED : Http::STATUS_OK); } public function get(string $application, int $schemaVersion): DataResponse { From feb5bec80286d24fc2502e59c421ee5df28625d1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:01:24 -0300 Subject: [PATCH 049/283] test: cover immutable application schemas --- .../Integration/Db/SchemaRepositoryTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/php/Integration/Db/SchemaRepositoryTest.php diff --git a/tests/php/Integration/Db/SchemaRepositoryTest.php b/tests/php/Integration/Db/SchemaRepositoryTest.php new file mode 100644 index 0000000..e805ade --- /dev/null +++ b/tests/php/Integration/Db/SchemaRepositoryTest.php @@ -0,0 +1,47 @@ +db = Server::get(IDBConnection::class); + $this->schemas = new SchemaRepository($this->db); + $this->db->getQueryBuilder()->delete('usage_stats_schemas')->executeStatement(); + } + + protected function tearDown(): void { + $this->db->getQueryBuilder()->delete('usage_stats_schemas')->executeStatement(); + parent::tearDown(); + } + + public function testRegistrationIsIdempotentAndImmutable(): void { + $definition = [ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [], + ]; + + self::assertTrue($this->schemas->store('libresign', 1, $definition)); + self::assertFalse($this->schemas->store('libresign', 1, $definition)); + self::assertSame($definition, $this->schemas->find('libresign', 1)); + + $changed = $definition; + $changed['metrics'][] = ['category' => 'server']; + + $this->expectException(\LogicException::class); + $this->schemas->store('libresign', 1, $changed); + } +} From 95bb88f565fd803635ac4ceb76b734ea792d2371 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:02:21 -0300 Subject: [PATCH 050/283] feat: add historical data retention job --- lib/BackgroundJob/CleanupOldData.php | 101 +++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 lib/BackgroundJob/CleanupOldData.php diff --git a/lib/BackgroundJob/CleanupOldData.php b/lib/BackgroundJob/CleanupOldData.php new file mode 100644 index 0000000..cfb96f2 --- /dev/null +++ b/lib/BackgroundJob/CleanupOldData.php @@ -0,0 +1,101 @@ +setInterval(60 * 60 * 24); + $this->setTimeSensitivity(self::TIME_INSENSITIVE); + } + + #[\Override] + protected function run($argument): void { + $retentionDays = max(1, $this->appConfig->getValueInt( + self::APP_ID, + 'retention_days', + self::DEFAULT_RETENTION_DAYS, + )); + $cutoff = (new \DateTimeImmutable('@' . ($this->time->getTime() - ($retentionDays * 86400)))) + ->setTimezone(new \DateTimeZone('UTC')); + + $this->deleteStaleInstallations($cutoff); + + while (true) { + $reportIds = $this->findOldReportIds($cutoff); + if ($reportIds === []) { + break; + } + + $this->db->beginTransaction(); + try { + $metrics = $this->db->getQueryBuilder(); + $metrics->delete('usage_stats_metrics') + ->where($metrics->expr()->in( + 'report_id', + $metrics->createNamedParameter($reportIds, IQueryBuilder::PARAM_INT_ARRAY), + )) + ->executeStatement(); + + $reports = $this->db->getQueryBuilder(); + $reports->delete('usage_stats_reports') + ->where($reports->expr()->in( + 'id', + $reports->createNamedParameter($reportIds, IQueryBuilder::PARAM_INT_ARRAY), + )) + ->executeStatement(); + + $this->db->commit(); + } catch (\Throwable $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + } + } + + private function deleteStaleInstallations(\DateTimeImmutable $cutoff): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('usage_stats_installations') + ->where($qb->expr()->lt( + 'last_seen_at', + $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + )) + ->executeStatement(); + } + + /** @return list */ + private function findOldReportIds(\DateTimeImmutable $cutoff): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('id') + ->from('usage_stats_reports') + ->where($qb->expr()->lt( + 'received_at', + $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + )) + ->orderBy('id', 'ASC') + ->setMaxResults(self::BATCH_SIZE) + ->executeQuery(); + + $ids = array_map('intval', $result->fetchFirstColumn()); + $result->closeCursor(); + return $ids; + } +} From 052e867d1093afa7aeb85613b695713dd9cfbbe0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:02:32 -0300 Subject: [PATCH 051/283] feat: register historical retention job --- appinfo/info.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/appinfo/info.xml b/appinfo/info.xml index c824d7a..3aa2111 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -23,4 +23,7 @@ A generic Nextcloud app for receiving, storing, aggregating, and exposing privac + + OCA\UsageStatisticsServer\BackgroundJob\CleanupOldData + From 6a661a0eabd52bec66ed761892897660b04b1054 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:03:40 -0300 Subject: [PATCH 052/283] docs: clarify immutable application schemas --- docs/application-schemas.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/application-schemas.md b/docs/application-schemas.md index c1084b9..52bb396 100644 --- a/docs/application-schemas.md +++ b/docs/application-schemas.md @@ -63,6 +63,10 @@ A stored schema can be inspected with: GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/schemas/{application}/{schemaVersion} ``` +Registration is idempotent when the same definition is submitted again. + +A schema version is immutable after it is registered. Reusing the same `(application, schemaVersion)` with a different definition returns a conflict. Applications must increment `schemaVersion` when the contract changes. + ## Ingestion behavior A report is accepted only when its `(application, schemaVersion)` is registered. @@ -78,6 +82,8 @@ This makes the schema an explicit allowlist rather than allowing arbitrary metri ## Evolution -Schemas are immutable from the client's point of view. If the meaning, type, or required set of metrics changes incompatibly, the application should publish a new `schemaVersion`. +Schemas are immutable so historical reports keep the interpretation they had when received. + +If a metric meaning or type changes incompatibly, prefer both a new `schemaVersion` and a new metric key. This keeps historical aggregates unambiguous when a query spans multiple schema versions. -The server currently allows an administrator to replace a stored definition for operational correction. This should be used carefully once reports for that version exist, because historical reports are interpreted using the registered definition. +Adding an optional metric or changing presentation metadata still requires a new `schemaVersion`; the client and server contract should never depend on silently changing a registered definition. From d636dadbc49c70d73206face929a037f478e748f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:03:58 -0300 Subject: [PATCH 053/283] docs: document registered schema behavior --- docs/protocol-v1.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 6cedb8a..8045d8e 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -79,6 +79,8 @@ Version of the metric schema defined by the sending application. The server stores this value so historical reports remain interpretable after an application evolves its metrics. +The `usage_statistics_server` implementation requires the `(application, schemaVersion)` definition to be registered by an administrator before reports using it are accepted. Registered schema versions are immutable. + ### `period` The reporting interval represented by period metrics. @@ -120,6 +122,8 @@ Application schemas SHOULD document whether each metric is one of: Servers MUST NOT blindly sum repeated snapshots across periods. +The `usage_statistics_server` application schema also declares the allowed aggregation for each metric (`distribution`, `numerical`, or `none`). + ## Idempotency The logical identity of a report is: @@ -139,7 +143,7 @@ A repeated submission MAY: but it MUST NOT create a second logical report that would double-count statistics. -The server implementation MUST document which behavior it uses. +The `usage_statistics_server` implementation keeps the first accepted report immutable and returns `already_received` for an identical logical report submitted again. ## Validation @@ -153,8 +157,9 @@ The server MUST validate at least: - metric count per report; - category/key format and length; - metric type/value compatibility; -- scalar value size; -- application-specific schema when configured. +- scalar value size. + +The `usage_statistics_server` implementation additionally requires a registered application schema and rejects unknown metrics, type mismatches, and missing required metrics. Invalid reports MUST NOT be partially persisted. From 7a6aeacaa1f1b8dd01e6b1d717e3d7785602d33e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:04:07 -0300 Subject: [PATCH 054/283] refactor: extract historical data cleanup service --- lib/Service/RetentionService.php | 89 ++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 lib/Service/RetentionService.php diff --git a/lib/Service/RetentionService.php b/lib/Service/RetentionService.php new file mode 100644 index 0000000..ab845c7 --- /dev/null +++ b/lib/Service/RetentionService.php @@ -0,0 +1,89 @@ +deleteStaleInstallations($cutoff); + $reportsDeleted = 0; + $metricsDeleted = 0; + + while (true) { + $reportIds = $this->findOldReportIds($cutoff); + if ($reportIds === []) { + break; + } + + $this->db->beginTransaction(); + try { + $metrics = $this->db->getQueryBuilder(); + $metricsDeleted += $metrics->delete('usage_stats_metrics') + ->where($metrics->expr()->in( + 'report_id', + $metrics->createNamedParameter($reportIds, IQueryBuilder::PARAM_INT_ARRAY), + )) + ->executeStatement(); + + $reports = $this->db->getQueryBuilder(); + $reportsDeleted += $reports->delete('usage_stats_reports') + ->where($reports->expr()->in( + 'id', + $reports->createNamedParameter($reportIds, IQueryBuilder::PARAM_INT_ARRAY), + )) + ->executeStatement(); + + $this->db->commit(); + } catch (\Throwable $e) { + if ($this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $e; + } + } + + return [ + 'installations' => $installations, + 'reports' => $reportsDeleted, + 'metrics' => $metricsDeleted, + ]; + } + + private function deleteStaleInstallations(\DateTimeImmutable $cutoff): int { + $qb = $this->db->getQueryBuilder(); + return $qb->delete('usage_stats_installations') + ->where($qb->expr()->lt( + 'last_seen_at', + $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + )) + ->executeStatement(); + } + + /** @return list */ + private function findOldReportIds(\DateTimeImmutable $cutoff): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('id') + ->from('usage_stats_reports') + ->where($qb->expr()->lt( + 'received_at', + $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + )) + ->orderBy('id', 'ASC') + ->setMaxResults(self::BATCH_SIZE) + ->executeQuery(); + + $ids = array_map('intval', $result->fetchFirstColumn()); + $result->closeCursor(); + return $ids; + } +} From 9a3aecc1e42639d982dedd418d70ebc0ec35f185 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:04:18 -0300 Subject: [PATCH 055/283] refactor: keep retention background job thin --- lib/BackgroundJob/CleanupOldData.php | 69 ++-------------------------- 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/lib/BackgroundJob/CleanupOldData.php b/lib/BackgroundJob/CleanupOldData.php index cfb96f2..e0a66f1 100644 --- a/lib/BackgroundJob/CleanupOldData.php +++ b/lib/BackgroundJob/CleanupOldData.php @@ -4,21 +4,19 @@ namespace OCA\UsageStatisticsServer\BackgroundJob; +use OCA\UsageStatisticsServer\Service\RetentionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; -use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IAppConfig; -use OCP\IDBConnection; final class CleanupOldData extends TimedJob { private const APP_ID = 'usage_statistics_server'; private const DEFAULT_RETENTION_DAYS = 1095; - private const BATCH_SIZE = 500; public function __construct( ITimeFactory $time, - private readonly IDBConnection $db, private readonly IAppConfig $appConfig, + private readonly RetentionService $retention, ) { parent::__construct($time); $this->setInterval(60 * 60 * 24); @@ -35,67 +33,6 @@ protected function run($argument): void { $cutoff = (new \DateTimeImmutable('@' . ($this->time->getTime() - ($retentionDays * 86400)))) ->setTimezone(new \DateTimeZone('UTC')); - $this->deleteStaleInstallations($cutoff); - - while (true) { - $reportIds = $this->findOldReportIds($cutoff); - if ($reportIds === []) { - break; - } - - $this->db->beginTransaction(); - try { - $metrics = $this->db->getQueryBuilder(); - $metrics->delete('usage_stats_metrics') - ->where($metrics->expr()->in( - 'report_id', - $metrics->createNamedParameter($reportIds, IQueryBuilder::PARAM_INT_ARRAY), - )) - ->executeStatement(); - - $reports = $this->db->getQueryBuilder(); - $reports->delete('usage_stats_reports') - ->where($reports->expr()->in( - 'id', - $reports->createNamedParameter($reportIds, IQueryBuilder::PARAM_INT_ARRAY), - )) - ->executeStatement(); - - $this->db->commit(); - } catch (\Throwable $e) { - if ($this->db->inTransaction()) { - $this->db->rollBack(); - } - throw $e; - } - } - } - - private function deleteStaleInstallations(\DateTimeImmutable $cutoff): void { - $qb = $this->db->getQueryBuilder(); - $qb->delete('usage_stats_installations') - ->where($qb->expr()->lt( - 'last_seen_at', - $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), - )) - ->executeStatement(); - } - - /** @return list */ - private function findOldReportIds(\DateTimeImmutable $cutoff): array { - $qb = $this->db->getQueryBuilder(); - $result = $qb->select('id') - ->from('usage_stats_reports') - ->where($qb->expr()->lt( - 'received_at', - $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), - )) - ->orderBy('id', 'ASC') - ->setMaxResults(self::BATCH_SIZE) - ->executeQuery(); - - $ids = array_map('intval', $result->fetchFirstColumn()); - $result->closeCursor(); - return $ids; + $this->retention->cleanupBefore($cutoff); } } From b123506ad4a13772abc49cf15cbfb556992fef44 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:04:34 -0300 Subject: [PATCH 056/283] test: cover historical data retention --- .../Service/RetentionServiceTest.php | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/php/Integration/Service/RetentionServiceTest.php diff --git a/tests/php/Integration/Service/RetentionServiceTest.php b/tests/php/Integration/Service/RetentionServiceTest.php new file mode 100644 index 0000000..23c671c --- /dev/null +++ b/tests/php/Integration/Service/RetentionServiceTest.php @@ -0,0 +1,117 @@ +db = Server::get(IDBConnection::class); + $this->reports = new ReportRepository($this->db); + $this->schemas = new SchemaRepository($this->db); + $this->retention = new RetentionService($this->db); + $this->factory = new ReportFactory(); + $this->clearTables(); + } + + protected function tearDown(): void { + $this->clearTables(); + parent::tearDown(); + } + + public function testCleanupRemovesOldReportsAndKeepsSchemasAndRecentData(): void { + $this->schemas->store('libresign', 1, [ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [], + ]); + + $old = $this->store('old-installation', '2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z'); + $recent = $this->store('recent-installation', '2026-08-01T00:00:00Z', '2026-09-01T00:00:00Z'); + + $this->setReportReceivedAt($old, '2025-02-02T00:00:00Z'); + $this->setInstallationLastSeen('old-installation', '2025-02-02T00:00:00Z'); + $this->setReportReceivedAt($recent, '2026-09-02T00:00:00Z'); + $this->setInstallationLastSeen('recent-installation', '2026-09-02T00:00:00Z'); + + $deleted = $this->retention->cleanupBefore(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + + self::assertSame(1, $deleted['installations']); + self::assertSame(1, $deleted['reports']); + self::assertSame(1, $deleted['metrics']); + self::assertSame(1, $this->countRows('usage_stats_schemas')); + self::assertSame(1, $this->countRows('usage_stats_installations')); + self::assertSame(1, $this->countRows('usage_stats_reports')); + self::assertSame(1, $this->countRows('usage_stats_metrics')); + } + + private function store(string $installationId, string $start, string $end): int { + $payload = [ + 'protocolVersion' => 1, + 'application' => 'libresign', + 'installationId' => $installationId, + 'schemaVersion' => 1, + 'period' => ['start' => $start, 'end' => $end], + 'metrics' => [[ + 'category' => 'usage', + 'key' => 'requests_completed', + 'type' => 'integer', + 'value' => 1, + ]], + ]; + + return $this->reports->store($this->factory->fromPayload($payload), $payload)['id']; + } + + private function setReportReceivedAt(int $reportId, string $date): void { + $qb = $this->db->getQueryBuilder(); + $qb->update('usage_stats_reports') + ->set('received_at', $qb->createNamedParameter( + new \DateTimeImmutable($date), + IQueryBuilder::PARAM_DATETIME_IMMUTABLE, + )) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + + private function setInstallationLastSeen(string $installationId, string $date): void { + $qb = $this->db->getQueryBuilder(); + $qb->update('usage_stats_installations') + ->set('last_seen_at', $qb->createNamedParameter( + new \DateTimeImmutable($date), + IQueryBuilder::PARAM_DATETIME_IMMUTABLE, + )) + ->where($qb->expr()->eq('installation_id', $qb->createNamedParameter($installationId))) + ->executeStatement(); + } + + private function countRows(string $table): int { + $qb = $this->db->getQueryBuilder(); + return (int)$qb->select('COUNT(*)')->from($table)->executeQuery()->fetchOne(); + } + + private function clearTables(): void { + $this->db->getQueryBuilder()->delete('usage_stats_metrics')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_installations')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_reports')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_schemas')->executeStatement(); + } +} From 9380cd6a19f969a02efb42bbbed9f4962242beab Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:05:07 -0300 Subject: [PATCH 057/283] feat: add retention settings service --- lib/Service/SettingsService.php | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 lib/Service/SettingsService.php diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php new file mode 100644 index 0000000..06b7b09 --- /dev/null +++ b/lib/Service/SettingsService.php @@ -0,0 +1,40 @@ +appConfig->getValueInt( + self::APP_ID, + 'retention_days', + self::DEFAULT_RETENTION_DAYS, + ); + + return min(self::MAX_RETENTION_DAYS, max(self::MIN_RETENTION_DAYS, $value)); + } + + public function setRetentionDays(int $days): int { + if ($days < self::MIN_RETENTION_DAYS || $days > self::MAX_RETENTION_DAYS) { + throw new \InvalidArgumentException(sprintf( + 'retentionDays must be between %d and %d.', + self::MIN_RETENTION_DAYS, + self::MAX_RETENTION_DAYS, + )); + } + + $this->appConfig->setValueInt(self::APP_ID, 'retention_days', $days); + return $days; + } +} From 6afe719361ddb542c84bfd4585982b9aa251342f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:05:16 -0300 Subject: [PATCH 058/283] feat: add retention settings API --- lib/Controller/SettingsController.php | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lib/Controller/SettingsController.php diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..aead82e --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,42 @@ + $this->settings->getRetentionDays(), + 'minimumRetentionDays' => SettingsService::MIN_RETENTION_DAYS, + 'maximumRetentionDays' => SettingsService::MAX_RETENTION_DAYS, + ]); + } + + public function update(int $retentionDays): DataResponse { + try { + $retentionDays = $this->settings->setRetentionDays($retentionDays); + } catch (\InvalidArgumentException $e) { + return new DataResponse([ + 'error' => 'invalid_retention', + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(['retentionDays' => $retentionDays]); + } +} From 5b55d118f4bba26ca15bf254fb78a5006589e1ad Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:05:28 -0300 Subject: [PATCH 059/283] refactor: use validated retention settings in cleanup job --- lib/BackgroundJob/CleanupOldData.php | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/lib/BackgroundJob/CleanupOldData.php b/lib/BackgroundJob/CleanupOldData.php index e0a66f1..a8d367d 100644 --- a/lib/BackgroundJob/CleanupOldData.php +++ b/lib/BackgroundJob/CleanupOldData.php @@ -5,17 +5,14 @@ namespace OCA\UsageStatisticsServer\BackgroundJob; use OCA\UsageStatisticsServer\Service\RetentionService; +use OCA\UsageStatisticsServer\Service\SettingsService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; -use OCP\IAppConfig; final class CleanupOldData extends TimedJob { - private const APP_ID = 'usage_statistics_server'; - private const DEFAULT_RETENTION_DAYS = 1095; - public function __construct( ITimeFactory $time, - private readonly IAppConfig $appConfig, + private readonly SettingsService $settings, private readonly RetentionService $retention, ) { parent::__construct($time); @@ -25,11 +22,7 @@ public function __construct( #[\Override] protected function run($argument): void { - $retentionDays = max(1, $this->appConfig->getValueInt( - self::APP_ID, - 'retention_days', - self::DEFAULT_RETENTION_DAYS, - )); + $retentionDays = $this->settings->getRetentionDays(); $cutoff = (new \DateTimeImmutable('@' . ($this->time->getTime() - ($retentionDays * 86400)))) ->setTimezone(new \DateTimeZone('UTC')); From b8faa0f7713124370871e4532dd0a8d099fd713b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:05:40 -0300 Subject: [PATCH 060/283] feat: add server settings routes --- appinfo/routes.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index ed8a43c..02743e1 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -7,6 +7,8 @@ ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], ['name' => 'schema#create', 'url' => '/api/v1/admin/schemas', 'verb' => 'POST'], ['name' => 'schema#get', 'url' => '/api/v1/admin/schemas/{application}/{schemaVersion}', 'verb' => 'GET'], + ['name' => 'settings#get', 'url' => '/api/v1/admin/settings', 'verb' => 'GET'], + ['name' => 'settings#update', 'url' => '/api/v1/admin/settings', 'verb' => 'PUT'], ['name' => 'statistics#summary', 'url' => '/api/v1/admin/{application}', 'verb' => 'GET'], ['name' => 'statistics#distribution', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/distribution', 'verb' => 'GET'], ['name' => 'statistics#numerical', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/numerical', 'verb' => 'GET'], From 72e368b5415b0f91b3cf1fc1160dda7a475f6c89 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:06:42 -0300 Subject: [PATCH 061/283] ci: test against MySQL --- .github/workflows/phpunit-mysql.yml | 94 +++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/workflows/phpunit-mysql.yml diff --git a/.github/workflows/phpunit-mysql.yml b/.github/workflows/phpunit-mysql.yml new file mode 100644 index 0000000..76ebd0c --- /dev/null +++ b/.github/workflows/phpunit-mysql.yml @@ -0,0 +1,94 @@ +# This workflow is based on the Nextcloud organization workflow template. +# +# https://github.com/nextcloud/.github +# SPDX-FileCopyrightText: 2022-2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: PHPUnit MySQL + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: phpunit-mysql-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.versions.outputs.sparse-matrix }} + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + with: + matrix: '{"mysql-versions": ["8.4"]}' + + phpunit-mysql: + runs-on: ubuntu-latest + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} + env: + APP_NAME: usage_statistics_server + name: MySQL ${{ matrix.mysql-versions }} PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }} + services: + mysql: + image: ghcr.io/nextcloud/continuous-integration-mysql-${{ matrix.mysql-versions }}:latest + ports: + - 4444:3306/tcp + env: + MYSQL_ROOT_PASSWORD: rootpassword + options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10 + steps: + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + - name: Set up PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ matrix.php-versions }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, mysql, pdo_mysql + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Enable ONLY_FULL_GROUP_BY + run: | + echo "SET GLOBAL sql_mode=(SELECT CONCAT(@@sql_mode,',ONLY_FULL_GROUP_BY'));" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword + - name: Install app dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=4444 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin + ./occ --version + ./occ app:enable --force ${{ env.APP_NAME }} + - name: Run PHPUnit suites + working-directory: apps/${{ env.APP_NAME }} + run: composer run test:php + - name: Print logs + if: always() + run: cat data/nextcloud.log || true From f4950b4cbffee9f8401dd96a6f03b2cb7cd4c638 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:06:53 -0300 Subject: [PATCH 062/283] ci: test against PostgreSQL --- .github/workflows/phpunit-pgsql.yml | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/phpunit-pgsql.yml diff --git a/.github/workflows/phpunit-pgsql.yml b/.github/workflows/phpunit-pgsql.yml new file mode 100644 index 0000000..5bee0ad --- /dev/null +++ b/.github/workflows/phpunit-pgsql.yml @@ -0,0 +1,91 @@ +# This workflow is based on the Nextcloud organization workflow template. +# +# https://github.com/nextcloud/.github +# SPDX-FileCopyrightText: 2022-2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: PHPUnit PostgreSQL + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: phpunit-pgsql-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.versions.outputs.sparse-matrix }} + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + + phpunit-pgsql: + runs-on: ubuntu-latest + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} + env: + APP_NAME: usage_statistics_server + name: PostgreSQL PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }} + services: + postgres: + image: ghcr.io/nextcloud/continuous-integration-postgres-16:latest + ports: + - 4444:5432/tcp + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: rootpassword + POSTGRES_DB: nextcloud + options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 + steps: + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + - name: Set up PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ matrix.php-versions }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install app dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 --database-port=4444 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin + ./occ --version + ./occ app:enable --force ${{ env.APP_NAME }} + - name: Run PHPUnit suites + working-directory: apps/${{ env.APP_NAME }} + run: composer run test:php + - name: Print logs + if: always() + run: cat data/nextcloud.log || true From c63f660d759c60907cc4e8e077bac79ac26cb647 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:07:25 -0300 Subject: [PATCH 063/283] docs: document historical data retention --- docs/retention.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/retention.md diff --git a/docs/retention.md b/docs/retention.md new file mode 100644 index 0000000..a7ce5a2 --- /dev/null +++ b/docs/retention.md @@ -0,0 +1,49 @@ +# Historical data retention + +The server preserves report history so usage trends can be recomputed and compared over time. Historical data is not retained indefinitely by default. + +## Default + +The default retention period is 1095 days (three years). + +A daily, time-insensitive Nextcloud background job removes data older than the configured retention period. + +Cleanup removes: + +- stale installation state whose last report is older than the cutoff; +- metrics belonging to expired reports; +- expired reports. + +Registered application schemas are preserved because they define how retained and externally referenced report versions are interpreted. + +## Limits + +Retention can be configured between 45 and 3650 days. + +The minimum matches the active-installation window. Allowing a shorter retention period would make the server forget installations that should still qualify as reporting within the last 45 days. + +## Administrative API + +Read the current value: + +```text +GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/settings +``` + +Update it: + +```text +PUT /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/settings +``` + +with `retentionDays` as an integer value. + +The setting is stored using Nextcloud `IAppConfig` as an integer. + +## Operational behavior + +Report deletion is performed in batches of 500 reports to avoid a single large cleanup transaction. Metrics are deleted before their parent reports in the same transaction. + +Retention is based on the server-side `received_at` timestamp, not on a client-provided timestamp. This prevents a client-controlled period value from extending server retention. + +The approach is inspired by the configurable cleanup in Nextcloud's `survey_server`, but this application uses a finite default because it intentionally preserves historical reports rather than replacing each installation's previous snapshot. From 474a002412c6162e7b4caee5e9db0cca6eff3bbb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:14:43 -0300 Subject: [PATCH 064/283] fix: normalize database datetime parameters --- lib/Db/ReportRepository.php | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index de96eeb..bed668b 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -10,6 +10,8 @@ use OCP\IDBConnection; final readonly class ReportRepository { + private const DB_DATETIME_FORMAT = 'Y-m-d H:i:s'; + public function __construct(private IDBConnection $db) { } @@ -30,9 +32,9 @@ public function store(Report $report, array $rawPayload): array { 'application' => $qb->createNamedParameter($report->application), 'installation_id' => $qb->createNamedParameter($report->installationId), 'schema_version' => $qb->createNamedParameter($report->schemaVersion, IQueryBuilder::PARAM_INT), - 'period_start' => $qb->createNamedParameter($report->periodStart, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), - 'period_end' => $qb->createNamedParameter($report->periodEnd, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), - 'received_at' => $qb->createNamedParameter($receivedAt, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + 'period_start' => $qb->createNamedParameter($this->formatDateTime($report->periodStart)), + 'period_end' => $qb->createNamedParameter($this->formatDateTime($report->periodEnd)), + 'received_at' => $qb->createNamedParameter($this->formatDateTime($receivedAt)), 'raw_payload' => $qb->createNamedParameter(json_encode($rawPayload, JSON_THROW_ON_ERROR)), ])->executeStatement(); $reportId = $qb->getLastInsertId(); @@ -60,23 +62,25 @@ public function store(Report $report, array $rawPayload): array { 'installation_id' => $report->installationId, ], [ - 'last_seen_at' => $receivedAt, + 'last_seen_at' => $this->formatDateTime($receivedAt), 'last_report_id' => $reportId, ], ); $this->db->commit(); return ['id' => $reportId, 'created' => true]; - } catch (Exception $e) { + } catch (\Throwable $e) { if ($this->db->inTransaction()) { $this->db->rollBack(); } - if (in_array($e->getReason(), [Exception::REASON_CONSTRAINT_VIOLATION, Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION], true)) { + + if ($e instanceof Exception && in_array($e->getReason(), [Exception::REASON_CONSTRAINT_VIOLATION, Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION], true)) { $id = $this->findId($report); if ($id !== null) { return ['id' => $id, 'created' => false]; } } + throw $e; } } @@ -87,9 +91,17 @@ private function findId(Report $report): ?int { ->where($qb->expr()->eq('application', $qb->createNamedParameter($report->application))) ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))) ->andWhere($qb->expr()->eq('schema_version', $qb->createNamedParameter($report->schemaVersion, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('period_start', $qb->createNamedParameter($report->periodStart, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) - ->andWhere($qb->expr()->eq('period_end', $qb->createNamedParameter($report->periodEnd, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) - ->executeQuery()->fetchOne(); + ->andWhere($qb->expr()->eq('period_start', $qb->createNamedParameter($this->formatDateTime($report->periodStart)))) + ->andWhere($qb->expr()->eq('period_end', $qb->createNamedParameter($this->formatDateTime($report->periodEnd)))) + ->executeQuery() + ->fetchOne(); + return $result === false ? null : (int)$result; } + + private function formatDateTime(\DateTimeImmutable $dateTime): string { + return $dateTime + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DB_DATETIME_FORMAT); + } } From 273e1b2fcde5d42d2f246d5eabb3b6fb390bc957 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:15:04 -0300 Subject: [PATCH 065/283] fix: normalize statistic datetime filters --- lib/Db/StatisticsRepository.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index 712a363..65dde0d 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -4,10 +4,11 @@ namespace OCA\UsageStatisticsServer\Db; -use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; final readonly class StatisticsRepository { + private const DB_DATETIME_FORMAT = 'Y-m-d H:i:s'; + public function __construct(private IDBConnection $db) { } @@ -16,7 +17,7 @@ public function countActiveInstallations(string $application, \DateTimeImmutable $result = $qb->select('COUNT(*) AS active_count') ->from('usage_stats_installations') ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) - ->andWhere($qb->expr()->gte('last_seen_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->andWhere($qb->expr()->gte('last_seen_at', $qb->createNamedParameter($this->formatDateTime($since)))) ->executeQuery() ->fetchOne(); @@ -100,8 +101,8 @@ public function numericalHistory( ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) ->andWhere($qb->expr()->isNotNull('m.numeric_value')) - ->andWhere($qb->expr()->gte('r.period_end', $qb->createNamedParameter($from, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) - ->andWhere($qb->expr()->lte('r.period_end', $qb->createNamedParameter($to, IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->andWhere($qb->expr()->gte('r.period_end', $qb->createNamedParameter($this->formatDateTime($from)))) + ->andWhere($qb->expr()->lte('r.period_end', $qb->createNamedParameter($this->formatDateTime($to)))) ->groupBy('r.period_start', 'r.period_end') ->orderBy('r.period_end', 'ASC') ->executeQuery(); @@ -122,4 +123,10 @@ public function numericalHistory( return $history; } + + private function formatDateTime(\DateTimeImmutable $dateTime): string { + return $dateTime + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DB_DATETIME_FORMAT); + } } From 24535c1c2c890cda5e97c9704f4122a4ecccdfcb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:15:17 -0300 Subject: [PATCH 066/283] fix: normalize retention datetime filters --- lib/Service/RetentionService.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/Service/RetentionService.php b/lib/Service/RetentionService.php index ab845c7..7a61153 100644 --- a/lib/Service/RetentionService.php +++ b/lib/Service/RetentionService.php @@ -9,6 +9,7 @@ final readonly class RetentionService { private const BATCH_SIZE = 500; + private const DB_DATETIME_FORMAT = 'Y-m-d H:i:s'; public function __construct(private IDBConnection $db) { } @@ -64,7 +65,7 @@ private function deleteStaleInstallations(\DateTimeImmutable $cutoff): int { return $qb->delete('usage_stats_installations') ->where($qb->expr()->lt( 'last_seen_at', - $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + $qb->createNamedParameter($this->formatDateTime($cutoff)), )) ->executeStatement(); } @@ -76,7 +77,7 @@ private function findOldReportIds(\DateTimeImmutable $cutoff): array { ->from('usage_stats_reports') ->where($qb->expr()->lt( 'received_at', - $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_IMMUTABLE), + $qb->createNamedParameter($this->formatDateTime($cutoff)), )) ->orderBy('id', 'ASC') ->setMaxResults(self::BATCH_SIZE) @@ -86,4 +87,10 @@ private function findOldReportIds(\DateTimeImmutable $cutoff): array { $result->closeCursor(); return $ids; } + + private function formatDateTime(\DateTimeImmutable $dateTime): string { + return $dateTime + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DB_DATETIME_FORMAT); + } } From c7b59ce7002306ce49138df978d8e826b9bbeeee Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:22:41 -0300 Subject: [PATCH 067/283] fix: build aggregations with DBAL functions --- lib/Db/StatisticsRepository.php | 69 +++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index 65dde0d..f87e8d4 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -14,7 +14,7 @@ public function __construct(private IDBConnection $db) { public function countActiveInstallations(string $application, \DateTimeImmutable $since): int { $qb = $this->db->getQueryBuilder(); - $result = $qb->select('COUNT(*) AS active_count') + $result = $qb->select($qb->func()->count('*')) ->from('usage_stats_installations') ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->gte('last_seen_at', $qb->createNamedParameter($this->formatDateTime($since)))) @@ -27,7 +27,8 @@ public function countActiveInstallations(string $application, \DateTimeImmutable /** @return list */ public function currentDistribution(string $application, string $category, string $key): array { $qb = $this->db->getQueryBuilder(); - $result = $qb->select('m.metric_value', 'COUNT(*) AS value_count') + $result = $qb + ->select('m.metric_value', $qb->func()->count('*', 'value_count')) ->from('usage_stats_installations', 'i') ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) @@ -38,13 +39,12 @@ public function currentDistribution(string $application, string $category, strin ->executeQuery(); $distribution = []; - while ($row = $result->fetch()) { + foreach ($result->iterateAssociative() as $row) { $distribution[] = [ 'value' => json_decode((string)$row['metric_value'], true, 512, JSON_THROW_ON_ERROR), 'count' => (int)$row['value_count'], ]; } - $result->closeCursor(); return $distribution; } @@ -53,11 +53,12 @@ public function currentDistribution(string $application, string $category, strin public function currentNumericalEvaluation(string $application, string $category, string $key): array { $qb = $this->db->getQueryBuilder(); $row = $qb - ->select('COUNT(m.numeric_value) AS value_count') - ->addSelect($qb->createFunction('AVG(m.numeric_value) AS average_value')) - ->addSelect($qb->createFunction('MIN(m.numeric_value) AS min_value')) - ->addSelect($qb->createFunction('MAX(m.numeric_value) AS max_value')) - ->addSelect($qb->createFunction('SUM(m.numeric_value) AS total_value')) + ->select( + $qb->func()->count('m.numeric_value'), + $qb->func()->sum('m.numeric_value'), + $qb->func()->min('m.numeric_value'), + $qb->func()->max('m.numeric_value'), + ) ->from('usage_stats_installations', 'i') ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) @@ -65,18 +66,22 @@ public function currentNumericalEvaluation(string $application, string $category ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) ->andWhere($qb->expr()->isNotNull('m.numeric_value')) ->executeQuery() - ->fetch(); + ->fetchNumeric(); if ($row === false) { return ['count' => 0, 'average' => null, 'min' => null, 'max' => null, 'total' => null]; } + [$countValue, $totalValue, $minValue, $maxValue] = $row; + $count = (int)$countValue; + $total = $totalValue === null ? null : (float)$totalValue; + return [ - 'count' => (int)$row['value_count'], - 'average' => $row['average_value'] === null ? null : round((float)$row['average_value'], 2), - 'min' => $row['min_value'] === null ? null : (float)$row['min_value'], - 'max' => $row['max_value'] === null ? null : (float)$row['max_value'], - 'total' => $row['total_value'] === null ? null : (float)$row['total_value'], + 'count' => $count, + 'average' => $count === 0 || $total === null ? null : round($total / $count, 2), + 'min' => $minValue === null ? null : (float)$minValue, + 'max' => $maxValue === null ? null : (float)$maxValue, + 'total' => $total, ]; } @@ -90,11 +95,14 @@ public function numericalHistory( ): array { $qb = $this->db->getQueryBuilder(); $result = $qb - ->select('r.period_start', 'r.period_end', 'COUNT(m.numeric_value) AS value_count') - ->addSelect($qb->createFunction('AVG(m.numeric_value) AS average_value')) - ->addSelect($qb->createFunction('MIN(m.numeric_value) AS min_value')) - ->addSelect($qb->createFunction('MAX(m.numeric_value) AS max_value')) - ->addSelect($qb->createFunction('SUM(m.numeric_value) AS total_value')) + ->select( + 'r.period_start', + 'r.period_end', + $qb->func()->count('m.numeric_value'), + $qb->func()->sum('m.numeric_value'), + $qb->func()->min('m.numeric_value'), + $qb->func()->max('m.numeric_value'), + ) ->from('usage_stats_reports', 'r') ->innerJoin('r', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'r.id')) ->where($qb->expr()->eq('r.application', $qb->createNamedParameter($application))) @@ -108,18 +116,21 @@ public function numericalHistory( ->executeQuery(); $history = []; - while ($row = $result->fetch()) { + foreach ($result->iterateNumeric() as $row) { + [$periodStart, $periodEnd, $countValue, $totalValue, $minValue, $maxValue] = $row; + $count = (int)$countValue; + $total = $totalValue === null ? null : (float)$totalValue; + $history[] = [ - 'periodStart' => (string)$row['period_start'], - 'periodEnd' => (string)$row['period_end'], - 'count' => (int)$row['value_count'], - 'average' => $row['average_value'] === null ? null : round((float)$row['average_value'], 2), - 'min' => $row['min_value'] === null ? null : (float)$row['min_value'], - 'max' => $row['max_value'] === null ? null : (float)$row['max_value'], - 'total' => $row['total_value'] === null ? null : (float)$row['total_value'], + 'periodStart' => (string)$periodStart, + 'periodEnd' => (string)$periodEnd, + 'count' => $count, + 'average' => $count === 0 || $total === null ? null : round($total / $count, 2), + 'min' => $minValue === null ? null : (float)$minValue, + 'max' => $maxValue === null ? null : (float)$maxValue, + 'total' => $total, ]; } - $result->closeCursor(); return $history; } From ca4b9670ab01cafd02bf08c931085df3ba0a3c12 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:23:42 -0300 Subject: [PATCH 068/283] test: use DBAL functions in retention assertions --- .../Service/RetentionServiceTest.php | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/php/Integration/Service/RetentionServiceTest.php b/tests/php/Integration/Service/RetentionServiceTest.php index 23c671c..a1de586 100644 --- a/tests/php/Integration/Service/RetentionServiceTest.php +++ b/tests/php/Integration/Service/RetentionServiceTest.php @@ -16,6 +16,8 @@ #[Group('DB')] final class RetentionServiceTest extends TestCase { + private const DB_DATETIME_FORMAT = 'Y-m-d H:i:s'; + private IDBConnection $db; private ReportRepository $reports; private SchemaRepository $schemas; @@ -84,10 +86,7 @@ private function store(string $installationId, string $start, string $end): int private function setReportReceivedAt(int $reportId, string $date): void { $qb = $this->db->getQueryBuilder(); $qb->update('usage_stats_reports') - ->set('received_at', $qb->createNamedParameter( - new \DateTimeImmutable($date), - IQueryBuilder::PARAM_DATETIME_IMMUTABLE, - )) + ->set('received_at', $qb->createNamedParameter($this->formatDateTime($date))) ->where($qb->expr()->eq('id', $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT))) ->executeStatement(); } @@ -95,17 +94,18 @@ private function setReportReceivedAt(int $reportId, string $date): void { private function setInstallationLastSeen(string $installationId, string $date): void { $qb = $this->db->getQueryBuilder(); $qb->update('usage_stats_installations') - ->set('last_seen_at', $qb->createNamedParameter( - new \DateTimeImmutable($date), - IQueryBuilder::PARAM_DATETIME_IMMUTABLE, - )) + ->set('last_seen_at', $qb->createNamedParameter($this->formatDateTime($date))) ->where($qb->expr()->eq('installation_id', $qb->createNamedParameter($installationId))) ->executeStatement(); } private function countRows(string $table): int { $qb = $this->db->getQueryBuilder(); - return (int)$qb->select('COUNT(*)')->from($table)->executeQuery()->fetchOne(); + return (int)$qb + ->select($qb->func()->count('*')) + ->from($table) + ->executeQuery() + ->fetchOne(); } private function clearTables(): void { @@ -114,4 +114,10 @@ private function clearTables(): void { $this->db->getQueryBuilder()->delete('usage_stats_reports')->executeStatement(); $this->db->getQueryBuilder()->delete('usage_stats_schemas')->executeStatement(); } + + private function formatDateTime(string $date): string { + return (new \DateTimeImmutable($date)) + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DB_DATETIME_FORMAT); + } } From 15755857d9d4bc29ebad416f00a72cd7ed8a2112 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:24:07 -0300 Subject: [PATCH 069/283] fix: normalize schema timestamps --- lib/Db/SchemaRepository.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php index 7383666..21bbab3 100644 --- a/lib/Db/SchemaRepository.php +++ b/lib/Db/SchemaRepository.php @@ -8,6 +8,8 @@ use OCP\IDBConnection; final readonly class SchemaRepository { + private const DB_DATETIME_FORMAT = 'Y-m-d H:i:s'; + public function __construct(private IDBConnection $db) { } @@ -47,12 +49,17 @@ public function store(string $application, int $schemaVersion, array $definition 'application' => $qb->createNamedParameter($application), 'schema_version' => $qb->createNamedParameter($schemaVersion, IQueryBuilder::PARAM_INT), 'definition' => $qb->createNamedParameter(json_encode($definition, JSON_THROW_ON_ERROR)), - 'created_at' => $qb->createNamedParameter( + 'created_at' => $qb->createNamedParameter($this->formatDateTime( new \DateTimeImmutable('now', new \DateTimeZone('UTC')), - IQueryBuilder::PARAM_DATETIME_IMMUTABLE, - ), + )), ])->executeStatement(); return true; } + + private function formatDateTime(\DateTimeImmutable $dateTime): string { + return $dateTime + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DB_DATETIME_FORMAT); + } } From 7a3055245919de4a4a400ceb474e5d6db13d3901 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:24:42 -0300 Subject: [PATCH 070/283] fix: let DBAL build count star expressions --- lib/Db/StatisticsRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index f87e8d4..5308f7d 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -14,7 +14,7 @@ public function __construct(private IDBConnection $db) { public function countActiveInstallations(string $application, \DateTimeImmutable $since): int { $qb = $this->db->getQueryBuilder(); - $result = $qb->select($qb->func()->count('*')) + $result = $qb->select($qb->func()->count()) ->from('usage_stats_installations') ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->gte('last_seen_at', $qb->createNamedParameter($this->formatDateTime($since)))) @@ -28,7 +28,7 @@ public function countActiveInstallations(string $application, \DateTimeImmutable public function currentDistribution(string $application, string $category, string $key): array { $qb = $this->db->getQueryBuilder(); $result = $qb - ->select('m.metric_value', $qb->func()->count('*', 'value_count')) + ->select('m.metric_value', $qb->func()->count('', 'value_count')) ->from('usage_stats_installations', 'i') ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) From 9f6c91e3831eeb152652275d99aedb428eb14408 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:25:00 -0300 Subject: [PATCH 071/283] test: let DBAL build count star expression --- tests/php/Integration/Service/RetentionServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Integration/Service/RetentionServiceTest.php b/tests/php/Integration/Service/RetentionServiceTest.php index a1de586..b4da031 100644 --- a/tests/php/Integration/Service/RetentionServiceTest.php +++ b/tests/php/Integration/Service/RetentionServiceTest.php @@ -102,7 +102,7 @@ private function setInstallationLastSeen(string $installationId, string $date): private function countRows(string $table): int { $qb = $this->db->getQueryBuilder(); return (int)$qb - ->select($qb->func()->count('*')) + ->select($qb->func()->count()) ->from($table) ->executeQuery() ->fetchOne(); From d51f877e489225222210dc55df8251198760c79a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:28:09 -0300 Subject: [PATCH 072/283] fix: make distribution ordering deterministic --- lib/Db/StatisticsRepository.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index 5308f7d..11088c9 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -36,6 +36,7 @@ public function currentDistribution(string $application, string $category, strin ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) ->groupBy('m.metric_value') ->orderBy('value_count', 'DESC') + ->addOrderBy('m.metric_value', 'ASC') ->executeQuery(); $distribution = []; From ea5ea573cc78d59f64e1b3b356a58fac75b60d78 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:33:20 -0300 Subject: [PATCH 073/283] fix: avoid setValues in report transaction --- lib/Db/ReportRepository.php | 43 +++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index bed668b..3513d1f 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -17,6 +17,11 @@ public function __construct(private IDBConnection $db) { /** @return array{id:int,created:bool} */ public function store(Report $report, array $rawPayload): array { + return $this->storeWithRetry($report, $rawPayload, true); + } + + /** @return array{id:int,created:bool} */ + private function storeWithRetry(Report $report, array $rawPayload, bool $allowRetry): array { $existing = $this->findId($report); if ($existing !== null) { return ['id' => $existing, 'created' => false]; @@ -55,17 +60,7 @@ public function store(Report $report, array $rawPayload): array { ])->executeStatement(); } - $this->db->setValues( - 'usage_stats_installations', - [ - 'application' => $report->application, - 'installation_id' => $report->installationId, - ], - [ - 'last_seen_at' => $this->formatDateTime($receivedAt), - 'last_report_id' => $reportId, - ], - ); + $this->updateInstallation($report, $reportId, $receivedAt); $this->db->commit(); return ['id' => $reportId, 'created' => true]; @@ -79,12 +74,38 @@ public function store(Report $report, array $rawPayload): array { if ($id !== null) { return ['id' => $id, 'created' => false]; } + + if ($allowRetry) { + return $this->storeWithRetry($report, $rawPayload, false); + } } throw $e; } } + private function updateInstallation(Report $report, int $reportId, \DateTimeImmutable $receivedAt): void { + $qb = $this->db->getQueryBuilder(); + $updated = $qb->update('usage_stats_installations') + ->set('last_seen_at', $qb->createNamedParameter($this->formatDateTime($receivedAt))) + ->set('last_report_id', $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->eq('application', $qb->createNamedParameter($report->application))) + ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))) + ->executeStatement(); + + if ($updated > 0) { + return; + } + + $insertQb = $this->db->getQueryBuilder(); + $insertQb->insert('usage_stats_installations')->values([ + 'application' => $insertQb->createNamedParameter($report->application), + 'installation_id' => $insertQb->createNamedParameter($report->installationId), + 'last_seen_at' => $insertQb->createNamedParameter($this->formatDateTime($receivedAt)), + 'last_report_id' => $insertQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), + ])->executeStatement(); + } + private function findId(Report $report): ?int { $qb = $this->db->getQueryBuilder(); $result = $qb->select('id')->from('usage_stats_reports') From 29854a5e757f0a09676f556fb503ac748f808aa2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:37:46 -0300 Subject: [PATCH 074/283] refactor: normalize report metric storage --- lib/Migration/Version010000Date20260908001000.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index 7880be3..be55112 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -48,9 +48,8 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $reports->addColumn('period_start', Types::DATETIME_IMMUTABLE); $reports->addColumn('period_end', Types::DATETIME_IMMUTABLE); $reports->addColumn('received_at', Types::DATETIME_IMMUTABLE); - $reports->addColumn('raw_payload', Types::TEXT); $reports->setPrimaryKey(['id']); - $reports->addUniqueIndex(['application', 'installation_id', 'schema_version', 'period_start', 'period_end'], 'usage_stats_report_identity'); + $reports->addUniqueIndex(['application', 'installation_id', 'period_start', 'period_end'], 'usage_stats_report_identity'); $reports->addIndex(['application', 'received_at'], 'usage_stats_recent_reports'); } @@ -61,8 +60,10 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $metrics->addColumn('category', Types::STRING, ['length' => 128]); $metrics->addColumn('metric_key', Types::STRING, ['length' => 512]); $metrics->addColumn('metric_type', Types::STRING, ['length' => 16]); - $metrics->addColumn('metric_value', Types::TEXT); - $metrics->addColumn('numeric_value', Types::FLOAT, ['notnull' => false]); + $metrics->addColumn('value_integer', Types::BIGINT, ['notnull' => false]); + $metrics->addColumn('value_number', Types::FLOAT, ['notnull' => false]); + $metrics->addColumn('value_boolean', Types::BOOLEAN, ['notnull' => false]); + $metrics->addColumn('value_string', Types::TEXT, ['notnull' => false]); $metrics->setPrimaryKey(['id']); $metrics->addUniqueIndex(['report_id', 'category', 'metric_key'], 'usage_stats_metric_identity'); $metrics->addIndex(['category', 'metric_key'], 'usage_stats_metric_lookup'); From 31b75317ad2bcdf6f64a37df0c5761409829b7be Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:38:20 -0300 Subject: [PATCH 075/283] feat: reject conflicting report periods --- lib/Service/ConflictingReport.php | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 lib/Service/ConflictingReport.php diff --git a/lib/Service/ConflictingReport.php b/lib/Service/ConflictingReport.php new file mode 100644 index 0000000..9e83c2a --- /dev/null +++ b/lib/Service/ConflictingReport.php @@ -0,0 +1,8 @@ + Date: Mon, 7 Sep 2026 22:38:35 -0300 Subject: [PATCH 076/283] refactor: store normalized report values --- lib/Db/ReportRepository.php | 91 +++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index 3513d1f..9db824c 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -4,6 +4,8 @@ namespace OCA\UsageStatisticsServer\Db; +use OCA\UsageStatisticsServer\Service\ConflictingReport; +use OCA\UsageStatisticsServer\Service\Metric; use OCA\UsageStatisticsServer\Service\Report; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -16,15 +18,16 @@ public function __construct(private IDBConnection $db) { } /** @return array{id:int,created:bool} */ - public function store(Report $report, array $rawPayload): array { - return $this->storeWithRetry($report, $rawPayload, true); + public function store(Report $report): array { + return $this->storeWithRetry($report, true); } /** @return array{id:int,created:bool} */ - private function storeWithRetry(Report $report, array $rawPayload, bool $allowRetry): array { - $existing = $this->findId($report); + private function storeWithRetry(Report $report, bool $allowRetry): array { + $existing = $this->findByLogicalIdentity($report); if ($existing !== null) { - return ['id' => $existing, 'created' => false]; + $this->assertSameSchema($report, $existing['schemaVersion']); + return ['id' => $existing['id'], 'created' => false]; } $receivedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); @@ -40,24 +43,11 @@ private function storeWithRetry(Report $report, array $rawPayload, bool $allowRe 'period_start' => $qb->createNamedParameter($this->formatDateTime($report->periodStart)), 'period_end' => $qb->createNamedParameter($this->formatDateTime($report->periodEnd)), 'received_at' => $qb->createNamedParameter($this->formatDateTime($receivedAt)), - 'raw_payload' => $qb->createNamedParameter(json_encode($rawPayload, JSON_THROW_ON_ERROR)), ])->executeStatement(); $reportId = $qb->getLastInsertId(); foreach ($report->metrics as $metric) { - $metricQb = $this->db->getQueryBuilder(); - $numericValue = in_array($metric->type, ['integer', 'number'], true) - ? (float)$metric->value - : null; - - $metricQb->insert('usage_stats_metrics')->values([ - 'report_id' => $metricQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), - 'category' => $metricQb->createNamedParameter($metric->category), - 'metric_key' => $metricQb->createNamedParameter($metric->key), - 'metric_type' => $metricQb->createNamedParameter($metric->type), - 'metric_value' => $metricQb->createNamedParameter(json_encode($metric->value, JSON_THROW_ON_ERROR)), - 'numeric_value' => $metricQb->createNamedParameter($numericValue), - ])->executeStatement(); + $this->insertMetric($reportId, $metric); } $this->updateInstallation($report, $reportId, $receivedAt); @@ -70,13 +60,14 @@ private function storeWithRetry(Report $report, array $rawPayload, bool $allowRe } if ($e instanceof Exception && in_array($e->getReason(), [Exception::REASON_CONSTRAINT_VIOLATION, Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION], true)) { - $id = $this->findId($report); - if ($id !== null) { - return ['id' => $id, 'created' => false]; + $existing = $this->findByLogicalIdentity($report); + if ($existing !== null) { + $this->assertSameSchema($report, $existing['schemaVersion']); + return ['id' => $existing['id'], 'created' => false]; } if ($allowRetry) { - return $this->storeWithRetry($report, $rawPayload, false); + return $this->storeWithRetry($report, false); } } @@ -84,6 +75,37 @@ private function storeWithRetry(Report $report, array $rawPayload, bool $allowRe } } + private function insertMetric(int $reportId, Metric $metric): void { + $qb = $this->db->getQueryBuilder(); + $values = [ + 'report_id' => $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), + 'category' => $qb->createNamedParameter($metric->category), + 'metric_key' => $qb->createNamedParameter($metric->key), + 'metric_type' => $qb->createNamedParameter($metric->type), + 'value_integer' => $qb->createNamedParameter(null), + 'value_number' => $qb->createNamedParameter(null), + 'value_boolean' => $qb->createNamedParameter(null), + 'value_string' => $qb->createNamedParameter(null), + ]; + + switch ($metric->type) { + case 'integer': + $values['value_integer'] = $qb->createNamedParameter((int)$metric->value, IQueryBuilder::PARAM_INT); + break; + case 'number': + $values['value_number'] = $qb->createNamedParameter((float)$metric->value); + break; + case 'boolean': + $values['value_boolean'] = $qb->createNamedParameter((bool)$metric->value, IQueryBuilder::PARAM_BOOL); + break; + case 'string': + $values['value_string'] = $qb->createNamedParameter((string)$metric->value); + break; + } + + $qb->insert('usage_stats_metrics')->values($values)->executeStatement(); + } + private function updateInstallation(Report $report, int $reportId, \DateTimeImmutable $receivedAt): void { $qb = $this->db->getQueryBuilder(); $updated = $qb->update('usage_stats_installations') @@ -106,18 +128,31 @@ private function updateInstallation(Report $report, int $reportId, \DateTimeImmu ])->executeStatement(); } - private function findId(Report $report): ?int { + /** @return array{id:int,schemaVersion:int}|null */ + private function findByLogicalIdentity(Report $report): ?array { $qb = $this->db->getQueryBuilder(); - $result = $qb->select('id')->from('usage_stats_reports') + $row = $qb->select('id', 'schema_version')->from('usage_stats_reports') ->where($qb->expr()->eq('application', $qb->createNamedParameter($report->application))) ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))) - ->andWhere($qb->expr()->eq('schema_version', $qb->createNamedParameter($report->schemaVersion, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('period_start', $qb->createNamedParameter($this->formatDateTime($report->periodStart)))) ->andWhere($qb->expr()->eq('period_end', $qb->createNamedParameter($this->formatDateTime($report->periodEnd)))) ->executeQuery() - ->fetchOne(); + ->fetchAssociative(); - return $result === false ? null : (int)$result; + if ($row === false) { + return null; + } + + return [ + 'id' => (int)$row['id'], + 'schemaVersion' => (int)$row['schema_version'], + ]; + } + + private function assertSameSchema(Report $report, int $existingSchemaVersion): void { + if ($existingSchemaVersion !== $report->schemaVersion) { + throw new ConflictingReport('A report for this installation and period already exists with a different schema version.'); + } } private function formatDateTime(\DateTimeImmutable $dateTime): string { From 515d8d7350ae098b8c677a3b674aa1555100f08e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:38:56 -0300 Subject: [PATCH 077/283] refactor: aggregate typed metric values --- lib/Db/StatisticsRepository.php | 161 +++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 33 deletions(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index 11088c9..fc88c39 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -28,88 +28,139 @@ public function countActiveInstallations(string $application, \DateTimeImmutable public function currentDistribution(string $application, string $category, string $key): array { $qb = $this->db->getQueryBuilder(); $result = $qb - ->select('m.metric_value', $qb->func()->count('', 'value_count')) + ->select( + 'm.metric_type', + 'm.value_integer', + 'm.value_number', + 'm.value_boolean', + 'm.value_string', + $qb->func()->count('', 'value_count'), + ) ->from('usage_stats_installations', 'i') ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) - ->groupBy('m.metric_value') + ->groupBy('m.metric_type', 'm.value_integer', 'm.value_number', 'm.value_boolean', 'm.value_string') ->orderBy('value_count', 'DESC') - ->addOrderBy('m.metric_value', 'ASC') ->executeQuery(); $distribution = []; foreach ($result->iterateAssociative() as $row) { $distribution[] = [ - 'value' => json_decode((string)$row['metric_value'], true, 512, JSON_THROW_ON_ERROR), + 'value' => $this->readTypedValue($row), 'count' => (int)$row['value_count'], ]; } + usort($distribution, static function (array $left, array $right): int { + $countComparison = $right['count'] <=> $left['count']; + if ($countComparison !== 0) { + return $countComparison; + } + + return (string)$left['value'] <=> (string)$right['value']; + }); + return $distribution; } /** @return array{count:int,average:float|null,min:float|null,max:float|null,total:float|null} */ public function currentNumericalEvaluation(string $application, string $category, string $key): array { + $integer = $this->currentNumericalEvaluationForType($application, $category, $key, 'integer', 'value_integer'); + $number = $this->currentNumericalEvaluationForType($application, $category, $key, 'number', 'value_number'); + + return $this->mergeNumericalEvaluations($integer, $number); + } + + /** @return list */ + public function numericalHistory( + string $application, + string $category, + string $key, + \DateTimeImmutable $from, + \DateTimeImmutable $to, + ): array { + $history = []; + + foreach ([['integer', 'value_integer'], ['number', 'value_number']] as [$type, $column]) { + foreach ($this->numericalHistoryForType($application, $category, $key, $from, $to, $type, $column) as $row) { + $identity = $row['periodStart'] . '|' . $row['periodEnd']; + $history[$identity] = isset($history[$identity]) + ? $this->mergeHistoricalRows($history[$identity], $row) + : $row; + } + } + + $rows = array_values($history); + usort($rows, static fn (array $left, array $right): int => $left['periodEnd'] <=> $right['periodEnd']); + + return $rows; + } + + /** @return array{count:int,total:float|null,min:float|null,max:float|null} */ + private function currentNumericalEvaluationForType( + string $application, + string $category, + string $key, + string $type, + string $column, + ): array { $qb = $this->db->getQueryBuilder(); $row = $qb ->select( - $qb->func()->count('m.numeric_value'), - $qb->func()->sum('m.numeric_value'), - $qb->func()->min('m.numeric_value'), - $qb->func()->max('m.numeric_value'), + $qb->func()->count('m.' . $column), + $qb->func()->sum('m.' . $column), + $qb->func()->min('m.' . $column), + $qb->func()->max('m.' . $column), ) ->from('usage_stats_installations', 'i') ->innerJoin('i', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'i.last_report_id')) ->where($qb->expr()->eq('i.application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) - ->andWhere($qb->expr()->isNotNull('m.numeric_value')) + ->andWhere($qb->expr()->eq('m.metric_type', $qb->createNamedParameter($type))) ->executeQuery() ->fetchNumeric(); if ($row === false) { - return ['count' => 0, 'average' => null, 'min' => null, 'max' => null, 'total' => null]; + return ['count' => 0, 'total' => null, 'min' => null, 'max' => null]; } - [$countValue, $totalValue, $minValue, $maxValue] = $row; - $count = (int)$countValue; - $total = $totalValue === null ? null : (float)$totalValue; - return [ - 'count' => $count, - 'average' => $count === 0 || $total === null ? null : round($total / $count, 2), - 'min' => $minValue === null ? null : (float)$minValue, - 'max' => $maxValue === null ? null : (float)$maxValue, - 'total' => $total, + 'count' => (int)$row[0], + 'total' => $row[1] === null ? null : (float)$row[1], + 'min' => $row[2] === null ? null : (float)$row[2], + 'max' => $row[3] === null ? null : (float)$row[3], ]; } /** @return list */ - public function numericalHistory( + private function numericalHistoryForType( string $application, string $category, string $key, \DateTimeImmutable $from, \DateTimeImmutable $to, + string $type, + string $column, ): array { $qb = $this->db->getQueryBuilder(); $result = $qb ->select( 'r.period_start', 'r.period_end', - $qb->func()->count('m.numeric_value'), - $qb->func()->sum('m.numeric_value'), - $qb->func()->min('m.numeric_value'), - $qb->func()->max('m.numeric_value'), + $qb->func()->count('m.' . $column), + $qb->func()->sum('m.' . $column), + $qb->func()->min('m.' . $column), + $qb->func()->max('m.' . $column), ) ->from('usage_stats_reports', 'r') ->innerJoin('r', 'usage_stats_metrics', 'm', $qb->expr()->eq('m.report_id', 'r.id')) ->where($qb->expr()->eq('r.application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->eq('m.category', $qb->createNamedParameter($category))) ->andWhere($qb->expr()->eq('m.metric_key', $qb->createNamedParameter($key))) - ->andWhere($qb->expr()->isNotNull('m.numeric_value')) + ->andWhere($qb->expr()->eq('m.metric_type', $qb->createNamedParameter($type))) ->andWhere($qb->expr()->gte('r.period_end', $qb->createNamedParameter($this->formatDateTime($from)))) ->andWhere($qb->expr()->lte('r.period_end', $qb->createNamedParameter($this->formatDateTime($to)))) ->groupBy('r.period_start', 'r.period_end') @@ -118,17 +169,15 @@ public function numericalHistory( $history = []; foreach ($result->iterateNumeric() as $row) { - [$periodStart, $periodEnd, $countValue, $totalValue, $minValue, $maxValue] = $row; - $count = (int)$countValue; - $total = $totalValue === null ? null : (float)$totalValue; - + $count = (int)$row[2]; + $total = $row[3] === null ? null : (float)$row[3]; $history[] = [ - 'periodStart' => (string)$periodStart, - 'periodEnd' => (string)$periodEnd, + 'periodStart' => (string)$row[0], + 'periodEnd' => (string)$row[1], 'count' => $count, 'average' => $count === 0 || $total === null ? null : round($total / $count, 2), - 'min' => $minValue === null ? null : (float)$minValue, - 'max' => $maxValue === null ? null : (float)$maxValue, + 'min' => $row[4] === null ? null : (float)$row[4], + 'max' => $row[5] === null ? null : (float)$row[5], 'total' => $total, ]; } @@ -136,6 +185,52 @@ public function numericalHistory( return $history; } + /** @param array{count:int,total:float|null,min:float|null,max:float|null} $left + * @param array{count:int,total:float|null,min:float|null,max:float|null} $right + * @return array{count:int,average:float|null,min:float|null,max:float|null,total:float|null} + */ + private function mergeNumericalEvaluations(array $left, array $right): array { + $count = $left['count'] + $right['count']; + $total = ($left['total'] ?? 0.0) + ($right['total'] ?? 0.0); + $hasValues = $count > 0; + + $mins = array_values(array_filter([$left['min'], $right['min']], static fn ($value): bool => $value !== null)); + $maxs = array_values(array_filter([$left['max'], $right['max']], static fn ($value): bool => $value !== null)); + + return [ + 'count' => $count, + 'average' => $hasValues ? round($total / $count, 2) : null, + 'min' => $mins === [] ? null : min($mins), + 'max' => $maxs === [] ? null : max($maxs), + 'total' => $hasValues ? $total : null, + ]; + } + + /** @param array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} $left + * @param array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} $right + * @return array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} + */ + private function mergeHistoricalRows(array $left, array $right): array { + $merged = $this->mergeNumericalEvaluations($left, $right); + + return [ + 'periodStart' => $left['periodStart'], + 'periodEnd' => $left['periodEnd'], + ...$merged, + ]; + } + + /** @param array $row */ + private function readTypedValue(array $row): mixed { + return match ($row['metric_type']) { + 'integer' => (int)$row['value_integer'], + 'number' => (float)$row['value_number'], + 'boolean' => (bool)$row['value_boolean'], + 'string' => (string)$row['value_string'], + default => null, + }; + } + private function formatDateTime(\DateTimeImmutable $dateTime): string { return $dateTime ->setTimezone(new \DateTimeZone('UTC')) From 55071b734e44cb102c6e4b915a53e70f3599fd6a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:39:10 -0300 Subject: [PATCH 078/283] refactor: simplify report response contract --- lib/Controller/ReportController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/Controller/ReportController.php b/lib/Controller/ReportController.php index 3021881..12e27d4 100644 --- a/lib/Controller/ReportController.php +++ b/lib/Controller/ReportController.php @@ -6,6 +6,7 @@ use OCA\UsageStatisticsServer\Db\ReportRepository; use OCA\UsageStatisticsServer\Db\SchemaRepository; +use OCA\UsageStatisticsServer\Service\ConflictingReport; use OCA\UsageStatisticsServer\Service\InvalidReport; use OCA\UsageStatisticsServer\Service\ReportFactory; use OCA\UsageStatisticsServer\Service\SchemaValidator; @@ -46,14 +47,13 @@ public function create(): DataResponse { } $this->schemaValidator->validateReport($report, $schema); - $stored = $this->repository->store($report, $payload); + $this->repository->store($report); } catch (InvalidReport|\JsonException $e) { return new DataResponse(['error' => 'invalid_report', 'message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (ConflictingReport $e) { + return new DataResponse(['error' => 'conflicting_report', 'message' => $e->getMessage()], Http::STATUS_CONFLICT); } - return new DataResponse([ - 'id' => $stored['id'], - 'status' => $stored['created'] ? 'created' : 'already_received', - ], $stored['created'] ? Http::STATUS_CREATED : Http::STATUS_OK); + return new DataResponse(['status' => 'accepted'], Http::STATUS_OK); } } From 15be89e81a3302744ba72b4ffc61603b5e28ab10 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:39:47 -0300 Subject: [PATCH 079/283] test: cover normalized report storage --- .../Db/StatisticsRepositoryTest.php | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/tests/php/Integration/Db/StatisticsRepositoryTest.php b/tests/php/Integration/Db/StatisticsRepositoryTest.php index 36582b7..edcf78d 100644 --- a/tests/php/Integration/Db/StatisticsRepositoryTest.php +++ b/tests/php/Integration/Db/StatisticsRepositoryTest.php @@ -6,6 +6,7 @@ use OCA\UsageStatisticsServer\Db\ReportRepository; use OCA\UsageStatisticsServer\Db\StatisticsRepository; +use OCA\UsageStatisticsServer\Service\ConflictingReport; use OCA\UsageStatisticsServer\Service\ReportFactory; use OCP\IDBConnection; use OCP\Server; @@ -74,6 +75,41 @@ public function testCurrentStateAndHistoricalNumericalAggregations(): void { self::assertSame(60.0, $history[1]['total']); } + public function testMetricValuesAreStoredInTheirTypedColumn(): void { + $this->store('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0', 20); + + $qb = $this->db->getQueryBuilder(); + $rows = $qb->select('metric_type', 'value_integer', 'value_number', 'value_boolean', 'value_string') + ->from('usage_stats_metrics') + ->orderBy('metric_type', 'ASC') + ->executeQuery() + ->fetchAllAssociative(); + + self::assertCount(2, $rows); + + $integer = array_values(array_filter($rows, static fn (array $row): bool => $row['metric_type'] === 'integer'))[0]; + self::assertSame(20, (int)$integer['value_integer']); + self::assertNull($integer['value_number']); + self::assertNull($integer['value_boolean']); + self::assertNull($integer['value_string']); + + $string = array_values(array_filter($rows, static fn (array $row): bool => $row['metric_type'] === 'string'))[0]; + self::assertSame('1.1.0', $string['value_string']); + self::assertNull($string['value_integer']); + self::assertNull($string['value_number']); + self::assertNull($string['value_boolean']); + } + + public function testSameLogicalPeriodWithDifferentSchemaIsRejected(): void { + $payload = $this->payload('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0', 20, 1); + $this->reports->store($this->factory->fromPayload($payload)); + + $conflicting = $this->payload('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0', 20, 2); + + $this->expectException(ConflictingReport::class); + $this->reports->store($this->factory->fromPayload($conflicting)); + } + private function store( string $installationId, string $start, @@ -81,11 +117,24 @@ private function store( string $version, int $requestsCompleted, ): void { - $payload = [ + $payload = $this->payload($installationId, $start, $end, $version, $requestsCompleted, 1); + $this->reports->store($this->factory->fromPayload($payload)); + } + + /** @return array */ + private function payload( + string $installationId, + string $start, + string $end, + string $version, + int $requestsCompleted, + int $schemaVersion, + ): array { + return [ 'protocolVersion' => 1, 'application' => 'libresign', 'installationId' => $installationId, - 'schemaVersion' => 1, + 'schemaVersion' => $schemaVersion, 'period' => [ 'start' => $start, 'end' => $end, @@ -105,8 +154,6 @@ private function store( ], ], ]; - - $this->reports->store($this->factory->fromPayload($payload), $payload); } private function truncateTables(): void { From a9c58abe9943d360afb1bc878f6a1f5bc0aa6a34 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:40:08 -0300 Subject: [PATCH 080/283] test: use normalized report repository contract --- tests/php/Integration/Service/RetentionServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Integration/Service/RetentionServiceTest.php b/tests/php/Integration/Service/RetentionServiceTest.php index b4da031..862d4a4 100644 --- a/tests/php/Integration/Service/RetentionServiceTest.php +++ b/tests/php/Integration/Service/RetentionServiceTest.php @@ -80,7 +80,7 @@ private function store(string $installationId, string $start, string $end): int ]], ]; - return $this->reports->store($this->factory->fromPayload($payload), $payload)['id']; + return $this->reports->store($this->factory->fromPayload($payload))['id']; } private function setReportReceivedAt(int $reportId, string $date): void { From 2bd5889f4b076a59225da243afcd2b98541f22d4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:40:41 -0300 Subject: [PATCH 081/283] docs: clarify report idempotency contract --- docs/protocol-v1.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 8045d8e..6c03b0b 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -18,6 +18,16 @@ POST /api/v1/reports The server SHOULD support idempotent resubmission of the same logical report. +A successful submission, including an idempotent retry, returns the same application-level response: + +```json +{ + "status": "accepted" +} +``` + +The protocol does not expose internal database identifiers and does not require the server to tell the client whether a successful submission created a row or matched an existing report. + ## Report Example: @@ -81,6 +91,8 @@ The server stores this value so historical reports remain interpretable after an The `usage_statistics_server` implementation requires the `(application, schemaVersion)` definition to be registered by an administrator before reports using it are accepted. Registered schema versions are immutable. +A schema version is report content, not part of report identity. Applications SHOULD activate a new schema at a reporting-period boundary. A second submission for the same installation and period using another schema version is a conflict, not another report. + ### `period` The reporting interval represented by period metrics. @@ -129,21 +141,26 @@ The `usage_statistics_server` application schema also declares the allowed aggre The logical identity of a report is: ```text -(application, installationId, period.start, period.end, schemaVersion) +(application, installationId, period.start, period.end) ``` A server MUST prevent accidental duplicate storage for the same logical report. +`schemaVersion` is intentionally excluded from this identity. Otherwise a schema transition during one period could create two reports and double-count that installation. + Protocol v1 does not require a preliminary handshake or server-issued report token. -A repeated submission MAY: +The `usage_statistics_server` implementation keeps the first accepted report immutable: + +- a retry using the same schema version is accepted idempotently; +- a retry for the same logical period using another schema version is rejected as a conflict; +- neither case creates a second logical report. -- replace the existing report for that logical identity; or -- be rejected as already received; +## Storage guidance -but it MUST NOT create a second logical report that would double-count statistics. +Servers SHOULD persist normalized validated fields rather than the arbitrary request body. -The `usage_statistics_server` implementation keeps the first accepted report immutable and returns `already_received` for an identical logical report submitted again. +The `usage_statistics_server` implementation does not retain the raw JSON payload. Metric values are stored in typed columns according to their declared type, with exactly one value column populated for each metric. ## Validation From ff4fc0d3dff41683595369ec17c790ab0fe683c9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:41:10 -0300 Subject: [PATCH 082/283] docs: describe normalized internal storage --- docs/admin-api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/admin-api.md b/docs/admin-api.md index 5f4b351..9a04ddc 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -4,7 +4,7 @@ The administrative API exposes aggregated usage statistics to authenticated Next All endpoints use the OCS namespace of the `usage_statistics_server` app. -The API intentionally exposes aggregates rather than individual installation reports. Raw reports remain internal so later privacy thresholds and abuse filtering can be applied without changing the public contract. +The API intentionally exposes aggregates rather than individual installation reports. Normalized reports remain internal so later privacy thresholds and abuse filtering can be applied without changing the public contract. The server does not retain the arbitrary raw JSON request body after validation. ## Summary @@ -113,9 +113,9 @@ This app differs intentionally in important areas: - historical reports are preserved instead of replacing the previous report from an installation; - latest installation state is materialized separately from report history; -- metrics are explicitly typed by the protocol; +- metrics are explicitly typed by the protocol and stored in typed columns; - application schemas are versioned; -- raw report values are not exposed through the administrative statistics API. +- individual report values are not exposed through the administrative statistics API. ## Interpretation From a6c6123bfc3cd9c3adbd0eacfd93ce3d3975e7bc Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:42:14 -0300 Subject: [PATCH 083/283] test: cover every metric storage type --- .../Db/StatisticsRepositoryTest.php | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/tests/php/Integration/Db/StatisticsRepositoryTest.php b/tests/php/Integration/Db/StatisticsRepositoryTest.php index edcf78d..ed04c5c 100644 --- a/tests/php/Integration/Db/StatisticsRepositoryTest.php +++ b/tests/php/Integration/Db/StatisticsRepositoryTest.php @@ -51,6 +51,10 @@ public function testCurrentStateAndHistoricalNumericalAggregations(): void { ['value' => '1.1.0', 'count' => 1], ], $this->statistics->currentDistribution('libresign', 'server', 'version')); + self::assertSame([ + ['value' => true, 'count' => 2], + ], $this->statistics->currentDistribution('libresign', 'features', 'enabled')); + self::assertSame([ 'count' => 2, 'average' => 30.0, @@ -59,6 +63,14 @@ public function testCurrentStateAndHistoricalNumericalAggregations(): void { 'total' => 60.0, ], $this->statistics->currentNumericalEvaluation('libresign', 'usage', 'requests_completed')); + self::assertSame([ + 'count' => 2, + 'average' => 1.5, + 'min' => 1.5, + 'max' => 1.5, + 'total' => 3.0, + ], $this->statistics->currentNumericalEvaluation('libresign', 'usage', 'average_size')); + $history = $this->statistics->numericalHistory( 'libresign', 'usage', @@ -85,15 +97,27 @@ public function testMetricValuesAreStoredInTheirTypedColumn(): void { ->executeQuery() ->fetchAllAssociative(); - self::assertCount(2, $rows); + self::assertCount(4, $rows); - $integer = array_values(array_filter($rows, static fn (array $row): bool => $row['metric_type'] === 'integer'))[0]; + $integer = $this->findMetricRow($rows, 'integer'); self::assertSame(20, (int)$integer['value_integer']); self::assertNull($integer['value_number']); self::assertNull($integer['value_boolean']); self::assertNull($integer['value_string']); - $string = array_values(array_filter($rows, static fn (array $row): bool => $row['metric_type'] === 'string'))[0]; + $number = $this->findMetricRow($rows, 'number'); + self::assertSame(1.5, (float)$number['value_number']); + self::assertNull($number['value_integer']); + self::assertNull($number['value_boolean']); + self::assertNull($number['value_string']); + + $boolean = $this->findMetricRow($rows, 'boolean'); + self::assertTrue($this->toBoolean($boolean['value_boolean'])); + self::assertNull($boolean['value_integer']); + self::assertNull($boolean['value_number']); + self::assertNull($boolean['value_string']); + + $string = $this->findMetricRow($rows, 'string'); self::assertSame('1.1.0', $string['value_string']); self::assertNull($string['value_integer']); self::assertNull($string['value_number']); @@ -110,6 +134,17 @@ public function testSameLogicalPeriodWithDifferentSchemaIsRejected(): void { $this->reports->store($this->factory->fromPayload($conflicting)); } + public function testSameLogicalReportIsIdempotent(): void { + $payload = $this->payload('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0', 20, 1); + $report = $this->factory->fromPayload($payload); + + self::assertTrue($this->reports->store($report)['created']); + self::assertFalse($this->reports->store($report)['created']); + + $qb = $this->db->getQueryBuilder(); + self::assertSame(1, (int)$qb->select($qb->func()->count())->from('usage_stats_reports')->executeQuery()->fetchOne()); + } + private function store( string $installationId, string $start, @@ -152,10 +187,37 @@ private function payload( 'type' => 'integer', 'value' => $requestsCompleted, ], + [ + 'category' => 'usage', + 'key' => 'average_size', + 'type' => 'number', + 'value' => 1.5, + ], + [ + 'category' => 'features', + 'key' => 'enabled', + 'type' => 'boolean', + 'value' => true, + ], ], ]; } + /** @param list> $rows + * @return array + */ + private function findMetricRow(array $rows, string $type): array { + return array_values(array_filter($rows, static fn (array $row): bool => $row['metric_type'] === $type))[0]; + } + + private function toBoolean(mixed $value): bool { + if (is_bool($value)) { + return $value; + } + + return in_array($value, [1, '1', 't', 'true'], true); + } + private function truncateTables(): void { $this->db->getQueryBuilder()->delete('usage_stats_metrics')->executeStatement(); $this->db->getQueryBuilder()->delete('usage_stats_installations')->executeStatement(); From 00a98bd40a53147a2a08ae4af76d1a7a49a0286b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:42:41 -0300 Subject: [PATCH 084/283] fix: normalize boolean result values --- lib/Db/StatisticsRepository.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index fc88c39..ca01eab 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -225,12 +225,20 @@ private function readTypedValue(array $row): mixed { return match ($row['metric_type']) { 'integer' => (int)$row['value_integer'], 'number' => (float)$row['value_number'], - 'boolean' => (bool)$row['value_boolean'], + 'boolean' => $this->readBoolean($row['value_boolean']), 'string' => (string)$row['value_string'], default => null, }; } + private function readBoolean(mixed $value): bool { + if (is_bool($value)) { + return $value; + } + + return in_array($value, [1, '1', 't', 'true'], true); + } + private function formatDateTime(\DateTimeImmutable $dateTime): string { return $dateTime ->setTimezone(new \DateTimeZone('UTC')) From bfe1d23d2221448e1cf043a206fbdad0e7f50d7a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:43:54 -0300 Subject: [PATCH 085/283] fix: strictly validate report payload shape --- lib/Service/ReportFactory.php | 64 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/lib/Service/ReportFactory.php b/lib/Service/ReportFactory.php index fb61b04..b610fb0 100644 --- a/lib/Service/ReportFactory.php +++ b/lib/Service/ReportFactory.php @@ -6,6 +6,7 @@ final class ReportFactory { private const IDENTIFIER_PATTERN = '/^[A-Za-z0-9_.:-]+$/'; + private const RFC3339_PATTERN = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:\d{2})$/D'; private const MAX_APPLICATION_LENGTH = 128; private const MAX_INSTALLATION_ID_LENGTH = 128; private const MAX_CATEGORY_LENGTH = 128; @@ -14,6 +15,11 @@ final class ReportFactory { private const MAX_METRICS = 256; public function fromPayload(array $payload): Report { + if (array_is_list($payload)) { + throw new InvalidReport('Request body must be a JSON object.'); + } + $this->assertAllowedKeys($payload, ['protocolVersion', 'application', 'installationId', 'schemaVersion', 'period', 'metrics'], 'report'); + if (($payload['protocolVersion'] ?? null) !== 1) { throw new InvalidReport('Unsupported protocol version.'); } @@ -26,32 +32,31 @@ public function fromPayload(array $payload): Report { } $period = $payload['period'] ?? null; - if (!is_array($period)) { + if (!is_array($period) || array_is_list($period)) { throw new InvalidReport('period is required.'); } + $this->assertAllowedKeys($period, ['start', 'end'], 'period'); - try { - $start = (new \DateTimeImmutable((string)($period['start'] ?? '')))->setTimezone(new \DateTimeZone('UTC')); - $end = (new \DateTimeImmutable((string)($period['end'] ?? '')))->setTimezone(new \DateTimeZone('UTC')); - } catch (\Exception) { - throw new InvalidReport('Invalid report period.'); - } + $start = $this->parseRfc3339($period['start'] ?? null); + $end = $this->parseRfc3339($period['end'] ?? null); if ($end <= $start || ($end->getTimestamp() - $start->getTimestamp()) > 2678400) { throw new InvalidReport('Invalid report period.'); } $rawMetrics = $payload['metrics'] ?? null; - if (!is_array($rawMetrics) || $rawMetrics === [] || count($rawMetrics) > self::MAX_METRICS) { + if (!is_array($rawMetrics) || !array_is_list($rawMetrics) || $rawMetrics === [] || count($rawMetrics) > self::MAX_METRICS) { throw new InvalidReport('metrics must be a non-empty bounded list.'); } $metrics = []; $seen = []; foreach ($rawMetrics as $rawMetric) { - if (!is_array($rawMetric)) { + if (!is_array($rawMetric) || array_is_list($rawMetric)) { throw new InvalidReport('Invalid metric.'); } + $this->assertAllowedKeys($rawMetric, ['category', 'key', 'type', 'value'], 'metric'); + $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', self::MAX_CATEGORY_LENGTH); $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', self::MAX_KEY_LENGTH); $type = $rawMetric['type'] ?? null; @@ -78,6 +83,47 @@ public function fromPayload(array $payload): Report { return new Report(1, $application, $installationId, $schemaVersion, $start, $end, $metrics); } + /** @param array $value + * @param list $allowed + */ + private function assertAllowedKeys(array $value, array $allowed, string $scope): void { + $unknown = array_diff(array_keys($value), $allowed); + if ($unknown !== []) { + throw new InvalidReport("Unknown {$scope} field."); + } + } + + private function parseRfc3339(mixed $value): \DateTimeImmutable { + if (!is_string($value) || preg_match(self::RFC3339_PATTERN, $value, $matches) !== 1) { + throw new InvalidReport('Invalid report period.'); + } + + $year = (int)$matches[1]; + $month = (int)$matches[2]; + $day = (int)$matches[3]; + $hour = (int)$matches[4]; + $minute = (int)$matches[5]; + $second = (int)$matches[6]; + $timezone = $matches[8]; + + if (!checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) { + throw new InvalidReport('Invalid report period.'); + } + + if ($timezone !== 'Z') { + [$timezoneHour, $timezoneMinute] = array_map('intval', explode(':', substr($timezone, 1))); + if ($timezoneHour > 23 || $timezoneMinute > 59) { + throw new InvalidReport('Invalid report period.'); + } + } + + try { + return (new \DateTimeImmutable($value))->setTimezone(new \DateTimeZone('UTC')); + } catch (\Exception) { + throw new InvalidReport('Invalid report period.'); + } + } + private function identifier(mixed $value, string $field, int $maxLength): string { if (!is_string($value) || $value === '' || strlen($value) > $maxLength || preg_match(self::IDENTIFIER_PATTERN, $value) !== 1) { throw new InvalidReport("Invalid {$field}."); From 248dcbec3103ca4592560d9b55dc6969dbe36fd2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:44:05 -0300 Subject: [PATCH 086/283] test: cover strict protocol validation --- tests/php/Unit/Service/ReportFactoryTest.php | 81 +++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/tests/php/Unit/Service/ReportFactoryTest.php b/tests/php/Unit/Service/ReportFactoryTest.php index 947f0db..39c1452 100644 --- a/tests/php/Unit/Service/ReportFactoryTest.php +++ b/tests/php/Unit/Service/ReportFactoryTest.php @@ -6,11 +6,69 @@ use OCA\UsageStatisticsServer\Service\InvalidReport; use OCA\UsageStatisticsServer\Service\ReportFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class ReportFactoryTest extends TestCase { public function testBuildsValidReportAndNormalizesPeriodToUtc(): void { - $report = (new ReportFactory())->fromPayload([ + $report = (new ReportFactory())->fromPayload($this->validPayload()); + + self::assertSame('libresign', $report->application); + self::assertSame('UTC', $report->periodStart->getTimezone()->getName()); + self::assertSame(72, $report->metrics[0]->value); + } + + public function testRejectsDuplicateMetric(): void { + $payload = $this->validPayload(); + $payload['metrics'][] = $payload['metrics'][0]; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + #[DataProvider('invalidTimestampProvider')] + public function testRejectsNonRfc3339OrInvalidTimestamp(string $timestamp): void { + $payload = $this->validPayload(); + $payload['period']['start'] = $timestamp; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + /** @return iterable */ + public static function invalidTimestampProvider(): iterable { + yield 'date only' => ['2026-08-01']; + yield 'space separator' => ['2026-08-01 00:00:00Z']; + yield 'missing timezone' => ['2026-08-01T00:00:00']; + yield 'invalid calendar date' => ['2026-02-30T00:00:00Z']; + yield 'invalid hour' => ['2026-08-01T25:00:00Z']; + yield 'invalid timezone' => ['2026-08-01T00:00:00+25:00']; + } + + #[DataProvider('unknownFieldProvider')] + public function testRejectsUnknownFields(string $scope): void { + $payload = $this->validPayload(); + + match ($scope) { + 'report' => $payload['unexpected'] = true, + 'period' => $payload['period']['unexpected'] = true, + 'metric' => $payload['metrics'][0]['unexpected'] = true, + }; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + /** @return iterable */ + public static function unknownFieldProvider(): iterable { + yield 'report' => ['report']; + yield 'period' => ['period']; + yield 'metric' => ['metric']; + } + + /** @return array */ + private function validPayload(): array { + return [ 'protocolVersion' => 1, 'application' => 'libresign', 'installationId' => str_repeat('a', 64), @@ -25,25 +83,6 @@ public function testBuildsValidReportAndNormalizesPeriodToUtc(): void { 'type' => 'integer', 'value' => 72, ]], - ]); - - self::assertSame('libresign', $report->application); - self::assertSame('UTC', $report->periodStart->getTimezone()->getName()); - self::assertSame(72, $report->metrics[0]->value); - } - - public function testRejectsDuplicateMetric(): void { - $this->expectException(InvalidReport::class); - (new ReportFactory())->fromPayload([ - 'protocolVersion' => 1, - 'application' => 'libresign', - 'installationId' => str_repeat('a', 64), - 'schemaVersion' => 1, - 'period' => ['start' => '2026-08-01T00:00:00Z', 'end' => '2026-09-01T00:00:00Z'], - 'metrics' => [ - ['category' => 'usage', 'key' => 'requests', 'type' => 'integer', 'value' => 1], - ['category' => 'usage', 'key' => 'requests', 'type' => 'integer', 'value' => 2], - ], - ]); + ]; } } From 9e1555e2e0a3e4ae6a7a54dd8f29c87c477f2a47 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:44:30 -0300 Subject: [PATCH 087/283] fix: strictly validate application schemas --- lib/Service/SchemaValidator.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/Service/SchemaValidator.php b/lib/Service/SchemaValidator.php index 8a7de22..8e3556f 100644 --- a/lib/Service/SchemaValidator.php +++ b/lib/Service/SchemaValidator.php @@ -12,6 +12,11 @@ final class SchemaValidator { /** @return array */ public function validateDefinition(array $definition): array { + if (array_is_list($definition)) { + throw new InvalidReport('Schema definition must be an object.'); + } + $this->assertAllowedKeys($definition, ['application', 'schemaVersion', 'metrics'], 'schema'); + $application = $this->identifier($definition['application'] ?? null, 'application', 128); $schemaVersion = $definition['schemaVersion'] ?? null; if (!is_int($schemaVersion) || $schemaVersion < 1) { @@ -19,16 +24,17 @@ public function validateDefinition(array $definition): array { } $rawMetrics = $definition['metrics'] ?? null; - if (!is_array($rawMetrics) || $rawMetrics === [] || count($rawMetrics) > 256) { + if (!is_array($rawMetrics) || !array_is_list($rawMetrics) || $rawMetrics === [] || count($rawMetrics) > 256) { throw new InvalidReport('Schema metrics must be a non-empty bounded list.'); } $metrics = []; $seen = []; foreach ($rawMetrics as $rawMetric) { - if (!is_array($rawMetric)) { + if (!is_array($rawMetric) || array_is_list($rawMetric)) { throw new InvalidReport('Invalid schema metric.'); } + $this->assertAllowedKeys($rawMetric, ['category', 'key', 'type', 'kind', 'aggregation', 'description', 'required'], 'schema metric'); $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', 128); $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', 512); @@ -117,6 +123,15 @@ public function validateReport(Report $report, array $definition): void { } } + /** @param array $value + * @param list $allowed + */ + private function assertAllowedKeys(array $value, array $allowed, string $scope): void { + if (array_diff(array_keys($value), $allowed) !== []) { + throw new InvalidReport("Unknown {$scope} field."); + } + } + private function identifier(mixed $value, string $field, int $maxLength): string { if (!is_string($value) || $value === '' || strlen($value) > $maxLength || preg_match(self::IDENTIFIER_PATTERN, $value) !== 1) { throw new InvalidReport("Invalid {$field}."); From 771ea5e6b6a8c9e5444296da441d9b1366ca4343 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:44:43 -0300 Subject: [PATCH 088/283] test: cover strict schema fields --- .../php/Unit/Service/SchemaValidatorTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/php/Unit/Service/SchemaValidatorTest.php b/tests/php/Unit/Service/SchemaValidatorTest.php index b0e3f0c..e2d5716 100644 --- a/tests/php/Unit/Service/SchemaValidatorTest.php +++ b/tests/php/Unit/Service/SchemaValidatorTest.php @@ -8,6 +8,7 @@ use OCA\UsageStatisticsServer\Service\Metric; use OCA\UsageStatisticsServer\Service\Report; use OCA\UsageStatisticsServer\Service\SchemaValidator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class SchemaValidatorTest extends TestCase { @@ -77,6 +78,26 @@ public function testRejectsNumericalAggregationForStringMetric(): void { $this->validator->validateDefinition($definition); } + #[DataProvider('unknownSchemaFieldProvider')] + public function testRejectsUnknownSchemaFields(string $scope): void { + $definition = $this->definition(); + + if ($scope === 'schema') { + $definition['unexpected'] = true; + } else { + $definition['metrics'][0]['unexpected'] = true; + } + + $this->expectException(InvalidReport::class); + $this->validator->validateDefinition($definition); + } + + /** @return iterable */ + public static function unknownSchemaFieldProvider(): iterable { + yield 'schema' => ['schema']; + yield 'metric' => ['metric']; + } + /** @return array */ private function definition(): array { return [ From 38a2617de1453e8e9fc716eb20e7446ad23087f3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:51:09 -0300 Subject: [PATCH 089/283] refactor: register report route with ApiRoute --- lib/Controller/ReportController.php | 45 +++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/lib/Controller/ReportController.php b/lib/Controller/ReportController.php index 12e27d4..43e37ea 100644 --- a/lib/Controller/ReportController.php +++ b/lib/Controller/ReportController.php @@ -12,12 +12,15 @@ use OCA\UsageStatisticsServer\Service\SchemaValidator; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; +#[OpenAPI(tags: ['reports'])] final class ReportController extends OCSController { public function __construct( string $appName, @@ -30,25 +33,49 @@ public function __construct( parent::__construct($appName, $request); } + /** + * Submit a usage statistics report + * + * Stores one validated report for one application installation and reporting period. + * Repeating an already accepted report with the same schema version is idempotent. + * + * @param int $protocolVersion Usage statistics protocol version + * @param string $application Stable application identifier + * @param string $installationId Stable pseudonymous installation identifier + * @param int $schemaVersion Registered application schema version + * @param array{start:string,end:string} $period RFC3339 reporting period, start inclusive and end exclusive + * @param list $metrics Aggregate metric values + * + * @return DataResponse|DataResponse + */ #[PublicPage] #[NoCSRFRequired] #[AnonRateLimit(limit: 60, period: 3600)] - public function create(): DataResponse { + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/reports', requirements: ['apiVersion' => '(v1)'])] + public function create( + int $protocolVersion, + string $application, + string $installationId, + int $schemaVersion, + array $period, + array $metrics, + ): DataResponse { try { - $payload = json_decode($this->request->getRawInput(), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($payload)) { - throw new InvalidReport('Request body must be a JSON object.'); - } - - $report = $this->factory->fromPayload($payload); + $report = $this->factory->fromPayload([ + 'protocolVersion' => $protocolVersion, + 'application' => $application, + 'installationId' => $installationId, + 'schemaVersion' => $schemaVersion, + 'period' => $period, + 'metrics' => $metrics, + ]); $schema = $this->schemas->find($report->application, $report->schemaVersion); if ($schema === null) { throw new InvalidReport('Application schema is not registered.'); } $this->schemaValidator->validateReport($report, $schema); - $this->repository->store($report); - } catch (InvalidReport|\JsonException $e) { + } catch (InvalidReport $e) { return new DataResponse(['error' => 'invalid_report', 'message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } catch (ConflictingReport $e) { return new DataResponse(['error' => 'conflicting_report', 'message' => $e->getMessage()], Http::STATUS_CONFLICT); From b222d4a873f10ef453f6ffc0d0ff74cb6ba368fb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:51:27 -0300 Subject: [PATCH 090/283] refactor: register schema routes with ApiRoute --- lib/Controller/SchemaController.php | 37 ++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/Controller/SchemaController.php b/lib/Controller/SchemaController.php index eb2fdcb..91fd4a8 100644 --- a/lib/Controller/SchemaController.php +++ b/lib/Controller/SchemaController.php @@ -8,10 +8,13 @@ use OCA\UsageStatisticsServer\Service\InvalidReport; use OCA\UsageStatisticsServer\Service\SchemaValidator; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['schemas'])] final class SchemaController extends OCSController { public function __construct( string $appName, @@ -22,20 +25,29 @@ public function __construct( parent::__construct($appName, $request); } - public function create(): DataResponse { + /** + * Register an application metric schema + * + * @param string $application Stable application identifier + * @param int $schemaVersion Immutable schema version + * @param list $metrics Metric definitions + * + * @return DataResponse|DataResponse + */ + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/schemas', requirements: ['apiVersion' => '(v1)'])] + public function create(string $application, int $schemaVersion, array $metrics): DataResponse { try { - $payload = json_decode($this->request->getRawInput(), true, 512, JSON_THROW_ON_ERROR); - if (!is_array($payload)) { - throw new InvalidReport('Request body must be a JSON object.'); - } - - $definition = $this->validator->validateDefinition($payload); + $definition = $this->validator->validateDefinition([ + 'application' => $application, + 'schemaVersion' => $schemaVersion, + 'metrics' => $metrics, + ]); $created = $this->schemas->store( $definition['application'], $definition['schemaVersion'], $definition, ); - } catch (InvalidReport|\JsonException $e) { + } catch (InvalidReport $e) { return new DataResponse([ 'error' => 'invalid_schema', 'message' => $e->getMessage(), @@ -54,6 +66,15 @@ public function create(): DataResponse { ], $created ? Http::STATUS_CREATED : Http::STATUS_OK); } + /** + * Get one registered application metric schema + * + * @param string $application Stable application identifier + * @param int $schemaVersion Schema version + * + * @return DataResponse, array{}>|DataResponse + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/schemas/{application}/{schemaVersion}', requirements: ['apiVersion' => '(v1)'])] public function get(string $application, int $schemaVersion): DataResponse { $definition = $this->schemas->find($application, $schemaVersion); if ($definition === null) { From 4090cc05eaaf3a7c79259824c5c32b1617ef233e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:51:36 -0300 Subject: [PATCH 091/283] refactor: register settings routes with ApiRoute --- lib/Controller/SettingsController.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index aead82e..444ec6e 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -6,10 +6,13 @@ use OCA\UsageStatisticsServer\Service\SettingsService; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])] final class SettingsController extends OCSController { public function __construct( string $appName, @@ -19,6 +22,12 @@ public function __construct( parent::__construct($appName, $request); } + /** + * Get usage statistics server settings + * + * @return DataResponse + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/settings', requirements: ['apiVersion' => '(v1)'])] public function get(): DataResponse { return new DataResponse([ 'retentionDays' => $this->settings->getRetentionDays(), @@ -27,6 +36,14 @@ public function get(): DataResponse { ]); } + /** + * Update usage statistics server settings + * + * @param int $retentionDays Number of days to retain reports + * + * @return DataResponse|DataResponse + */ + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/admin/settings', requirements: ['apiVersion' => '(v1)'])] public function update(int $retentionDays): DataResponse { try { $retentionDays = $this->settings->setRetentionDays($retentionDays); From b204d2ee840de869f02c189aa067884bbe5ad591 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:51:51 -0300 Subject: [PATCH 092/283] refactor: register statistics routes with ApiRoute --- lib/Controller/StatisticsController.php | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php index 6ef5a56..b86ddfe 100644 --- a/lib/Controller/StatisticsController.php +++ b/lib/Controller/StatisticsController.php @@ -6,10 +6,13 @@ use OCA\UsageStatisticsServer\Db\StatisticsRepository; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; +#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['statistics'])] final class StatisticsController extends OCSController { private const ACTIVE_WINDOW_DAYS = 45; @@ -21,6 +24,14 @@ public function __construct( parent::__construct($appName, $request); } + /** + * Get application usage statistics summary + * + * @param string $application Stable application identifier + * + * @return DataResponse + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}', requirements: ['apiVersion' => '(v1)'])] public function summary(string $application): DataResponse { $since = new \DateTimeImmutable( sprintf('-%d days', self::ACTIVE_WINDOW_DAYS), @@ -34,6 +45,16 @@ public function summary(string $application): DataResponse { ]); } + /** + * Get the current distribution for a metric + * + * @param string $application Stable application identifier + * @param string $category Metric category + * @param string $key Metric key + * + * @return DataResponse}, array{}> + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}/metrics/{category}/{key}/distribution', requirements: ['apiVersion' => '(v1)'])] public function distribution(string $application, string $category, string $key): DataResponse { return new DataResponse([ 'application' => $application, @@ -43,6 +64,16 @@ public function distribution(string $application, string $category, string $key) ]); } + /** + * Get the current numerical evaluation for a metric + * + * @param string $application Stable application identifier + * @param string $category Metric category + * @param string $key Metric key + * + * @return DataResponse + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}/metrics/{category}/{key}/numerical', requirements: ['apiVersion' => '(v1)'])] public function numerical(string $application, string $category, string $key): DataResponse { return new DataResponse([ 'application' => $application, @@ -52,6 +83,18 @@ public function numerical(string $application, string $category, string $key): D ]); } + /** + * Get historical numerical evaluation for a metric + * + * @param string $application Stable application identifier + * @param string $category Metric category + * @param string $key Metric key + * @param string $from Optional ISO 8601 lower bound + * @param string $to Optional ISO 8601 upper bound + * + * @return DataResponse}, array{}>|DataResponse + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}/metrics/{category}/{key}/numerical/history', requirements: ['apiVersion' => '(v1)'])] public function numericalHistory( string $application, string $category, From 9d20d808750af8476da813fef677819ec8b48c48 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:52:34 -0300 Subject: [PATCH 093/283] chore: add OpenAPI and Psalm tooling --- composer.json | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index fa30a88..5aaf79b 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "php": ">=8.3" }, "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", "nextcloud/ocp": "dev-master", "phpunit/phpunit": "^11.5", "roave/security-advisories": "dev-latest" @@ -23,11 +24,29 @@ } }, "scripts": { - "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", + "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './node_modules/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", + "openapi": "generate-spec --verbose && npm run typescript:generate", + "psalm": "psalm --no-cache --threads=$(nproc)", "test:unit": "phpunit -c tests/php/phpunit.xml --testsuite unit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", - "test:php": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations" + "test:php": "phpunit -c tests/php/phpunit.xml --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", + "post-install-cmd": [ + "@composer bin all install --ansi", + "composer dump-autoload -o" + ], + "post-update-cmd": [ + "composer dump-autoload" + ] + }, + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + } }, "config": { + "allow-plugins": { + "bamarni/composer-bin-plugin": true + }, "optimize-autoloader": true, "sort-packages": true, "platform": { From d9aebf86d660362d663a00d88e93e63eb165489a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:52:40 -0300 Subject: [PATCH 094/283] refactor: remove legacy route registration --- appinfo/routes.php | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 appinfo/routes.php diff --git a/appinfo/routes.php b/appinfo/routes.php deleted file mode 100644 index 02743e1..0000000 --- a/appinfo/routes.php +++ /dev/null @@ -1,17 +0,0 @@ - [ - ['name' => 'report#create', 'url' => '/api/v1/reports', 'verb' => 'POST'], - ['name' => 'schema#create', 'url' => '/api/v1/admin/schemas', 'verb' => 'POST'], - ['name' => 'schema#get', 'url' => '/api/v1/admin/schemas/{application}/{schemaVersion}', 'verb' => 'GET'], - ['name' => 'settings#get', 'url' => '/api/v1/admin/settings', 'verb' => 'GET'], - ['name' => 'settings#update', 'url' => '/api/v1/admin/settings', 'verb' => 'PUT'], - ['name' => 'statistics#summary', 'url' => '/api/v1/admin/{application}', 'verb' => 'GET'], - ['name' => 'statistics#distribution', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/distribution', 'verb' => 'GET'], - ['name' => 'statistics#numerical', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/numerical', 'verb' => 'GET'], - ['name' => 'statistics#numericalHistory', 'url' => '/api/v1/admin/{application}/metrics/{category}/{key}/numerical/history', 'verb' => 'GET'], - ], -]; From 3567cfb7dcb219f20f142fb076f83bfc376997d0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:53:36 -0300 Subject: [PATCH 095/283] chore: add OpenAPI extractor tooling --- vendor-bin/openapi-extractor/composer.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 vendor-bin/openapi-extractor/composer.json diff --git a/vendor-bin/openapi-extractor/composer.json b/vendor-bin/openapi-extractor/composer.json new file mode 100644 index 0000000..f0b78bb --- /dev/null +++ b/vendor-bin/openapi-extractor/composer.json @@ -0,0 +1,10 @@ +{ + "require-dev": { + "nextcloud/openapi-extractor": "dev-main" + }, + "config": { + "platform": { + "php": "8.3" + } + } +} From 8b083978d96792d6af7a46a71cebcbb00f15bad6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:53:40 -0300 Subject: [PATCH 096/283] chore: add Psalm tooling --- vendor-bin/psalm/composer.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 vendor-bin/psalm/composer.json diff --git a/vendor-bin/psalm/composer.json b/vendor-bin/psalm/composer.json new file mode 100644 index 0000000..c55768f --- /dev/null +++ b/vendor-bin/psalm/composer.json @@ -0,0 +1,11 @@ +{ + "config": { + "platform": { + "php": "8.3" + }, + "sort-packages": true + }, + "require-dev": { + "vimeo/psalm": "^6.16" + } +} From 1c1739e5be6b84322a188d5e013581dcf9425abe Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:53:47 -0300 Subject: [PATCH 097/283] chore: configure Psalm for OpenAPI --- psalm.xml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 psalm.xml diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..d4f31e7 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + From 88c1379f9d07ee6c3edb82098d20e3ef099c1552 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:53:53 -0300 Subject: [PATCH 098/283] chore: add TypeScript OpenAPI tooling --- package.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f0aaba --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "usage-statistics-server", + "version": "0.1.0", + "private": true, + "license": "AGPL-3.0-or-later", + "scripts": { + "lint": "eslint .", + "ts:check": "tsc --noEmit", + "typescript:generate": "openapi-typescript -t" + }, + "devDependencies": { + "@nextcloud/eslint-config": "^9.0.1", + "openapi-typescript": "^7.13.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": "^24.0.0", + "npm": "^11.3.0" + } +} From 911dfd0018b92cf5ea36fb6318b6fd7813a9e8ce Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:05 -0300 Subject: [PATCH 099/283] chore: configure TypeScript checks --- tsconfig.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tsconfig.json diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..736f992 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/types/**/*.ts" + ] +} From 31fab3e588030a7e5bf18f90af49d0abfbb407d3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:11 -0300 Subject: [PATCH 100/283] chore: configure JavaScript linting --- eslint.config.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 eslint.config.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b4082c5 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Vitor Mattos + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { recommended } from '@nextcloud/eslint-config' + +export default [ + ...recommended, + { + name: 'usage-statistics-server/ignores', + ignores: [ + 'build/*', + 'node_modules/*', + 'src/types/openapi/*', + 'openapi*.json', + ], + }, +] From 6992ef76b1fdb0671548a9e97ebc6137a1e87180 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:16 -0300 Subject: [PATCH 101/283] chore: configure OpenAPI TypeScript outputs --- redocly.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 redocly.yaml diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..fc1e48a --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later +apis: + openapi@v1: + root: ./openapi.json + x-openapi-ts: + output: ./src/types/openapi/openapi.ts + openapi-administration@v1: + root: ./openapi-administration.json + x-openapi-ts: + output: ./src/types/openapi/openapi-administration.ts + openapi-full@v1: + root: ./openapi-full.json + x-openapi-ts: + output: ./src/types/openapi/openapi-full.ts From 636c3e72ca761a99ae8606870a55a052a622c3d1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:25 -0300 Subject: [PATCH 102/283] ci: add OpenAPI contract validation --- .github/workflows/openapi.yml | 74 +++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/openapi.yml diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..b3dcdc5 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,74 @@ +# This workflow is based on the Nextcloud organization workflow template. +# +# https://github.com/nextcloud/.github +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: OpenAPI + +on: pull_request + +permissions: + contents: read + +concurrency: + group: openapi-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + openapi: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + + - name: Get php version + id: php_versions + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ steps.php_versions.outputs.php-available }} + extensions: xml + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: node_versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.node_versions.outputs.nodeVersion }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.node_versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.node_versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}' + + - name: Install JavaScript dependencies + run: npm install + + - name: Install Composer dependencies + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 + + - name: Regenerate OpenAPI + run: composer run openapi + + - name: Check generated OpenAPI and TypeScript files + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo 'Please run "composer run openapi" and commit the generated OpenAPI JSON and TypeScript files.' + git status + git --no-pager diff -- openapi.json openapi-administration.json openapi-full.json src/types/openapi + exit 1 + fi From ef50f4233b876a0f4d5802b2508fc1a731d02d44 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:32 -0300 Subject: [PATCH 103/283] ci: add ESLint workflow --- .github/workflows/lint-eslint.yml | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/lint-eslint.yml diff --git a/.github/workflows/lint-eslint.yml b/.github/workflows/lint-eslint.yml new file mode 100644 index 0000000..c72092d --- /dev/null +++ b/.github/workflows/lint-eslint.yml @@ -0,0 +1,38 @@ +# Based on the Nextcloud organization workflow template. +# SPDX-License-Identifier: MIT + +name: Lint eslint + +on: pull_request + +permissions: + contents: read + +concurrency: + group: lint-eslint-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Read package engines + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + - name: Install dependencies + run: npm install + - name: Lint + run: npm run lint From b9996d493b18fd9b488ab07d8bc37217a10e4cd2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:40 -0300 Subject: [PATCH 104/283] ci: add TypeScript workflow --- .github/workflows/lint-typescript.yml | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/lint-typescript.yml diff --git a/.github/workflows/lint-typescript.yml b/.github/workflows/lint-typescript.yml new file mode 100644 index 0000000..aaba594 --- /dev/null +++ b/.github/workflows/lint-typescript.yml @@ -0,0 +1,38 @@ +# Based on the Nextcloud organization workflow template. +# SPDX-License-Identifier: MIT + +name: Type checking + +on: pull_request + +permissions: + contents: read + +concurrency: + group: lint-typescript-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + typescript: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Read package engines + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + - name: Install dependencies + run: npm install + - name: Check types + run: npm run ts:check From ba8e4c9fe6202b194011ff1b879f27f84306a7b1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:54:54 -0300 Subject: [PATCH 105/283] ci: add Node workflow --- .github/workflows/node-test.yml | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/node-test.yml diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml new file mode 100644 index 0000000..075f455 --- /dev/null +++ b/.github/workflows/node-test.yml @@ -0,0 +1,40 @@ +# Based on the Nextcloud organization workflow template. +# SPDX-License-Identifier: MIT + +name: Node tests + +on: pull_request + +permissions: + contents: read + +concurrency: + group: node-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + node: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Read package engines + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + - name: Install dependencies + run: npm install + - name: Run Node checks + run: | + npm run lint + npm run ts:check From 622707896011aea1c92bb3e4cc53391f6ae73a8d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:55:05 -0300 Subject: [PATCH 106/283] ci: add Psalm workflow --- .github/workflows/psalm.yml | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/psalm.yml diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml new file mode 100644 index 0000000..91dd56a --- /dev/null +++ b/.github/workflows/psalm.yml @@ -0,0 +1,75 @@ +# Based on the Nextcloud organization workflow template. +# SPDX-License-Identifier: MIT + +name: Static analysis + +on: pull_request + +permissions: + contents: read + +concurrency: + group: psalm-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + static-analysis: + runs-on: ubuntu-latest + env: + APP_NAME: usage_statistics_server + + steps: + - name: Checkout app metadata + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ steps.versions.outputs.branches-max }} + + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Check minimum PHP version in psalm.xml + working-directory: apps/${{ env.APP_NAME }} + run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ steps.versions.outputs.php-available }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Remove nextcloud/ocp + working-directory: apps/${{ env.APP_NAME }} + run: composer remove nextcloud/ocp --dev --no-scripts + + - name: Install Composer dependencies + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 + with: + working-directory: apps/${{ env.APP_NAME }} + + - name: Install matching nextcloud/ocp + working-directory: apps/${{ env.APP_NAME }} + run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies + + - name: Run Psalm + working-directory: apps/${{ env.APP_NAME }} + run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github From f098fad0aae2d12d01b2ff2a403358ebcb76ab29 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:55:50 -0300 Subject: [PATCH 107/283] fix: align OpenAPI TypeScript peer dependency --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 6f0aaba..0326de7 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,11 @@ "ts:check": "tsc --noEmit", "typescript:generate": "openapi-typescript -t" }, + "overrides": { + "openapi-typescript": { + "typescript": "$typescript" + } + }, "devDependencies": { "@nextcloud/eslint-config": "^9.0.1", "openapi-typescript": "^7.13.0", From 01c8c84baf210f6941a443e13960edda5b7f2907 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:56:01 -0300 Subject: [PATCH 108/283] chore: initialize TypeScript sources --- src/types/index.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/types/index.ts diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..120cff8 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: 2026 Vitor Mattos +// SPDX-License-Identifier: AGPL-3.0-or-later + +export {} From 2678d51cf9f82c47dd41b241d170fef2960c6a19 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:56:18 -0300 Subject: [PATCH 109/283] fix: avoid generating npm lock in CI --- .github/workflows/openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index b3dcdc5..7b37f8a 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -56,7 +56,7 @@ jobs: run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}' - name: Install JavaScript dependencies - run: npm install + run: npm install --no-package-lock - name: Install Composer dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 From b2e33d648a39782779ddb4edae59fba5ed3435e9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:56:29 -0300 Subject: [PATCH 110/283] fix: avoid generating npm lock in eslint CI --- .github/workflows/lint-eslint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-eslint.yml b/.github/workflows/lint-eslint.yml index c72092d..c1735fd 100644 --- a/.github/workflows/lint-eslint.yml +++ b/.github/workflows/lint-eslint.yml @@ -33,6 +33,6 @@ jobs: - name: Set up npm run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' - name: Install dependencies - run: npm install + run: npm install --no-package-lock - name: Lint run: npm run lint From 07a156dbe10d7a2271ac0b141ef2ddf5696df651 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:56:37 -0300 Subject: [PATCH 111/283] fix: avoid generating npm lock in TypeScript CI --- .github/workflows/lint-typescript.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-typescript.yml b/.github/workflows/lint-typescript.yml index aaba594..ff92b22 100644 --- a/.github/workflows/lint-typescript.yml +++ b/.github/workflows/lint-typescript.yml @@ -33,6 +33,6 @@ jobs: - name: Set up npm run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' - name: Install dependencies - run: npm install + run: npm install --no-package-lock - name: Check types run: npm run ts:check From 8ffc884896a595b9a48564edf92f3020b7bd46c8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:56:50 -0300 Subject: [PATCH 112/283] fix: avoid generating npm lock in Node CI --- .github/workflows/node-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 075f455..6ef7910 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -33,7 +33,7 @@ jobs: - name: Set up npm run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' - name: Install dependencies - run: npm install + run: npm install --no-package-lock - name: Run Node checks run: | npm run lint From dbb6f35175952bebde4878232e18a7b344125cbf Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:57:42 -0300 Subject: [PATCH 113/283] ci: upload generated OpenAPI artifacts --- .github/workflows/openapi.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 7b37f8a..75a8442 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -64,6 +64,18 @@ jobs: - name: Regenerate OpenAPI run: composer run openapi + - name: Upload generated OpenAPI files + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: generated-openapi + if-no-files-found: ignore + path: | + openapi.json + openapi-administration.json + openapi-full.json + src/types/openapi/*.ts + - name: Check generated OpenAPI and TypeScript files run: | if [[ -n "$(git status --porcelain)" ]]; then From 0535089fc8300d1fd7b157a317ce7edffbbe807e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 22:59:34 -0300 Subject: [PATCH 114/283] fix: align Psalm platform version --- vendor-bin/psalm/composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor-bin/psalm/composer.json b/vendor-bin/psalm/composer.json index c55768f..9c813e6 100644 --- a/vendor-bin/psalm/composer.json +++ b/vendor-bin/psalm/composer.json @@ -1,11 +1,11 @@ { "config": { "platform": { - "php": "8.3" + "php": "8.3.30" }, "sort-packages": true }, "require-dev": { - "vimeo/psalm": "^6.16" + "vimeo/psalm": "6.16.1" } } From bf3ab2ff338dbbddf247cf602a7fb326ea98208a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:16:50 -0300 Subject: [PATCH 115/283] fix: satisfy Psalm override checks --- lib/AppInfo/Application.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2277964..46839e1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,6 +8,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use Override; final class Application extends App implements IBootstrap { public const APP_ID = 'usage_statistics_server'; @@ -16,9 +17,11 @@ public function __construct() { parent::__construct(self::APP_ID); } + #[Override] public function register(IRegistrationContext $context): void { } + #[Override] public function boot(IBootContext $context): void { } } From a5a6693f165606efdbb1e18166c70554d3b92287 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:17:09 -0300 Subject: [PATCH 116/283] fix: align Psalm DBAL handling with Talk --- psalm.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/psalm.xml b/psalm.xml index d4f31e7..39bb3d7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -21,5 +21,10 @@ + + + + + From 68b13e56342c0e55c0e5fb2674f2852a288aa1d8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:18:08 -0300 Subject: [PATCH 117/283] fix: mark migration override explicitly --- lib/Migration/Version010000Date20260908001000.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index be55112..86e7960 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -9,8 +9,10 @@ use OCP\DB\ISchemaWrapper; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; +use Override; final class Version010000Date20260908001000 extends SimpleMigrationStep { + #[Override] public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); From 90988f0e9514a536cc485590e0d3851a35d360b9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:18:32 -0300 Subject: [PATCH 118/283] fix: narrow numerical rows before merging --- lib/Db/StatisticsRepository.php | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/Db/StatisticsRepository.php b/lib/Db/StatisticsRepository.php index ca01eab..649279b 100644 --- a/lib/Db/StatisticsRepository.php +++ b/lib/Db/StatisticsRepository.php @@ -185,9 +185,10 @@ private function numericalHistoryForType( return $history; } - /** @param array{count:int,total:float|null,min:float|null,max:float|null} $left - * @param array{count:int,total:float|null,min:float|null,max:float|null} $right - * @return array{count:int,average:float|null,min:float|null,max:float|null,total:float|null} + /** + * @param array{count:int,total:float|null,min:float|null,max:float|null} $left + * @param array{count:int,total:float|null,min:float|null,max:float|null} $right + * @return array{count:int,average:float|null,min:float|null,max:float|null,total:float|null} */ private function mergeNumericalEvaluations(array $left, array $right): array { $count = $left['count'] + $right['count']; @@ -206,12 +207,26 @@ private function mergeNumericalEvaluations(array $left, array $right): array { ]; } - /** @param array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} $left - * @param array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} $right - * @return array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} + /** + * @param array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} $left + * @param array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} $right + * @return array{periodStart:string,periodEnd:string,count:int,average:float|null,min:float|null,max:float|null,total:float|null} */ private function mergeHistoricalRows(array $left, array $right): array { - $merged = $this->mergeNumericalEvaluations($left, $right); + $merged = $this->mergeNumericalEvaluations( + [ + 'count' => $left['count'], + 'total' => $left['total'], + 'min' => $left['min'], + 'max' => $left['max'], + ], + [ + 'count' => $right['count'], + 'total' => $right['total'], + 'min' => $right['min'], + 'max' => $right['max'], + ], + ); return [ 'periodStart' => $left['periodStart'], From 81b990bee6622cde78e096ed3e6c62ad4ca424c8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:18:48 -0300 Subject: [PATCH 119/283] ci: align Psalm workflow with Talk --- .github/workflows/psalm.yml | 50 +++++++++++++------------------------ 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 91dd56a..6a7eaaf 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -1,52 +1,41 @@ -# Based on the Nextcloud organization workflow template. +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors # SPDX-License-Identifier: MIT name: Static analysis on: pull_request -permissions: - contents: read - concurrency: group: psalm-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + jobs: static-analysis: runs-on: ubuntu-latest - env: - APP_NAME: usage_statistics_server + name: static-psalm-analysis steps: - - name: Checkout app metadata + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Get version matrix + - name: Get php version id: versions - uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 - - name: Checkout server - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - submodules: true - repository: nextcloud/server - ref: ${{ steps.versions.outputs.branches-max }} - - - name: Checkout app - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - path: apps/${{ env.APP_NAME }} - - - name: Check minimum PHP version in psalm.xml - working-directory: apps/${{ env.APP_NAME }} + - name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml - - name: Set up PHP + - name: Set up php${{ steps.versions.outputs.php-available }} uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: ${{ steps.versions.outputs.php-available }} @@ -58,18 +47,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Remove nextcloud/ocp - working-directory: apps/${{ env.APP_NAME }} run: composer remove nextcloud/ocp --dev --no-scripts - - name: Install Composer dependencies + - name: Install composer dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 - with: - working-directory: apps/${{ env.APP_NAME }} - - name: Install matching nextcloud/ocp - working-directory: apps/${{ env.APP_NAME }} + - name: Install nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies - name: Run Psalm - working-directory: apps/${{ env.APP_NAME }} run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github From 1e55e2b59aaf21e490eae8e507246fafeffcaaee Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:19:03 -0300 Subject: [PATCH 120/283] fix: document report API responses --- lib/Controller/ReportController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Controller/ReportController.php b/lib/Controller/ReportController.php index 43e37ea..b50c84f 100644 --- a/lib/Controller/ReportController.php +++ b/lib/Controller/ReportController.php @@ -47,6 +47,10 @@ public function __construct( * @param list $metrics Aggregate metric values * * @return DataResponse|DataResponse + * + * 200: Report accepted + * 400: Invalid report + * 409: Conflicting report for the reporting period */ #[PublicPage] #[NoCSRFRequired] From 72138b7f5d092203fc755564dfc1d61c9799f054 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:19:15 -0300 Subject: [PATCH 121/283] fix: document schema API responses --- lib/Controller/SchemaController.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/Controller/SchemaController.php b/lib/Controller/SchemaController.php index 91fd4a8..e11e710 100644 --- a/lib/Controller/SchemaController.php +++ b/lib/Controller/SchemaController.php @@ -33,6 +33,11 @@ public function __construct( * @param list $metrics Metric definitions * * @return DataResponse|DataResponse + * + * 200: Schema was already registered with the same definition + * 201: Schema registered + * 400: Invalid schema definition + * 409: Schema version already exists with another definition */ #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/schemas', requirements: ['apiVersion' => '(v1)'])] public function create(string $application, int $schemaVersion, array $metrics): DataResponse { @@ -73,6 +78,9 @@ public function create(string $application, int $schemaVersion, array $metrics): * @param int $schemaVersion Schema version * * @return DataResponse, array{}>|DataResponse + * + * 200: Registered schema definition + * 404: Schema not found */ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/schemas/{application}/{schemaVersion}', requirements: ['apiVersion' => '(v1)'])] public function get(string $application, int $schemaVersion): DataResponse { From af0c9127509eb37626e6d9d3e4cc4d81de68aa63 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:19:27 -0300 Subject: [PATCH 122/283] fix: document settings API responses --- lib/Controller/SettingsController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 444ec6e..28fd975 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -26,6 +26,8 @@ public function __construct( * Get usage statistics server settings * * @return DataResponse + * + * 200: Current server settings */ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/settings', requirements: ['apiVersion' => '(v1)'])] public function get(): DataResponse { @@ -42,6 +44,9 @@ public function get(): DataResponse { * @param int $retentionDays Number of days to retain reports * * @return DataResponse|DataResponse + * + * 200: Server settings updated + * 400: Invalid retention value */ #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/admin/settings', requirements: ['apiVersion' => '(v1)'])] public function update(int $retentionDays): DataResponse { From a6feed0a9cdb16008fe2aca3c0f40cd36c6c22f6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:19:44 -0300 Subject: [PATCH 123/283] fix: disambiguate statistics API routes --- lib/Controller/StatisticsController.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php index b86ddfe..1cfc9c6 100644 --- a/lib/Controller/StatisticsController.php +++ b/lib/Controller/StatisticsController.php @@ -30,8 +30,10 @@ public function __construct( * @param string $application Stable application identifier * * @return DataResponse + * + * 200: Application usage statistics summary */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}', requirements: ['apiVersion' => '(v1)'])] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}', requirements: ['apiVersion' => '(v1)'])] public function summary(string $application): DataResponse { $since = new \DateTimeImmutable( sprintf('-%d days', self::ACTIVE_WINDOW_DAYS), @@ -53,8 +55,10 @@ public function summary(string $application): DataResponse { * @param string $key Metric key * * @return DataResponse}, array{}> + * + * 200: Current metric distribution */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}/metrics/{category}/{key}/distribution', requirements: ['apiVersion' => '(v1)'])] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}/metrics/{category}/{key}/distribution', requirements: ['apiVersion' => '(v1)'])] public function distribution(string $application, string $category, string $key): DataResponse { return new DataResponse([ 'application' => $application, @@ -72,8 +76,10 @@ public function distribution(string $application, string $category, string $key) * @param string $key Metric key * * @return DataResponse + * + * 200: Current numerical metric evaluation */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}/metrics/{category}/{key}/numerical', requirements: ['apiVersion' => '(v1)'])] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}/metrics/{category}/{key}/numerical', requirements: ['apiVersion' => '(v1)'])] public function numerical(string $application, string $category, string $key): DataResponse { return new DataResponse([ 'application' => $application, @@ -93,8 +99,11 @@ public function numerical(string $application, string $category, string $key): D * @param string $to Optional ISO 8601 upper bound * * @return DataResponse}, array{}>|DataResponse + * + * 200: Historical numerical metric evaluation + * 400: Invalid date range */ - #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/{application}/metrics/{category}/{key}/numerical/history', requirements: ['apiVersion' => '(v1)'])] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}/metrics/{category}/{key}/numerical/history', requirements: ['apiVersion' => '(v1)'])] public function numericalHistory( string $application, string $category, From 70785ba1d3891abe13f7470eb940ee7046298342 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:20:04 -0300 Subject: [PATCH 124/283] docs: update statistics API routes --- docs/admin-api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/admin-api.md b/docs/admin-api.md index 9a04ddc..6aee35f 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -8,7 +8,7 @@ The API intentionally exposes aggregates rather than individual installation rep ## Summary -`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}` +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/applications/{application}` Returns the number of installations that submitted a valid report during the active window. @@ -28,7 +28,7 @@ Example response data: ## Current distribution -`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/distribution` +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/applications/{application}/metrics/{category}/{key}/distribution` Returns a categorical distribution using only the latest report for every known installation. @@ -50,7 +50,7 @@ This endpoint represents current state. Older reports from the same installation ## Current numerical evaluation -`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/numerical` +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/applications/{application}/metrics/{category}/{key}/numerical` Returns the standard numerical evaluation for a numeric metric using only the latest report from each installation. @@ -73,7 +73,7 @@ The shape follows the same useful aggregation set used by Nextcloud's `survey_se ## Numerical history -`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/{application}/metrics/{category}/{key}/numerical/history` +`GET /ocs/v2.php/apps/usage_statistics_server/api/v1/admin/applications/{application}/metrics/{category}/{key}/numerical/history` Optional query parameters: From 01a8f7bc50ff873477e32bc9cf0fb4edb90cff4c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:22:36 -0300 Subject: [PATCH 125/283] ci: validate OpenAPI generation without tracked baseline --- .github/workflows/openapi.yml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 75a8442..d8f2c92 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -61,26 +61,15 @@ jobs: - name: Install Composer dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 - - name: Regenerate OpenAPI + - name: Generate OpenAPI and TypeScript types run: composer run openapi - name: Upload generated OpenAPI files - if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: generated-openapi - if-no-files-found: ignore path: | openapi.json openapi-administration.json openapi-full.json src/types/openapi/*.ts - - - name: Check generated OpenAPI and TypeScript files - run: | - if [[ -n "$(git status --porcelain)" ]]; then - echo 'Please run "composer run openapi" and commit the generated OpenAPI JSON and TypeScript files.' - git status - git --no-pager diff -- openapi.json openapi-administration.json openapi-full.json src/types/openapi - exit 1 - fi From 01f918f2ac4536dee88a55857fa0f97b1b8c1380 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:30:52 -0300 Subject: [PATCH 126/283] test: add Behat integration dependencies --- tests/integration/composer.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/integration/composer.json diff --git a/tests/integration/composer.json b/tests/integration/composer.json new file mode 100644 index 0000000..cc2c431 --- /dev/null +++ b/tests/integration/composer.json @@ -0,0 +1,21 @@ +{ + "require": { + "behat/behat": "^3.13", + "guzzlehttp/guzzle": "^7.10", + "jarnaiz/behat-junit-formatter": "^1.3", + "libresign/nextcloud-behat": "^1.6", + "php-http/guzzle7-adapter": "^1.1", + "php-http/message": "^1.16" + }, + "scripts": { + "behat": "vendor/bin/behat -f junit -f pretty --colors" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + }, + "platform": { + "php": "8.3" + } + } +} From 9f2278c56aa1b9b2e5981ca1a442a0428ed5ae47 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:31:04 -0300 Subject: [PATCH 127/283] test: configure Behat integration suite --- tests/integration/config/behat.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/integration/config/behat.yml diff --git a/tests/integration/config/behat.yml b/tests/integration/config/behat.yml new file mode 100644 index 0000000..c96d31b --- /dev/null +++ b/tests/integration/config/behat.yml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later + +default: + autoload: + '': '%paths.base%/../features/bootstrap' + suites: + default: + contexts: + - FeatureContext + paths: + - '%paths.base%/../features' + + extensions: + PhpBuiltin\Server: + workers: 10 + jarnaiz\JUnitFormatter\JUnitFormatterExtension: + filename: report.xml + outputDir: '%paths.base%/../output/' From bf612645fbe91156e6c42a270aaa77def5a26676 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:31:12 -0300 Subject: [PATCH 128/283] test: add Behat Nextcloud context --- .../features/bootstrap/FeatureContext.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/integration/features/bootstrap/FeatureContext.php diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..522ca74 --- /dev/null +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -0,0 +1,23 @@ + Date: Mon, 7 Sep 2026 23:31:43 -0300 Subject: [PATCH 129/283] test: support anonymous Behat requests --- tests/integration/features/bootstrap/FeatureContext.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 522ca74..e9f46a6 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -8,6 +8,7 @@ */ use Behat\Hook\BeforeSuite; +use Behat\Step\Given; use Behat\Testwork\Hook\Scope\BeforeSuiteScope; use Libresign\NextcloudBehat\NextcloudApiContext; @@ -20,4 +21,9 @@ public static function beforeSuite(BeforeSuiteScope $scope): void { self::runCommand('config:system:set ratelimit.protection.enabled --value false --type boolean'); self::runCommand('app:enable --force usage_statistics_server'); } + + #[Given('as anonymous user')] + public function asAnonymousUser(): void { + $this->setCurrentUser(''); + } } From ff43ae0b38a8ac2a85fccbb9e356fcaa201fd4b4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:32:07 -0300 Subject: [PATCH 130/283] test: cover usage statistics OCS API with Behat --- .../features/api/usage_statistics.feature | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/integration/features/api/usage_statistics.feature diff --git a/tests/integration/features/api/usage_statistics.feature b/tests/integration/features/api/usage_statistics.feature new file mode 100644 index 0000000..e49eeaa --- /dev/null +++ b/tests/integration/features/api/usage_statistics.feature @@ -0,0 +1,190 @@ +Feature: usage statistics OCS API + + Scenario: administrator registers and reads an application schema + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.application | behat_schema | + | (jq).ocs.data.schemaVersion | 1 | + | (jq).ocs.data.status | created | + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas/behat_schema/1" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.application | behat_schema | + | (jq).ocs.data.schemaVersion | 1 | + + Scenario: registering the same schema is idempotent + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema_idempotent | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema_idempotent | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.status | already_registered | + + Scenario: administrator cannot replace an immutable schema version + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema_conflict | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema_conflict | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Changed meaning","required":true}] | + Then the response should have a status code 409 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.error | schema_conflict | + + Scenario: anonymous report submission is accepted and retry is idempotent + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_report | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true},{"category":"usage","key":"requests_completed","type":"integer","kind":"period","aggregation":"numerical","description":"Completed requests","required":true}] | + Then the response should have a status code 201 + Given as anonymous user + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/reports" + | protocolVersion | 1 | + | application | behat_report | + | installationId | install-behat-report | + | schemaVersion | 1 | + | period | {"start":"2026-08-01T00:00:00Z","end":"2026-09-01T00:00:00Z"} | + | metrics | [{"category":"server","key":"version","type":"string","value":"1.0.0"},{"category":"usage","key":"requests_completed","type":"integer","value":12}] | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.status | accepted | + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/reports" + | protocolVersion | 1 | + | application | behat_report | + | installationId | install-behat-report | + | schemaVersion | 1 | + | period | {"start":"2026-08-01T00:00:00Z","end":"2026-09-01T00:00:00Z"} | + | metrics | [{"category":"server","key":"version","type":"string","value":"1.0.0"},{"category":"usage","key":"requests_completed","type":"integer","value":12}] | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.status | accepted | + + Scenario: report with an unknown metric is rejected + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_unknown_metric | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + Given as anonymous user + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/reports" + | protocolVersion | 1 | + | application | behat_unknown_metric | + | installationId | install-unknown-metric | + | schemaVersion | 1 | + | period | {"start":"2026-08-01T00:00:00Z","end":"2026-09-01T00:00:00Z"} | + | metrics | [{"category":"server","key":"version","type":"string","value":"1.0.0"},{"category":"usage","key":"unexpected","type":"integer","value":1}] | + Then the response should have a status code 400 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.error | invalid_report | + + Scenario: changing schema version for the same installation and reporting period conflicts + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_period_conflict | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_period_conflict | + | schemaVersion | 2 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + Given as anonymous user + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/reports" + | protocolVersion | 1 | + | application | behat_period_conflict | + | installationId | install-period-conflict | + | schemaVersion | 1 | + | period | {"start":"2026-08-01T00:00:00Z","end":"2026-09-01T00:00:00Z"} | + | metrics | [{"category":"server","key":"version","type":"string","value":"1.0.0"}] | + Then the response should have a status code 200 + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/reports" + | protocolVersion | 1 | + | application | behat_period_conflict | + | installationId | install-period-conflict | + | schemaVersion | 2 | + | period | {"start":"2026-08-01T00:00:00Z","end":"2026-09-01T00:00:00Z"} | + | metrics | [{"category":"server","key":"version","type":"string","value":"1.0.0"}] | + Then the response should have a status code 409 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.error | conflicting_report | + + Scenario: administrator can read current aggregates + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_statistics | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true},{"category":"usage","key":"requests_completed","type":"integer","kind":"period","aggregation":"numerical","description":"Completed requests","required":true}] | + Then the response should have a status code 201 + Given as anonymous user + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/reports" + | protocolVersion | 1 | + | application | behat_statistics | + | installationId | install-behat-statistics | + | schemaVersion | 1 | + | period | {"start":"2026-08-15T00:00:00Z","end":"2026-09-01T00:00:00Z"} | + | metrics | [{"category":"server","key":"version","type":"string","value":"1.2.3"},{"category":"usage","key":"requests_completed","type":"integer","value":7}] | + Then the response should have a status code 200 + Given as user "admin" + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_statistics" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.application | behat_statistics | + | (jq).ocs.data.activeInstallations | 1 | + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_statistics/metrics/server/version/distribution" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.values[0].value | 1.2.3 | + | (jq).ocs.data.values[0].count | 1 | + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_statistics/metrics/usage/requests_completed/numerical" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.statistics.count | 1 | + | (jq).ocs.data.statistics.average | 7 | + | (jq).ocs.data.statistics.total | 7 | + + Scenario: non-admin user cannot access administration endpoints + Given user "behat-user" exists + And as user "behat-user" + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/settings" + Then the response should have a status code 403 + + Scenario: administrator can read and update retention settings + Given as user "admin" + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/settings" + Then the response should have a status code 200 + When sending "put" to ocs "/apps/usage_statistics_server/api/v1/admin/settings" + | retentionDays | 365 | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.retentionDays | 365 | From d8f3ce4a3a3840468a1bee9c8b4ac3764496a667 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:32:29 -0300 Subject: [PATCH 131/283] ci: run Behat integration tests --- .github/workflows/behat-sqlite.yml | 148 +++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 .github/workflows/behat-sqlite.yml diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml new file mode 100644 index 0000000..52dee5e --- /dev/null +++ b/.github/workflows/behat-sqlite.yml @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Behat SQLite + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: behat-sqlite-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.versions.outputs.sparse-matrix }} + + steps: + - name: Checkout app metadata + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + src: ${{ steps.changes.outputs.src }} + + steps: + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/behat*' + - 'appinfo/**' + - 'lib/**' + - 'tests/integration/**' + - 'composer.json' + - 'composer.lock' + + behat-sqlite: + runs-on: ubuntu-latest + needs: [changes, matrix] + if: needs.changes.outputs.src != 'false' + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} + + env: + APP_NAME: usage_statistics_server + + name: SQLite PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }} + + steps: + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Set up PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ matrix.php-versions }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install --no-dev + composer --working-dir=tests/integration install + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install \ + --verbose \ + --database=sqlite \ + --database-name=nextcloud \ + --admin-user admin \ + --admin-pass admin + ./occ --version + ./occ app:enable --force ${{ env.APP_NAME }} + ./occ config:system:set auth.bruteforce.protection.enabled --value false --type boolean + ./occ config:system:set ratelimit.protection.enabled --value false --type boolean + ./occ config:system:set debug --value true --type boolean + + - name: Run Behat + working-directory: apps/${{ env.APP_NAME }}/tests/integration + env: + BEHAT_ROOT_DIR: ../../../../ + BEHAT_RUN_AS: runner + BEHAT_VERBOSE: ${{ runner.debug }} + run: composer run behat + + - name: Upload Behat results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: behat-results-${{ matrix.server-versions }}-php${{ matrix.php-versions }} + path: apps/${{ env.APP_NAME }}/tests/integration/output/ + if-no-files-found: ignore + retention-days: 30 + + - name: Print logs + if: always() + run: cat data/nextcloud.log 2>/dev/null || true + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: [changes, behat-sqlite] + if: always() + name: behat-sqlite-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.behat-sqlite.result != 'success' }}; then exit 1; fi From 115f92366dbc05b33d66120233942a3c7548a801 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:33:35 -0300 Subject: [PATCH 132/283] fix: require RFC3339 statistic ranges --- lib/Controller/StatisticsController.php | 29 ++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php index 1cfc9c6..eb98756 100644 --- a/lib/Controller/StatisticsController.php +++ b/lib/Controller/StatisticsController.php @@ -15,6 +15,7 @@ #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['statistics'])] final class StatisticsController extends OCSController { private const ACTIVE_WINDOW_DAYS = 45; + private const RFC3339_PATTERN = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:\d{2})$/D'; public function __construct( string $appName, @@ -95,8 +96,8 @@ public function numerical(string $application, string $category, string $key): D * @param string $application Stable application identifier * @param string $category Metric category * @param string $key Metric key - * @param string $from Optional ISO 8601 lower bound - * @param string $to Optional ISO 8601 upper bound + * @param string $from Optional RFC3339 lower bound + * @param string $to Optional RFC3339 upper bound * * @return DataResponse}, array{}>|DataResponse * @@ -150,10 +151,32 @@ private function parseRange(string $from, string $to): array { } private function parseDate(string $value): \DateTimeImmutable { + if (preg_match(self::RFC3339_PATTERN, $value, $matches) !== 1) { + throw new \InvalidArgumentException('Dates must use RFC3339.'); + } + + $year = (int)$matches[1]; + $month = (int)$matches[2]; + $day = (int)$matches[3]; + $hour = (int)$matches[4]; + $minute = (int)$matches[5]; + $second = (int)$matches[6]; + if (!checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) { + throw new \InvalidArgumentException('Dates must use RFC3339.'); + } + + $timezone = $matches[8]; + if ($timezone !== 'Z') { + [$timezoneHour, $timezoneMinute] = array_map('intval', explode(':', substr($timezone, 1))); + if ($timezoneHour > 23 || $timezoneMinute > 59) { + throw new \InvalidArgumentException('Dates must use RFC3339.'); + } + } + try { return (new \DateTimeImmutable($value))->setTimezone(new \DateTimeZone('UTC')); } catch (\Exception) { - throw new \InvalidArgumentException('Dates must use a valid ISO 8601 value.'); + throw new \InvalidArgumentException('Dates must use RFC3339.'); } } } From 45b920101ecbee26c1584b36355f0d08ad378761 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:34:10 -0300 Subject: [PATCH 133/283] test: cover strict historical range validation --- .../features/api/usage_statistics.feature | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/integration/features/api/usage_statistics.feature b/tests/integration/features/api/usage_statistics.feature index e49eeaa..3594e27 100644 --- a/tests/integration/features/api/usage_statistics.feature +++ b/tests/integration/features/api/usage_statistics.feature @@ -8,10 +8,10 @@ Feature: usage statistics OCS API | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | Then the response should have a status code 201 And the response should be a JSON array with the following mandatory values - | key | value | - | (jq).ocs.data.application | behat_schema | - | (jq).ocs.data.schemaVersion | 1 | - | (jq).ocs.data.status | created | + | key | value | + | (jq).ocs.data.application | behat_schema | + | (jq).ocs.data.schemaVersion | 1 | + | (jq).ocs.data.status | created | When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas/behat_schema/1" Then the response should have a status code 200 And the response should be a JSON array with the following mandatory values @@ -161,16 +161,24 @@ Feature: usage statistics OCS API When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_statistics/metrics/server/version/distribution" Then the response should have a status code 200 And the response should be a JSON array with the following mandatory values - | key | value | - | (jq).ocs.data.values[0].value | 1.2.3 | - | (jq).ocs.data.values[0].count | 1 | + | key | value | + | (jq).ocs.data.values[0].value | 1.2.3 | + | (jq).ocs.data.values[0].count | 1 | When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_statistics/metrics/usage/requests_completed/numerical" Then the response should have a status code 200 And the response should be a JSON array with the following mandatory values - | key | value | - | (jq).ocs.data.statistics.count | 1 | - | (jq).ocs.data.statistics.average | 7 | - | (jq).ocs.data.statistics.total | 7 | + | key | value | + | (jq).ocs.data.statistics.count | 1 | + | (jq).ocs.data.statistics.average | 7 | + | (jq).ocs.data.statistics.total | 7 | + + Scenario: historical range requires RFC3339 + Given as user "admin" + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_statistics/metrics/usage/requests_completed/numerical/history?from=2026-09-01" + Then the response should have a status code 400 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.error | invalid_range | Scenario: non-admin user cannot access administration endpoints Given user "behat-user" exists @@ -186,5 +194,5 @@ Feature: usage statistics OCS API | retentionDays | 365 | Then the response should have a status code 200 And the response should be a JSON array with the following mandatory values - | key | value | - | (jq).ocs.data.retentionDays | 365 | + | key | value | + | (jq).ocs.data.retentionDays | 365 | From 8a454468e93de271b0c15a40aff105c770caf5f4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:36:14 -0300 Subject: [PATCH 134/283] fix: preserve newest installation report --- lib/Db/ReportRepository.php | 68 ++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index 9db824c..83d3836 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -107,25 +107,67 @@ private function insertMetric(int $reportId, Metric $metric): void { } private function updateInstallation(Report $report, int $reportId, \DateTimeImmutable $receivedAt): void { + $installation = $this->findInstallation($report->application, $report->installationId); + if ($installation === null) { + $insertQb = $this->db->getQueryBuilder(); + $insertQb->insert('usage_stats_installations')->values([ + 'application' => $insertQb->createNamedParameter($report->application), + 'installation_id' => $insertQb->createNamedParameter($report->installationId), + 'last_seen_at' => $insertQb->createNamedParameter($this->formatDateTime($receivedAt)), + 'last_report_id' => $insertQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), + ])->executeStatement(); + return; + } + $qb = $this->db->getQueryBuilder(); - $updated = $qb->update('usage_stats_installations') + $qb->update('usage_stats_installations') ->set('last_seen_at', $qb->createNamedParameter($this->formatDateTime($receivedAt))) - ->set('last_report_id', $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT)) ->where($qb->expr()->eq('application', $qb->createNamedParameter($report->application))) - ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))) - ->executeStatement(); + ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))); - if ($updated > 0) { - return; + if ($this->isNewerThanCurrentReport($report, $installation['lastReportId'])) { + $qb->set('last_report_id', $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT)); + } + + $qb->executeStatement(); + } + + /** @return array{lastReportId:int}|null */ + private function findInstallation(string $application, string $installationId): ?array { + $qb = $this->db->getQueryBuilder(); + $row = $qb->select('last_report_id') + ->from('usage_stats_installations') + ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) + ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($installationId))) + ->executeQuery() + ->fetchAssociative(); + + if ($row === false) { + return null; + } + + return ['lastReportId' => (int)$row['last_report_id']]; + } + + private function isNewerThanCurrentReport(Report $report, int $currentReportId): bool { + $qb = $this->db->getQueryBuilder(); + $row = $qb->select('period_start', 'period_end') + ->from('usage_stats_reports') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($currentReportId, IQueryBuilder::PARAM_INT))) + ->executeQuery() + ->fetchAssociative(); + + if ($row === false) { + return true; + } + + $incomingEnd = $this->formatDateTime($report->periodEnd); + $currentEnd = (string)$row['period_end']; + if ($incomingEnd !== $currentEnd) { + return $incomingEnd > $currentEnd; } - $insertQb = $this->db->getQueryBuilder(); - $insertQb->insert('usage_stats_installations')->values([ - 'application' => $insertQb->createNamedParameter($report->application), - 'installation_id' => $insertQb->createNamedParameter($report->installationId), - 'last_seen_at' => $insertQb->createNamedParameter($this->formatDateTime($receivedAt)), - 'last_report_id' => $insertQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), - ])->executeStatement(); + return $this->formatDateTime($report->periodStart) > (string)$row['period_start']; } /** @return array{id:int,schemaVersion:int}|null */ From 8b88c40398bc6bda502087d59f0cdecfd719b52f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:36:30 -0300 Subject: [PATCH 135/283] test: keep current state on delayed reports --- .../Integration/Db/OutOfOrderReportTest.php | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/php/Integration/Db/OutOfOrderReportTest.php diff --git a/tests/php/Integration/Db/OutOfOrderReportTest.php b/tests/php/Integration/Db/OutOfOrderReportTest.php new file mode 100644 index 0000000..26ed0e4 --- /dev/null +++ b/tests/php/Integration/Db/OutOfOrderReportTest.php @@ -0,0 +1,90 @@ +db = Server::get(IDBConnection::class); + $this->truncateTables(); + } + + protected function tearDown(): void { + $this->truncateTables(); + parent::tearDown(); + } + + public function testDelayedHistoricalReportDoesNotReplaceCurrentState(): void { + $factory = new ReportFactory(); + $reports = new ReportRepository($this->db); + $statistics = new StatisticsRepository($this->db); + + $reports->store($factory->fromPayload($this->payload( + '2026-08-01T00:00:00Z', + '2026-09-01T00:00:00Z', + '2.0.0', + 20, + ))); + $reports->store($factory->fromPayload($this->payload( + '2026-07-01T00:00:00Z', + '2026-08-01T00:00:00Z', + '1.0.0', + 10, + ))); + + self::assertSame([ + ['value' => '2.0.0', 'count' => 1], + ], $statistics->currentDistribution('libresign', 'server', 'version')); + + self::assertSame([ + 'count' => 1, + 'average' => 20.0, + 'min' => 20.0, + 'max' => 20.0, + 'total' => 20.0, + ], $statistics->currentNumericalEvaluation('libresign', 'usage', 'requests_completed')); + + $history = $statistics->numericalHistory( + 'libresign', + 'usage', + 'requests_completed', + new \DateTimeImmutable('2026-07-01T00:00:00Z'), + new \DateTimeImmutable('2026-09-02T00:00:00Z'), + ); + self::assertCount(2, $history); + } + + /** @return array */ + private function payload(string $start, string $end, string $version, int $completed): array { + return [ + 'protocolVersion' => 1, + 'application' => 'libresign', + 'installationId' => 'installation-delayed', + 'schemaVersion' => 1, + 'period' => ['start' => $start, 'end' => $end], + 'metrics' => [ + ['category' => 'server', 'key' => 'version', 'type' => 'string', 'value' => $version], + ['category' => 'usage', 'key' => 'requests_completed', 'type' => 'integer', 'value' => $completed], + ], + ]; + } + + private function truncateTables(): void { + $this->db->getQueryBuilder()->delete('usage_stats_metrics')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_installations')->executeStatement(); + $this->db->getQueryBuilder()->delete('usage_stats_reports')->executeStatement(); + } +} From 930e443d7db3ed6a9ef08f0ffc843aabfaf49dbb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:37:55 -0300 Subject: [PATCH 136/283] ci: skip app dev scripts in Behat setup --- .github/workflows/behat-sqlite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index 52dee5e..ac371e4 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -96,7 +96,7 @@ jobs: working-directory: apps/${{ env.APP_NAME }} run: | composer remove nextcloud/ocp --dev --no-scripts - composer install --no-dev + composer install --no-dev --no-scripts composer --working-dir=tests/integration install - name: Set up Nextcloud From 87b9333357b134b73350d4075b082f429b6c79af Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:38:43 -0300 Subject: [PATCH 137/283] feat: enforce metric schema compatibility --- lib/Db/SchemaRepository.php | 78 ++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php index 21bbab3..32a35e7 100644 --- a/lib/Db/SchemaRepository.php +++ b/lib/Db/SchemaRepository.php @@ -27,8 +27,23 @@ public function find(string $application, int $schemaVersion): ?array { return null; } - $decoded = json_decode((string)$definition, true, 512, JSON_THROW_ON_ERROR); - return is_array($decoded) ? $decoded : null; + return $this->decodeDefinition($definition); + } + + /** @return array|null */ + public function findMetric(string $application, string $category, string $key): ?array { + foreach ($this->findAll($application) as $definition) { + foreach ($definition['metrics'] ?? [] as $metric) { + if (!is_array($metric)) { + continue; + } + if (($metric['category'] ?? null) === $category && ($metric['key'] ?? null) === $key) { + return $metric; + } + } + } + + return null; } /** @@ -44,6 +59,8 @@ public function store(string $application, int $schemaVersion, array $definition throw new \LogicException('Schema version already exists with a different definition.'); } + $this->assertMetricCompatibility($application, $definition); + $qb = $this->db->getQueryBuilder(); $qb->insert('usage_stats_schemas')->values([ 'application' => $qb->createNamedParameter($application), @@ -57,6 +74,63 @@ public function store(string $application, int $schemaVersion, array $definition return true; } + /** @return list> */ + private function findAll(string $application): array { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('definition') + ->from('usage_stats_schemas') + ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) + ->orderBy('schema_version', 'ASC') + ->executeQuery(); + + $definitions = []; + foreach ($result->iterateColumn() as $definition) { + $decoded = $this->decodeDefinition($definition); + if ($decoded !== null) { + $definitions[] = $decoded; + } + } + + return $definitions; + } + + /** @param array $definition */ + private function assertMetricCompatibility(string $application, array $definition): void { + $existingMetrics = []; + foreach ($this->findAll($application) as $existingDefinition) { + foreach ($existingDefinition['metrics'] ?? [] as $metric) { + if (!is_array($metric)) { + continue; + } + $identity = ($metric['category'] ?? '') . ':' . ($metric['key'] ?? ''); + $existingMetrics[$identity] = $metric; + } + } + + foreach ($definition['metrics'] ?? [] as $metric) { + if (!is_array($metric)) { + continue; + } + $identity = ($metric['category'] ?? '') . ':' . ($metric['key'] ?? ''); + $existing = $existingMetrics[$identity] ?? null; + if (!is_array($existing)) { + continue; + } + + foreach (['type', 'kind', 'aggregation'] as $field) { + if (($existing[$field] ?? null) !== ($metric[$field] ?? null)) { + throw new \LogicException("Metric {$identity} changes {$field}; use a new metric key for incompatible semantics."); + } + } + } + } + + /** @return array|null */ + private function decodeDefinition(mixed $definition): ?array { + $decoded = json_decode((string)$definition, true, 512, JSON_THROW_ON_ERROR); + return is_array($decoded) ? $decoded : null; + } + private function formatDateTime(\DateTimeImmutable $dateTime): string { return $dateTime ->setTimezone(new \DateTimeZone('UTC')) From 1064bbd8feefdc0b5caa5cc456c4078b22f6a366 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:39:10 -0300 Subject: [PATCH 138/283] feat: enforce metric aggregation contracts --- lib/Controller/StatisticsController.php | 50 +++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/Controller/StatisticsController.php b/lib/Controller/StatisticsController.php index eb98756..f555bdb 100644 --- a/lib/Controller/StatisticsController.php +++ b/lib/Controller/StatisticsController.php @@ -4,6 +4,7 @@ namespace OCA\UsageStatisticsServer\Controller; +use OCA\UsageStatisticsServer\Db\SchemaRepository; use OCA\UsageStatisticsServer\Db\StatisticsRepository; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -21,6 +22,7 @@ public function __construct( string $appName, IRequest $request, private readonly StatisticsRepository $statistics, + private readonly SchemaRepository $schemas, ) { parent::__construct($appName, $request); } @@ -55,12 +57,19 @@ public function summary(string $application): DataResponse { * @param string $category Metric category * @param string $key Metric key * - * @return DataResponse}, array{}> + * @return DataResponse}, array{}>|DataResponse * * 200: Current metric distribution + * 400: Metric does not support distribution aggregation + * 404: Metric is not registered */ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}/metrics/{category}/{key}/distribution', requirements: ['apiVersion' => '(v1)'])] public function distribution(string $application, string $category, string $key): DataResponse { + $error = $this->validateAggregation($application, $category, $key, 'distribution'); + if ($error !== null) { + return $error; + } + return new DataResponse([ 'application' => $application, 'category' => $category, @@ -76,12 +85,19 @@ public function distribution(string $application, string $category, string $key) * @param string $category Metric category * @param string $key Metric key * - * @return DataResponse + * @return DataResponse|DataResponse * * 200: Current numerical metric evaluation + * 400: Metric does not support numerical aggregation + * 404: Metric is not registered */ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}/metrics/{category}/{key}/numerical', requirements: ['apiVersion' => '(v1)'])] public function numerical(string $application, string $category, string $key): DataResponse { + $error = $this->validateAggregation($application, $category, $key, 'numerical'); + if ($error !== null) { + return $error; + } + return new DataResponse([ 'application' => $application, 'category' => $category, @@ -99,10 +115,11 @@ public function numerical(string $application, string $category, string $key): D * @param string $from Optional RFC3339 lower bound * @param string $to Optional RFC3339 upper bound * - * @return DataResponse}, array{}>|DataResponse + * @return DataResponse}, array{}>|DataResponse * * 200: Historical numerical metric evaluation - * 400: Invalid date range + * 400: Invalid date range or metric aggregation + * 404: Metric is not registered */ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/applications/{application}/metrics/{category}/{key}/numerical/history', requirements: ['apiVersion' => '(v1)'])] public function numericalHistory( @@ -112,6 +129,11 @@ public function numericalHistory( string $from = '', string $to = '', ): DataResponse { + $error = $this->validateAggregation($application, $category, $key, 'numerical'); + if ($error !== null) { + return $error; + } + try { $range = $this->parseRange($from, $to); } catch (\InvalidArgumentException $e) { @@ -137,6 +159,26 @@ public function numericalHistory( ]); } + /** @return DataResponse|null */ + private function validateAggregation(string $application, string $category, string $key, string $aggregation): ?DataResponse { + $metric = $this->schemas->findMetric($application, $category, $key); + if ($metric === null) { + return new DataResponse([ + 'error' => 'metric_not_found', + 'message' => 'Metric is not registered for this application.', + ], Http::STATUS_NOT_FOUND); + } + + if (($metric['aggregation'] ?? null) !== $aggregation) { + return new DataResponse([ + 'error' => 'invalid_aggregation', + 'message' => "Metric does not support {$aggregation} aggregation.", + ], Http::STATUS_BAD_REQUEST); + } + + return null; + } + /** @return array{from:\DateTimeImmutable,to:\DateTimeImmutable} */ private function parseRange(string $from, string $to): array { $utc = new \DateTimeZone('UTC'); From 550f5ef46770214bb6f66d7c5a512ad163069064 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:39:37 -0300 Subject: [PATCH 139/283] test: cover metric aggregation contracts --- .../features/api/schema_aggregation.feature | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/integration/features/api/schema_aggregation.feature diff --git a/tests/integration/features/api/schema_aggregation.feature b/tests/integration/features/api/schema_aggregation.feature new file mode 100644 index 0000000..8386b38 --- /dev/null +++ b/tests/integration/features/api/schema_aggregation.feature @@ -0,0 +1,30 @@ +Feature: metric aggregation contracts + + Scenario: aggregation endpoint must match the registered metric schema + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_aggregation | + | schemaVersion | 1 | + | metrics | [{"category":"server","key":"version","type":"string","kind":"snapshot","aggregation":"distribution","description":"Application version","required":true}] | + Then the response should have a status code 201 + When sending "get" to ocs "/apps/usage_statistics_server/api/v1/admin/applications/behat_aggregation/metrics/server/version/numerical" + Then the response should have a status code 400 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.error | invalid_aggregation | + + Scenario: metric semantics cannot change between schema versions + Given as user "admin" + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema_evolution | + | schemaVersion | 1 | + | metrics | [{"category":"usage","key":"requests","type":"integer","kind":"period","aggregation":"numerical","description":"Requests","required":true}] | + Then the response should have a status code 201 + When sending "post" to ocs "/apps/usage_statistics_server/api/v1/admin/schemas" + | application | behat_schema_evolution | + | schemaVersion | 2 | + | metrics | [{"category":"usage","key":"requests","type":"integer","kind":"period","aggregation":"distribution","description":"Requests","required":true}] | + Then the response should have a status code 409 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.error | schema_conflict | From 17cade5922864d18b16a2755cb04181f1e6532fb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:40:09 -0300 Subject: [PATCH 140/283] fix: use portable schema iteration --- lib/Db/SchemaRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php index 32a35e7..70c3c7a 100644 --- a/lib/Db/SchemaRepository.php +++ b/lib/Db/SchemaRepository.php @@ -84,8 +84,8 @@ private function findAll(string $application): array { ->executeQuery(); $definitions = []; - foreach ($result->iterateColumn() as $definition) { - $decoded = $this->decodeDefinition($definition); + foreach ($result->iterateAssociative() as $row) { + $decoded = $this->decodeDefinition($row['definition'] ?? null); if ($decoded !== null) { $definitions[] = $decoded; } From 840d38198e96085496a072a8d6a6421564fb306d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:51:22 -0300 Subject: [PATCH 141/283] test: isolate vendor deprecations in Behat --- tests/integration/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/composer.json b/tests/integration/composer.json index cc2c431..85516de 100644 --- a/tests/integration/composer.json +++ b/tests/integration/composer.json @@ -8,7 +8,7 @@ "php-http/message": "^1.16" }, "scripts": { - "behat": "vendor/bin/behat -f junit -f pretty --colors" + "behat": "@php -d error_reporting=8191 vendor/bin/behat -f junit -f pretty --colors" }, "config": { "allow-plugins": { From e1e1f55051c78826f6c14d35e2acb04e91507ef2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:51:37 -0300 Subject: [PATCH 142/283] fix: track current report period atomically --- lib/Migration/Version010000Date20260908001000.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Migration/Version010000Date20260908001000.php b/lib/Migration/Version010000Date20260908001000.php index 86e7960..1a1958e 100644 --- a/lib/Migration/Version010000Date20260908001000.php +++ b/lib/Migration/Version010000Date20260908001000.php @@ -35,6 +35,8 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $installations->addColumn('installation_id', Types::STRING, ['length' => 128]); $installations->addColumn('last_seen_at', Types::DATETIME_IMMUTABLE); $installations->addColumn('last_report_id', Types::BIGINT, ['unsigned' => true]); + $installations->addColumn('last_period_start', Types::DATETIME_IMMUTABLE); + $installations->addColumn('last_period_end', Types::DATETIME_IMMUTABLE); $installations->setPrimaryKey(['id']); $installations->addUniqueIndex(['application', 'installation_id'], 'usage_stats_installation_identity'); $installations->addIndex(['application', 'last_seen_at'], 'usage_stats_active_installations'); From 6ae3d70603ded5debc463292db3b8eaefbcc5e89 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:52:47 -0300 Subject: [PATCH 143/283] fix: update installation state atomically --- lib/Db/ReportRepository.php | 93 +++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 51 deletions(-) diff --git a/lib/Db/ReportRepository.php b/lib/Db/ReportRepository.php index 83d3836..c3cd62d 100644 --- a/lib/Db/ReportRepository.php +++ b/lib/Db/ReportRepository.php @@ -107,67 +107,58 @@ private function insertMetric(int $reportId, Metric $metric): void { } private function updateInstallation(Report $report, int $reportId, \DateTimeImmutable $receivedAt): void { - $installation = $this->findInstallation($report->application, $report->installationId); - if ($installation === null) { - $insertQb = $this->db->getQueryBuilder(); - $insertQb->insert('usage_stats_installations')->values([ - 'application' => $insertQb->createNamedParameter($report->application), - 'installation_id' => $insertQb->createNamedParameter($report->installationId), - 'last_seen_at' => $insertQb->createNamedParameter($this->formatDateTime($receivedAt)), - 'last_report_id' => $insertQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), - ])->executeStatement(); + $receivedAtValue = $this->formatDateTime($receivedAt); + $periodStart = $this->formatDateTime($report->periodStart); + $periodEnd = $this->formatDateTime($report->periodEnd); + + $seenQb = $this->db->getQueryBuilder(); + $seenUpdated = $seenQb->update('usage_stats_installations') + ->set('last_seen_at', $seenQb->createNamedParameter($receivedAtValue)) + ->where($seenQb->expr()->eq('application', $seenQb->createNamedParameter($report->application))) + ->andWhere($seenQb->expr()->eq('installation_id', $seenQb->createNamedParameter($report->installationId))) + ->andWhere($seenQb->expr()->lt('last_seen_at', $seenQb->createNamedParameter($receivedAtValue))) + ->executeStatement(); + + $currentQb = $this->db->getQueryBuilder(); + $currentUpdated = $currentQb->update('usage_stats_installations') + ->set('last_report_id', $currentQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT)) + ->set('last_period_start', $currentQb->createNamedParameter($periodStart)) + ->set('last_period_end', $currentQb->createNamedParameter($periodEnd)) + ->where($currentQb->expr()->eq('application', $currentQb->createNamedParameter($report->application))) + ->andWhere($currentQb->expr()->eq('installation_id', $currentQb->createNamedParameter($report->installationId))) + ->andWhere($currentQb->expr()->orX( + $currentQb->expr()->lt('last_period_end', $currentQb->createNamedParameter($periodEnd)), + $currentQb->expr()->andX( + $currentQb->expr()->eq('last_period_end', $currentQb->createNamedParameter($periodEnd)), + $currentQb->expr()->lt('last_period_start', $currentQb->createNamedParameter($periodStart)), + ), + )) + ->executeStatement(); + + if ($seenUpdated > 0 || $currentUpdated > 0 || $this->installationExists($report->application, $report->installationId)) { return; } - $qb = $this->db->getQueryBuilder(); - $qb->update('usage_stats_installations') - ->set('last_seen_at', $qb->createNamedParameter($this->formatDateTime($receivedAt))) - ->where($qb->expr()->eq('application', $qb->createNamedParameter($report->application))) - ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($report->installationId))); - - if ($this->isNewerThanCurrentReport($report, $installation['lastReportId'])) { - $qb->set('last_report_id', $qb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT)); - } - - $qb->executeStatement(); + $insertQb = $this->db->getQueryBuilder(); + $insertQb->insert('usage_stats_installations')->values([ + 'application' => $insertQb->createNamedParameter($report->application), + 'installation_id' => $insertQb->createNamedParameter($report->installationId), + 'last_seen_at' => $insertQb->createNamedParameter($receivedAtValue), + 'last_report_id' => $insertQb->createNamedParameter($reportId, IQueryBuilder::PARAM_INT), + 'last_period_start' => $insertQb->createNamedParameter($periodStart), + 'last_period_end' => $insertQb->createNamedParameter($periodEnd), + ])->executeStatement(); } - /** @return array{lastReportId:int}|null */ - private function findInstallation(string $application, string $installationId): ?array { + private function installationExists(string $application, string $installationId): bool { $qb = $this->db->getQueryBuilder(); - $row = $qb->select('last_report_id') + return $qb->select('id') ->from('usage_stats_installations') ->where($qb->expr()->eq('application', $qb->createNamedParameter($application))) ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter($installationId))) + ->setMaxResults(1) ->executeQuery() - ->fetchAssociative(); - - if ($row === false) { - return null; - } - - return ['lastReportId' => (int)$row['last_report_id']]; - } - - private function isNewerThanCurrentReport(Report $report, int $currentReportId): bool { - $qb = $this->db->getQueryBuilder(); - $row = $qb->select('period_start', 'period_end') - ->from('usage_stats_reports') - ->where($qb->expr()->eq('id', $qb->createNamedParameter($currentReportId, IQueryBuilder::PARAM_INT))) - ->executeQuery() - ->fetchAssociative(); - - if ($row === false) { - return true; - } - - $incomingEnd = $this->formatDateTime($report->periodEnd); - $currentEnd = (string)$row['period_end']; - if ($incomingEnd !== $currentEnd) { - return $incomingEnd > $currentEnd; - } - - return $this->formatDateTime($report->periodStart) > (string)$row['period_start']; + ->fetchOne() !== false; } /** @return array{id:int,schemaVersion:int}|null */ From 4c08659d518aec0b6df75b609d6854c3d3b9a384 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:53:01 -0300 Subject: [PATCH 144/283] test: keep user deprecations visible in Behat --- tests/integration/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/composer.json b/tests/integration/composer.json index 85516de..66cfa99 100644 --- a/tests/integration/composer.json +++ b/tests/integration/composer.json @@ -8,7 +8,7 @@ "php-http/message": "^1.16" }, "scripts": { - "behat": "@php -d error_reporting=8191 vendor/bin/behat -f junit -f pretty --colors" + "behat": "@php -d error_reporting=24575 vendor/bin/behat -f junit -f pretty --colors" }, "config": { "allow-plugins": { From 1fc5ed3c10219b49771b2850a0abd1d7a905586b Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:53:12 -0300 Subject: [PATCH 145/283] test: verify materialized report period --- tests/php/Integration/Db/OutOfOrderReportTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/php/Integration/Db/OutOfOrderReportTest.php b/tests/php/Integration/Db/OutOfOrderReportTest.php index 26ed0e4..5dc1f90 100644 --- a/tests/php/Integration/Db/OutOfOrderReportTest.php +++ b/tests/php/Integration/Db/OutOfOrderReportTest.php @@ -57,6 +57,18 @@ public function testDelayedHistoricalReportDoesNotReplaceCurrentState(): void { 'total' => 20.0, ], $statistics->currentNumericalEvaluation('libresign', 'usage', 'requests_completed')); + $installationQb = $this->db->getQueryBuilder(); + $installation = $installationQb->select('last_period_start', 'last_period_end') + ->from('usage_stats_installations') + ->where($installationQb->expr()->eq('application', $installationQb->createNamedParameter('libresign'))) + ->andWhere($installationQb->expr()->eq('installation_id', $installationQb->createNamedParameter('installation-delayed'))) + ->executeQuery() + ->fetchAssociative(); + + self::assertIsArray($installation); + self::assertSame('2026-08-01 00:00:00', (string)$installation['last_period_start']); + self::assertSame('2026-09-01 00:00:00', (string)$installation['last_period_end']); + $history = $statistics->numericalHistory( 'libresign', 'usage', From 9626dad2dfe458a82959372d6b2097feef19697e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 7 Sep 2026 23:53:28 -0300 Subject: [PATCH 146/283] ci: test MariaDB compatibility --- .github/workflows/phpunit-mariadb.yml | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/workflows/phpunit-mariadb.yml diff --git a/.github/workflows/phpunit-mariadb.yml b/.github/workflows/phpunit-mariadb.yml new file mode 100644 index 0000000..31af3af --- /dev/null +++ b/.github/workflows/phpunit-mariadb.yml @@ -0,0 +1,94 @@ +# This workflow is based on the Nextcloud organization workflow template. +# +# https://github.com/nextcloud/.github +# SPDX-FileCopyrightText: 2023-2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: PHPUnit MariaDB + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: phpunit-mariadb-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.versions.outputs.sparse-matrix }} + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + with: + matrix: '{"mariadb-versions": ["10.6", "11.4"]}' + + phpunit-mariadb: + runs-on: ubuntu-latest + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} + env: + APP_NAME: usage_statistics_server + name: MariaDB ${{ matrix.mariadb-versions }} PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }} + services: + mariadb: + image: ghcr.io/nextcloud/continuous-integration-mariadb-${{ matrix.mariadb-versions }}:latest + ports: + - 4444:3306/tcp + env: + MARIADB_ROOT_PASSWORD: rootpassword + options: --health-cmd="mariadb-admin ping" --health-interval 5s --health-timeout 2s --health-retries 10 + steps: + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ matrix.server-versions }} + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + - name: Set up PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ matrix.php-versions }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, mysql, pdo_mysql + coverage: none + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Enable ONLY_FULL_GROUP_BY + run: | + echo "SET GLOBAL sql_mode=(SELECT CONCAT(@@sql_mode,',ONLY_FULL_GROUP_BY'));" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword + - name: Install app dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=4444 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin + ./occ --version + ./occ app:enable --force ${{ env.APP_NAME }} + - name: Run PHPUnit suites + working-directory: apps/${{ env.APP_NAME }} + run: composer run test:php + - name: Print logs + if: always() + run: cat data/nextcloud.log || true From 20c4a128505bd947709eb40a215aa1a03c33a7a0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 06:54:10 -0300 Subject: [PATCH 147/283] ci: stabilize Behat integration matrix --- .github/workflows/behat-sqlite.yml | 34 ++++++++++-------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index ac371e4..ff6c92f 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -14,21 +14,6 @@ concurrency: cancel-in-progress: true jobs: - matrix: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.versions.outputs.sparse-matrix }} - - steps: - - name: Checkout app metadata - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Get version matrix - id: versions - uses: icewind1991/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 - changes: runs-on: ubuntu-latest permissions: @@ -53,18 +38,21 @@ jobs: behat-sqlite: runs-on: ubuntu-latest - needs: [changes, matrix] + needs: changes if: needs.changes.outputs.src != 'false' timeout-minutes: 30 strategy: fail-fast: false - matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} + matrix: + server-version: + - stable35 + - master env: APP_NAME: usage_statistics_server - name: SQLite PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }} + name: SQLite PHP 8.3 Nextcloud ${{ matrix.server-version }} steps: - name: Checkout server @@ -73,7 +61,7 @@ jobs: persist-credentials: false submodules: true repository: nextcloud/server - ref: ${{ matrix.server-versions }} + ref: ${{ matrix.server-version }} - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -81,10 +69,10 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Set up PHP ${{ matrix.php-versions }} + - name: Set up PHP 8.3 uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: - php-version: ${{ matrix.php-versions }} + php-version: '8.3' extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib coverage: none ini-file: development @@ -97,7 +85,7 @@ jobs: run: | composer remove nextcloud/ocp --dev --no-scripts composer install --no-dev --no-scripts - composer --working-dir=tests/integration install + composer --working-dir=tests/integration install --prefer-dist --no-progress - name: Set up Nextcloud run: | @@ -126,7 +114,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: behat-results-${{ matrix.server-versions }}-php${{ matrix.php-versions }} + name: behat-results-${{ matrix.server-version }}-php8.3 path: apps/${{ env.APP_NAME }}/tests/integration/output/ if-no-files-found: ignore retention-days: 30 From a9cf4612225299c32c50a829dee53d9061a81299 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 06:55:46 -0300 Subject: [PATCH 148/283] ci: align MariaDB test workflow --- .github/workflows/phpunit-mariadb.yml | 36 ++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/phpunit-mariadb.yml b/.github/workflows/phpunit-mariadb.yml index 31af3af..fc8b6df 100644 --- a/.github/workflows/phpunit-mariadb.yml +++ b/.github/workflows/phpunit-mariadb.yml @@ -32,9 +32,31 @@ jobs: with: matrix: '{"mariadb-versions": ["10.6", "11.4"]}' + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + src: ${{ steps.changes.outputs.src }} + steps: + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/phpunit*' + - 'appinfo/**' + - 'lib/**' + - 'tests/**' + - 'composer.json' + - 'composer.lock' + phpunit-mariadb: runs-on: ubuntu-latest - needs: matrix + needs: [changes, matrix] + if: needs.changes.outputs.src != 'false' strategy: fail-fast: false matrix: ${{ fromJson(needs.matrix.outputs.matrix) }} @@ -75,6 +97,7 @@ jobs: - name: Enable ONLY_FULL_GROUP_BY run: | echo "SET GLOBAL sql_mode=(SELECT CONCAT(@@sql_mode,',ONLY_FULL_GROUP_BY'));" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword + echo 'SELECT @@sql_mode;' | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword - name: Install app dependencies working-directory: apps/${{ env.APP_NAME }} run: | @@ -92,3 +115,14 @@ jobs: - name: Print logs if: always() run: cat data/nextcloud.log || true + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: [changes, phpunit-mariadb] + if: always() + name: phpunit-mariadb-summary + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-mariadb.result != 'success' }}; then exit 1; fi From f11251e02e46f570d501dfb7cc32c1919fd360cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 06:56:10 -0300 Subject: [PATCH 149/283] test: cover current report tie breaking --- .../Integration/Db/OutOfOrderReportTest.php | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/tests/php/Integration/Db/OutOfOrderReportTest.php b/tests/php/Integration/Db/OutOfOrderReportTest.php index 5dc1f90..77861db 100644 --- a/tests/php/Integration/Db/OutOfOrderReportTest.php +++ b/tests/php/Integration/Db/OutOfOrderReportTest.php @@ -57,17 +57,7 @@ public function testDelayedHistoricalReportDoesNotReplaceCurrentState(): void { 'total' => 20.0, ], $statistics->currentNumericalEvaluation('libresign', 'usage', 'requests_completed')); - $installationQb = $this->db->getQueryBuilder(); - $installation = $installationQb->select('last_period_start', 'last_period_end') - ->from('usage_stats_installations') - ->where($installationQb->expr()->eq('application', $installationQb->createNamedParameter('libresign'))) - ->andWhere($installationQb->expr()->eq('installation_id', $installationQb->createNamedParameter('installation-delayed'))) - ->executeQuery() - ->fetchAssociative(); - - self::assertIsArray($installation); - self::assertSame('2026-08-01 00:00:00', (string)$installation['last_period_start']); - self::assertSame('2026-09-01 00:00:00', (string)$installation['last_period_end']); + $this->assertCurrentPeriod('2026-08-01 00:00:00', '2026-09-01 00:00:00'); $history = $statistics->numericalHistory( 'libresign', @@ -79,6 +69,68 @@ public function testDelayedHistoricalReportDoesNotReplaceCurrentState(): void { self::assertCount(2, $history); } + public function testLaterStartWinsWhenReportsHaveTheSamePeriodEnd(): void { + $factory = new ReportFactory(); + $reports = new ReportRepository($this->db); + $statistics = new StatisticsRepository($this->db); + + $reports->store($factory->fromPayload($this->payload( + '2026-07-01T00:00:00Z', + '2026-09-01T00:00:00Z', + '1.0.0', + 10, + ))); + $reports->store($factory->fromPayload($this->payload( + '2026-08-01T00:00:00Z', + '2026-09-01T00:00:00Z', + '2.0.0', + 20, + ))); + + self::assertSame([ + ['value' => '2.0.0', 'count' => 1], + ], $statistics->currentDistribution('libresign', 'server', 'version')); + $this->assertCurrentPeriod('2026-08-01 00:00:00', '2026-09-01 00:00:00'); + } + + public function testEarlierStartCannotReplaceCurrentReportWithSamePeriodEnd(): void { + $factory = new ReportFactory(); + $reports = new ReportRepository($this->db); + $statistics = new StatisticsRepository($this->db); + + $reports->store($factory->fromPayload($this->payload( + '2026-08-01T00:00:00Z', + '2026-09-01T00:00:00Z', + '2.0.0', + 20, + ))); + $reports->store($factory->fromPayload($this->payload( + '2026-07-01T00:00:00Z', + '2026-09-01T00:00:00Z', + '1.0.0', + 10, + ))); + + self::assertSame([ + ['value' => '2.0.0', 'count' => 1], + ], $statistics->currentDistribution('libresign', 'server', 'version')); + $this->assertCurrentPeriod('2026-08-01 00:00:00', '2026-09-01 00:00:00'); + } + + private function assertCurrentPeriod(string $start, string $end): void { + $qb = $this->db->getQueryBuilder(); + $installation = $qb->select('last_period_start', 'last_period_end') + ->from('usage_stats_installations') + ->where($qb->expr()->eq('application', $qb->createNamedParameter('libresign'))) + ->andWhere($qb->expr()->eq('installation_id', $qb->createNamedParameter('installation-delayed'))) + ->executeQuery() + ->fetchAssociative(); + + self::assertIsArray($installation); + self::assertSame($start, (string)$installation['last_period_start']); + self::assertSame($end, (string)$installation['last_period_end']); + } + /** @return array */ private function payload(string $start, string $end, string $version, int $completed): array { return [ From 2f7ad4e31b9a97700feef3fe4b2e53ddaf9d6c44 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 06:58:04 -0300 Subject: [PATCH 150/283] test: pin Behat integration dependencies --- tests/integration/composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/composer.json b/tests/integration/composer.json index 66cfa99..0ae5257 100644 --- a/tests/integration/composer.json +++ b/tests/integration/composer.json @@ -1,11 +1,11 @@ { "require": { - "behat/behat": "^3.13", - "guzzlehttp/guzzle": "^7.10", - "jarnaiz/behat-junit-formatter": "^1.3", - "libresign/nextcloud-behat": "^1.6", - "php-http/guzzle7-adapter": "^1.1", - "php-http/message": "^1.16" + "behat/behat": "3.32.0", + "guzzlehttp/guzzle": "7.15.3", + "jarnaiz/behat-junit-formatter": "1.3.2", + "libresign/nextcloud-behat": "1.6.0", + "php-http/guzzle7-adapter": "1.1.0", + "php-http/message": "1.16.2" }, "scripts": { "behat": "@php -d error_reporting=24575 vendor/bin/behat -f junit -f pretty --colors" From b208c399fd11a6d82fc1608681e32e7e486c0741 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 06:59:01 -0300 Subject: [PATCH 151/283] docs: define current report ordering --- docs/protocol-v1.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 6c03b0b..d679f42 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -156,6 +156,20 @@ The `usage_statistics_server` implementation keeps the first accepted report imm - a retry for the same logical period using another schema version is rejected as a conflict; - neither case creates a second logical report. +## Current installation state + +Historical reports can arrive out of order, so receive order MUST NOT decide which report represents the current state of an installation. + +The `usage_statistics_server` implementation orders reports by reporting period: + +1. the report with the later `period.end` is newer; +2. when `period.end` is equal, the report with the later `period.start` is newer; +3. the current report pointer only moves forward according to that ordering. + +`last_seen_at` is independent from the current report pointer and records recent valid reporting activity. This means an older delayed report can refresh the installation activity timestamp without replacing its current metric snapshot. + +The current-state update is performed with a conditional database update rather than a read-then-write decision, so concurrent submissions cannot make the pointer move backwards. + ## Storage guidance Servers SHOULD persist normalized validated fields rather than the arbitrary request body. From cb7b5a785179ed43c2fc99a4d46dbb019322069f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:00:53 -0300 Subject: [PATCH 152/283] test: keep tie periods within protocol limit --- tests/php/Integration/Db/OutOfOrderReportTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/php/Integration/Db/OutOfOrderReportTest.php b/tests/php/Integration/Db/OutOfOrderReportTest.php index 77861db..dde531a 100644 --- a/tests/php/Integration/Db/OutOfOrderReportTest.php +++ b/tests/php/Integration/Db/OutOfOrderReportTest.php @@ -75,13 +75,13 @@ public function testLaterStartWinsWhenReportsHaveTheSamePeriodEnd(): void { $statistics = new StatisticsRepository($this->db); $reports->store($factory->fromPayload($this->payload( - '2026-07-01T00:00:00Z', + '2026-08-01T00:00:00Z', '2026-09-01T00:00:00Z', '1.0.0', 10, ))); $reports->store($factory->fromPayload($this->payload( - '2026-08-01T00:00:00Z', + '2026-08-15T00:00:00Z', '2026-09-01T00:00:00Z', '2.0.0', 20, @@ -90,7 +90,7 @@ public function testLaterStartWinsWhenReportsHaveTheSamePeriodEnd(): void { self::assertSame([ ['value' => '2.0.0', 'count' => 1], ], $statistics->currentDistribution('libresign', 'server', 'version')); - $this->assertCurrentPeriod('2026-08-01 00:00:00', '2026-09-01 00:00:00'); + $this->assertCurrentPeriod('2026-08-15 00:00:00', '2026-09-01 00:00:00'); } public function testEarlierStartCannotReplaceCurrentReportWithSamePeriodEnd(): void { @@ -99,13 +99,13 @@ public function testEarlierStartCannotReplaceCurrentReportWithSamePeriodEnd(): v $statistics = new StatisticsRepository($this->db); $reports->store($factory->fromPayload($this->payload( - '2026-08-01T00:00:00Z', + '2026-08-15T00:00:00Z', '2026-09-01T00:00:00Z', '2.0.0', 20, ))); $reports->store($factory->fromPayload($this->payload( - '2026-07-01T00:00:00Z', + '2026-08-01T00:00:00Z', '2026-09-01T00:00:00Z', '1.0.0', 10, @@ -114,7 +114,7 @@ public function testEarlierStartCannotReplaceCurrentReportWithSamePeriodEnd(): v self::assertSame([ ['value' => '2.0.0', 'count' => 1], ], $statistics->currentDistribution('libresign', 'server', 'version')); - $this->assertCurrentPeriod('2026-08-01 00:00:00', '2026-09-01 00:00:00'); + $this->assertCurrentPeriod('2026-08-15 00:00:00', '2026-09-01 00:00:00'); } private function assertCurrentPeriod(string $start, string $end): void { From faa90cb642634768f2b71cb78f2d90eae29927c5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:01:57 -0300 Subject: [PATCH 153/283] ci: retain Behat dependency snapshot --- .github/workflows/behat-sqlite.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index ff6c92f..e8a0eb2 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -115,7 +115,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: behat-results-${{ matrix.server-version }}-php8.3 - path: apps/${{ env.APP_NAME }}/tests/integration/output/ + path: | + apps/${{ env.APP_NAME }}/tests/integration/output/ + apps/${{ env.APP_NAME }}/tests/integration/composer.lock if-no-files-found: ignore retention-days: 30 From 9e55ee801843f74839828a0858fa8798c3fdfe44 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:05:48 -0300 Subject: [PATCH 154/283] ci: persist generated Behat lock once --- .github/workflows/behat-sqlite.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index e8a0eb2..ddec540 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -7,7 +7,7 @@ on: pull_request: permissions: - contents: read + contents: write concurrency: group: behat-sqlite-${{ github.head_ref || github.run_id }} @@ -66,7 +66,8 @@ jobs: - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: false + persist-credentials: true + ref: ${{ github.head_ref }} path: apps/${{ env.APP_NAME }} - name: Set up PHP 8.3 @@ -87,6 +88,18 @@ jobs: composer install --no-dev --no-scripts composer --working-dir=tests/integration install --prefer-dist --no-progress + - name: Commit generated integration lock + if: matrix.server-version == 'master' + working-directory: apps/${{ env.APP_NAME }} + run: | + if git status --porcelain -- tests/integration/composer.lock | grep -q .; then + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add tests/integration/composer.lock + git commit -s -m 'test: lock Behat integration dependencies' + git push origin HEAD:${GITHUB_HEAD_REF} + fi + - name: Set up Nextcloud run: | mkdir data @@ -115,9 +128,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: behat-results-${{ matrix.server-version }}-php8.3 - path: | - apps/${{ env.APP_NAME }}/tests/integration/output/ - apps/${{ env.APP_NAME }}/tests/integration/composer.lock + path: apps/${{ env.APP_NAME }}/tests/integration/output/ if-no-files-found: ignore retention-days: 30 From ebea0f2d23956cc1364de874f627aef5953d1eee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:06:32 +0000 Subject: [PATCH 155/283] test: lock Behat integration dependencies Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- tests/integration/composer.lock | 4804 +++++++++++++++++++++++++++++++ 1 file changed, 4804 insertions(+) create mode 100644 tests/integration/composer.lock diff --git a/tests/integration/composer.lock b/tests/integration/composer.lock new file mode 100644 index 0000000..7a51e2a --- /dev/null +++ b/tests/integration/composer.lock @@ -0,0 +1,4804 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c0ddd898943f1772223e9967b92601fd", + "packages": [ + { + "name": "behat/behat", + "version": "v3.32.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Behat.git", + "reference": "b9b89cf7a3b24c04d6e2d2865ed51bc5e1700de9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Behat/zipball/b9b89cf7a3b24c04d6e2d2865ed51bc5e1700de9", + "reference": "b9b89cf7a3b24c04d6e2d2865ed51bc5e1700de9", + "shasum": "" + }, + "require": { + "behat/gherkin": "^4.17.0", + "composer-runtime-api": "^2.2", + "composer/xdebug-handler": "^1.4 || ^2.0 || ^3.0", + "ext-mbstring": "*", + "nikic/php-parser": "^4.19.2 || ^5.2", + "php": ">=8.2 <8.6", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/translation": "^5.4 || ^6.4 || ^7.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "opis/json-schema": "^2.5", + "php-cs-fixer/shim": "^3.89", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "^9.6", + "rector/rector": "2.3.9", + "sebastian/diff": "^4.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "ext-dom": "Needed to output test results in JUnit format." + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "autoload": { + "psr-4": { + "Behat\\Hook\\": "src/Behat/Hook/", + "Behat\\Step\\": "src/Behat/Step/", + "Behat\\Behat\\": "src/Behat/Behat/", + "Behat\\Config\\": "src/Behat/Config/", + "Behat\\Testwork\\": "src/Behat/Testwork/", + "Behat\\Transformation\\": "src/Behat/Transformation/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "Agile", + "BDD", + "ScenarioBDD", + "Scrum", + "StoryBDD", + "User story", + "business", + "development", + "documentation", + "examples", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v3.32.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-06-20T08:34:52+00:00" + }, + { + "name": "behat/gherkin", + "version": "v4.17.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "php": ">=8.1 <8.6" + }, + "require-dev": { + "cucumber/gherkin-monorepo": "dev-gherkin-v39.1.0", + "friendsofphp/php-cs-fixer": "^3.77", + "mikey179/vfsstream": "^1.6", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpunit/phpunit": "^10.5", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Gherkin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "https://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.17.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-05-18T09:33:47+00:00" + }, + { + "name": "clue/stream-filter", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2023-12-20T15:40:13+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "estahn/json-query-wrapper", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/estahn/json-query-wrapper.git", + "reference": "79516b22ba364db552fc6dca719fcedc57ca5862" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/estahn/json-query-wrapper/zipball/79516b22ba364db552fc6dca719fcedc57ca5862", + "reference": "79516b22ba364db552fc6dca719fcedc57ca5862", + "shasum": "" + }, + "require": { + "php": "^7.1|^8", + "symfony/process": "^4|^5" + }, + "require-dev": { + "phpunit/phpunit": "^7|^8|^9", + "vimeo/psalm": "4.x-dev" + }, + "type": "library", + "autoload": { + "psr-4": { + "JsonQueryWrapper\\": "src/JsonQueryWrapper" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Enrico Stahn", + "email": "enrico.stahn@gmail.com" + } + ], + "description": "Wrapper for jq, a lightweight and flexible command-line JSON processor", + "homepage": "https://github.com/estahn/json-query-wrapper", + "keywords": [ + "jq", + "json", + "query" + ], + "support": { + "issues": "https://github.com/estahn/json-query-wrapper/issues", + "source": "https://github.com/estahn/json-query-wrapper/tree/v1.0.0" + }, + "time": "2021-01-11T23:19:55+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.2", + "guzzlehttp/psr7": "^2.13", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-05T19:48:21+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" + }, + { + "name": "jarnaiz/behat-junit-formatter", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "https://github.com/j-arnaiz/behat-junit-formatter.git", + "reference": "2f80b3881e04d3cf43e05ab821c0e80675a9846d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/j-arnaiz/behat-junit-formatter/zipball/2f80b3881e04d3cf43e05ab821c0e80675a9846d", + "reference": "2f80b3881e04d3cf43e05ab821c0e80675a9846d", + "shasum": "" + }, + "require": { + "behat/behat": "~3.0", + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "jarnaiz\\JUnitFormatter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "authors": [ + { + "name": "Jesús Arnaiz", + "email": "j.arnaiz@gmail.com" + } + ], + "description": "Behat 3 JUnit xml formatter", + "support": { + "issues": "https://github.com/j-arnaiz/behat-junit-formatter/issues", + "source": "https://github.com/j-arnaiz/behat-junit-formatter/tree/master" + }, + "time": "2016-01-26T17:05:07+00:00" + }, + { + "name": "libresign/behat-builtin-extension", + "version": "v0.6.3", + "source": { + "type": "git", + "url": "https://github.com/LibreSign/behat-builtin-extension.git", + "reference": "d9ddb309bbf36826f47318ca74d67c9e6c5714f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/LibreSign/behat-builtin-extension/zipball/d9ddb309bbf36826f47318ca74d67c9e6c5714f9", + "reference": "d9ddb309bbf36826f47318ca74d67c9e6c5714f9", + "shasum": "" + }, + "require": { + "ext-sockets": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "behat/behat": "^3.15", + "phpunit/phpunit": "^10.5", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpBuiltin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0+" + ], + "authors": [ + { + "name": "Vitor Mattos", + "email": "vitor@php.rio" + } + ], + "description": "Behat extension to run php built-in web server", + "support": { + "issues": "https://github.com/LibreSign/behat-builtin-extension/issues", + "source": "https://github.com/LibreSign/behat-builtin-extension/tree/v0.6.3" + }, + "time": "2024-10-31T18:50:11+00:00" + }, + { + "name": "libresign/nextcloud-behat", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/LibreSign/nextcloud-behat.git", + "reference": "617ffaae34ebf0e3794a09123a376c2598008f48" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/LibreSign/nextcloud-behat/zipball/617ffaae34ebf0e3794a09123a376c2598008f48", + "reference": "617ffaae34ebf0e3794a09123a376c2598008f48", + "shasum": "" + }, + "require": { + "behat/behat": "^3.29", + "estahn/json-query-wrapper": "*", + "guzzlehttp/guzzle": "^7.10", + "libresign/behat-builtin-extension": "^0.6.3", + "php": ">=8.1", + "phpunit/phpunit": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.9", + "donatj/mock-webserver": "^2.9" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": true + } + }, + "autoload": { + "psr-4": { + "Libresign\\NextcloudBehat\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Vitor Mattos", + "email": "vitor@php.rio" + } + ], + "description": "Basic steps for a Nextcloud app", + "keywords": [ + "Behat", + "nextcloud", + "php" + ], + "support": { + "issues": "https://github.com/LibreSign/nextcloud-behat/issues", + "source": "https://github.com/LibreSign/nextcloud-behat/tree/v1.6.0" + }, + "time": "2026-04-11T00:33:49+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-http/guzzle7-adapter", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/guzzle7-adapter.git", + "reference": "03a415fde709c2f25539790fecf4d9a31bc3d0eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/guzzle7-adapter/zipball/03a415fde709c2f25539790fecf4d9a31bc3d0eb", + "reference": "03a415fde709c2f25539790fecf4d9a31bc3d0eb", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.0", + "php": "^7.3 | ^8.0", + "php-http/httplug": "^2.0", + "psr/http-client": "^1.0" + }, + "provide": { + "php-http/async-client-implementation": "1.0", + "php-http/client-implementation": "1.0", + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "php-http/client-integration-tests": "^3.0", + "php-http/message-factory": "^1.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^8.0|^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Adapter\\Guzzle7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "Guzzle 7 HTTP Adapter", + "homepage": "http://httplug.io", + "keywords": [ + "Guzzle", + "http" + ], + "support": { + "issues": "https://github.com/php-http/guzzle7-adapter/issues", + "source": "https://github.com/php-http/guzzle7-adapter/tree/1.1.0" + }, + "time": "2024-11-26T11:14:36+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/message", + "version": "1.16.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.2" + }, + "time": "2024-10-02T11:34:13+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.34", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-27T08:38:28+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:55:18+00:00" + }, + { + "name": "symfony/console", + "version": "v6.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", + "reference": "3b8473e0d14157f2d22b0a0d7259ad23483d1e6d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T13:08:31+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v5.4.51", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", + "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v5.4.51" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:53:37+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T00:47:26+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3" + }, + "plugin-api-version": "2.9.0" +} From 94a518f4e7e203e22eaf2e2fd610de954665cf0d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:07:01 -0300 Subject: [PATCH 156/283] ci: restore read-only Behat workflow --- .github/workflows/behat-sqlite.yml | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index ddec540..ff6c92f 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -7,7 +7,7 @@ on: pull_request: permissions: - contents: write + contents: read concurrency: group: behat-sqlite-${{ github.head_ref || github.run_id }} @@ -66,8 +66,7 @@ jobs: - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: true - ref: ${{ github.head_ref }} + persist-credentials: false path: apps/${{ env.APP_NAME }} - name: Set up PHP 8.3 @@ -88,18 +87,6 @@ jobs: composer install --no-dev --no-scripts composer --working-dir=tests/integration install --prefer-dist --no-progress - - name: Commit generated integration lock - if: matrix.server-version == 'master' - working-directory: apps/${{ env.APP_NAME }} - run: | - if git status --porcelain -- tests/integration/composer.lock | grep -q .; then - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add tests/integration/composer.lock - git commit -s -m 'test: lock Behat integration dependencies' - git push origin HEAD:${GITHUB_HEAD_REF} - fi - - name: Set up Nextcloud run: | mkdir data From 167b2787eda689a6650c3e54ca3f36301e5c507e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:32:42 -0300 Subject: [PATCH 157/283] test: add Infection mutation testing --- vendor-bin/infection/composer.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 vendor-bin/infection/composer.json diff --git a/vendor-bin/infection/composer.json b/vendor-bin/infection/composer.json new file mode 100644 index 0000000..7e868ba --- /dev/null +++ b/vendor-bin/infection/composer.json @@ -0,0 +1,13 @@ +{ + "require-dev": { + "infection/infection": "^0.35" + }, + "config": { + "allow-plugins": { + "infection/extension-installer": true + }, + "platform": { + "php": "8.3.30" + } + } +} From e4f52efee4dcfd5b4ec48691118b7aacb2020013 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:32:52 -0300 Subject: [PATCH 158/283] test: configure Infection baseline --- infection.json5 | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 infection.json5 diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..c418312 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Vitor Mattos +// SPDX-License-Identifier: AGPL-3.0-or-later + +{ + "$schema": "https://raw.githubusercontent.com/infection/infection/0.35.2/resources/schema.json", + "source": { + "directories": [ + "lib" + ], + "excludes": [ + "AppInfo", + "Migration" + ] + }, + "bootstrap": "tests/php/bootstrap.php", + "phpUnit": { + "configDir": "tests/php" + }, + "minMsi": 0, + "minCoveredMsi": 0, + "mutators": { + "@default": true + } +} From 1f4bfe2ec694e89a219831f005c81949f9ad5c27 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:33:21 -0300 Subject: [PATCH 159/283] test: add mutation testing command --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 5aaf79b..efc9999 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ }, "scripts": { "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './node_modules/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", + "mutation:test": "vendor-bin/infection/vendor/bin/infection --configuration=infection.json5", "openapi": "generate-spec --verbose && npm run typescript:generate", "psalm": "psalm --no-cache --threads=$(nproc)", "test:unit": "phpunit -c tests/php/phpunit.xml --testsuite unit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", From 69a9d1a9ae7411a3f79f356bd4e24a0052f86725 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:33:41 -0300 Subject: [PATCH 160/283] ci: add mutation testing workflow --- .github/workflows/infection.yml | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 .github/workflows/infection.yml diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml new file mode 100644 index 0000000..89d8053 --- /dev/null +++ b/.github/workflows/infection.yml @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2026 Vitor Mattos +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Infection + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: infection-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest + outputs: + php-min: ${{ steps.versions.outputs.php-min }} + php-max: ${{ steps.versions.outputs.php-max }} + php-available: ${{ steps.versions.outputs.php-available }} + branches-max: ${{ steps.versions.outputs.branches-max }} + + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + src: ${{ steps.changes.outputs.src }} + + steps: + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/infection.yml' + - 'appinfo/**' + - 'lib/**' + - 'tests/php/**' + - 'vendor-bin/infection/**' + - 'infection.json5' + - 'composer.json' + - 'composer.lock' + + infection: + runs-on: ubuntu-latest + needs: [changes, matrix] + if: needs.changes.outputs.src != 'false' + timeout-minutes: 30 + env: + APP_NAME: usage_statistics_server + + name: PHP ${{ needs.matrix.outputs.php-min }} Nextcloud ${{ needs.matrix.outputs.branches-max }} + + steps: + - name: Checkout server + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ needs.matrix.outputs.branches-max }} + + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Set up PHP ${{ needs.matrix.outputs.php-min }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: ${{ needs.matrix.outputs.php-min }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib + coverage: xdebug + ini-file: development + ini-values: disable_functions=, memory_limit=2G + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer install + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install \ + --verbose \ + --database=sqlite \ + --database-name=nextcloud \ + --admin-user admin \ + --admin-pass admin + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: Run Infection + working-directory: apps/${{ env.APP_NAME }} + env: + COMPOSER_PROCESS_TIMEOUT: 0 + XDEBUG_MODE: coverage + run: | + set -o pipefail + composer mutation:test -- --no-progress --no-interaction 2>&1 | tee infection.log + + - name: Upload Infection log + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: infection-log + path: apps/${{ env.APP_NAME }}/infection.log + if-no-files-found: ignore + retention-days: 14 + + summary: + permissions: + contents: none + runs-on: ubuntu-latest + needs: [changes, infection] + if: always() + name: infection-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.infection.result != 'success' }}; then exit 1; fi From e5ab2338f09dd425bb30df504dc6f29f3fb24490 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:34:02 -0300 Subject: [PATCH 161/283] ci: derive Behat support boundaries --- .github/workflows/behat-sqlite.yml | 39 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index ff6c92f..d7f8497 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -14,6 +14,25 @@ concurrency: cancel-in-progress: true jobs: + matrix: + runs-on: ubuntu-latest + outputs: + php-min: ${{ steps.versions.outputs.php-min }} + php-max: ${{ steps.versions.outputs.php-max }} + php-available: ${{ steps.versions.outputs.php-available }} + branches-min: ${{ steps.versions.outputs.branches-min }} + branches-max: ${{ steps.versions.outputs.branches-max }} + + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 + changes: runs-on: ubuntu-latest permissions: @@ -38,21 +57,25 @@ jobs: behat-sqlite: runs-on: ubuntu-latest - needs: changes + needs: [changes, matrix] if: needs.changes.outputs.src != 'false' timeout-minutes: 30 strategy: fail-fast: false matrix: - server-version: - - stable35 - - master + include: + - php-version: ${{ needs.matrix.outputs.php-min }} + server-version: ${{ needs.matrix.outputs.branches-min }} + boundary: minimum + - php-version: ${{ needs.matrix.outputs.php-available }} + server-version: ${{ needs.matrix.outputs.branches-max }} + boundary: maximum env: APP_NAME: usage_statistics_server - name: SQLite PHP 8.3 Nextcloud ${{ matrix.server-version }} + name: SQLite ${{ matrix.boundary }} PHP ${{ matrix.php-version }} Nextcloud ${{ matrix.server-version }} steps: - name: Checkout server @@ -69,10 +92,10 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Set up PHP 8.3 + - name: Set up PHP ${{ matrix.php-version }} uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: - php-version: '8.3' + php-version: ${{ matrix.php-version }} extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib coverage: none ini-file: development @@ -114,7 +137,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: behat-results-${{ matrix.server-version }}-php8.3 + name: behat-results-${{ matrix.boundary }}-nc${{ matrix.server-version }}-php${{ matrix.php-version }} path: apps/${{ env.APP_NAME }}/tests/integration/output/ if-no-files-found: ignore retention-days: 30 From ffcbe96c78c3f0e4f3d87d29989d551e84e8b510 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:35:39 -0300 Subject: [PATCH 162/283] fix: use forwarded Infection binary --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index efc9999..922ac07 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "scripts": { "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './node_modules/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", - "mutation:test": "vendor-bin/infection/vendor/bin/infection --configuration=infection.json5", + "mutation:test": "vendor/bin/infection --configuration=infection.json5", "openapi": "generate-spec --verbose && npm run typescript:generate", "psalm": "psalm --no-cache --threads=$(nproc)", "test:unit": "phpunit -c tests/php/phpunit.xml --testsuite unit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", From 6b31f2f6a5f7020448c0cdf619ee6de07905bfd9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:36:46 -0300 Subject: [PATCH 163/283] ci: align PHP version matrix action --- .github/workflows/lint-php.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml index 15b415a..f338dae 100644 --- a/.github/workflows/lint-php.yml +++ b/.github/workflows/lint-php.yml @@ -22,31 +22,34 @@ jobs: steps: - name: Checkout app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Get version matrix id: versions - uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2 + uses: nextcloud-libraries/nextcloud-version-matrix@cd0211ffcef1065e2020cd579e4843b8746e7a58 # v1.3.3 php-lint: runs-on: ubuntu-latest needs: matrix - name: php-lint + name: php-lint PHP ${{ matrix.php-versions }} strategy: + fail-fast: false matrix: - php-versions: ['${{ needs.matrix.outputs.php-min }}', '${{ needs.matrix.outputs.php-max }}'] + php-versions: + - ${{ needs.matrix.outputs.php-min }} + - ${{ needs.matrix.outputs.php-max }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Set up php ${{ matrix.php-versions }} - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 + - name: Set up PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: ${{ matrix.php-versions }} extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite From 97585a8c8703a74045868e10aba42ddc37d128d0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:37:29 -0300 Subject: [PATCH 164/283] ci: preserve Infection dependency lock --- .github/workflows/infection.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 89d8053..9b5f7f2 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -118,12 +118,14 @@ jobs: set -o pipefail composer mutation:test -- --no-progress --no-interaction 2>&1 | tee infection.log - - name: Upload Infection log + - name: Upload Infection results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: infection-log - path: apps/${{ env.APP_NAME }}/infection.log + name: infection-results + path: | + apps/${{ env.APP_NAME }}/infection.log + apps/${{ env.APP_NAME }}/vendor-bin/infection/composer.lock if-no-files-found: ignore retention-days: 14 From 6f13f98327cd0a72b21bd5891af02f99a0053880 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:39:25 -0300 Subject: [PATCH 165/283] test: avoid remote PHPUnit schema dependency --- tests/php/phpunit.xml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/php/phpunit.xml b/tests/php/phpunit.xml index 9ccf245..744dc36 100644 --- a/tests/php/phpunit.xml +++ b/tests/php/phpunit.xml @@ -1,7 +1,5 @@ - From 6ba73701eac7037c6923386c085efe283833c558 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:40:32 -0300 Subject: [PATCH 166/283] ci: use explicit maximum PHP boundary --- .github/workflows/behat-sqlite.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index d7f8497..738bec7 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -19,7 +19,6 @@ jobs: outputs: php-min: ${{ steps.versions.outputs.php-min }} php-max: ${{ steps.versions.outputs.php-max }} - php-available: ${{ steps.versions.outputs.php-available }} branches-min: ${{ steps.versions.outputs.branches-min }} branches-max: ${{ steps.versions.outputs.branches-max }} @@ -68,7 +67,7 @@ jobs: - php-version: ${{ needs.matrix.outputs.php-min }} server-version: ${{ needs.matrix.outputs.branches-min }} boundary: minimum - - php-version: ${{ needs.matrix.outputs.php-available }} + - php-version: ${{ needs.matrix.outputs.php-max }} server-version: ${{ needs.matrix.outputs.branches-max }} boundary: maximum From 68476889bacb5e5ca13b27ff0a5024ca20c81bcb Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:42:40 -0300 Subject: [PATCH 167/283] ci: reuse validated coverage for Infection --- .github/workflows/infection.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 9b5f7f2..6561e4e 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -19,7 +19,6 @@ jobs: outputs: php-min: ${{ steps.versions.outputs.php-min }} php-max: ${{ steps.versions.outputs.php-max }} - php-available: ${{ steps.versions.outputs.php-available }} branches-max: ${{ steps.versions.outputs.branches-max }} steps: @@ -109,6 +108,20 @@ jobs: --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} + - name: Generate mutation coverage + working-directory: apps/${{ env.APP_NAME }} + env: + XDEBUG_MODE: coverage + run: | + mkdir -p build/coverage/coverage-xml + vendor/bin/phpunit -c tests/php/phpunit.xml \ + --coverage-xml build/coverage/coverage-xml \ + --log-junit build/coverage/junit.xml \ + --colors=always \ + --fail-on-warning \ + --fail-on-risky + php -r '$xml = new DOMDocument(); if (!$xml->load("build/coverage/coverage-xml/index.xml")) { exit(1); }' + - name: Run Infection working-directory: apps/${{ env.APP_NAME }} env: @@ -116,7 +129,12 @@ jobs: XDEBUG_MODE: coverage run: | set -o pipefail - composer mutation:test -- --no-progress --no-interaction 2>&1 | tee infection.log + composer mutation:test -- \ + --coverage=build/coverage \ + --skip-initial-tests \ + --threads=max \ + --no-progress \ + --no-interaction 2>&1 | tee infection.log - name: Upload Infection results if: always() @@ -125,6 +143,8 @@ jobs: name: infection-results path: | apps/${{ env.APP_NAME }}/infection.log + apps/${{ env.APP_NAME }}/build/coverage/junit.xml + apps/${{ env.APP_NAME }}/build/coverage/coverage-xml/index.xml apps/${{ env.APP_NAME }}/vendor-bin/infection/composer.lock if-no-files-found: ignore retention-days: 14 From 29b5905985d4615bb66531d7a28d5f0ff0fa2eb7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:46:02 -0300 Subject: [PATCH 168/283] ci: use PCOV for mutation coverage --- .github/workflows/infection.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 6561e4e..a5acf9a 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -85,7 +85,7 @@ jobs: with: php-version: ${{ needs.matrix.outputs.php-min }} extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib - coverage: xdebug + coverage: pcov ini-file: development ini-values: disable_functions=, memory_limit=2G env: @@ -110,8 +110,6 @@ jobs: - name: Generate mutation coverage working-directory: apps/${{ env.APP_NAME }} - env: - XDEBUG_MODE: coverage run: | mkdir -p build/coverage/coverage-xml vendor/bin/phpunit -c tests/php/phpunit.xml \ @@ -126,7 +124,6 @@ jobs: working-directory: apps/${{ env.APP_NAME }} env: COMPOSER_PROCESS_TIMEOUT: 0 - XDEBUG_MODE: coverage run: | set -o pipefail composer mutation:test -- \ From e06f28b950c0462b8d1a1b10d3ef46d6c2beb763 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:47:53 -0300 Subject: [PATCH 169/283] fix: run Infection from isolated vendor bin --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 922ac07..91efc61 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ }, "scripts": { "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './node_modules/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", - "mutation:test": "vendor/bin/infection --configuration=infection.json5", + "mutation:test": "php vendor-bin/infection/vendor/infection/infection/bin/infection --configuration=infection.json5", "openapi": "generate-spec --verbose && npm run typescript:generate", "psalm": "psalm --no-cache --threads=$(nproc)", "test:unit": "phpunit -c tests/php/phpunit.xml --testsuite unit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", From 4d404596f4dfb27e393afd4eb852d8cceffc6c63 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:50:18 -0300 Subject: [PATCH 170/283] test: diagnose Infection coverage parser --- .github/workflows/infection.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index a5acf9a..137e9f5 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -119,6 +119,7 @@ jobs: --fail-on-warning \ --fail-on-risky php -r '$xml = new DOMDocument(); if (!$xml->load("build/coverage/coverage-xml/index.xml")) { exit(1); }' + php -r 'require "vendor-bin/infection/vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' - name: Run Infection working-directory: apps/${{ env.APP_NAME }} From 4f7ddc393b363b3408993655fcba4e6994548e4e Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:51:41 -0300 Subject: [PATCH 171/283] test: reproduce Infection autoload stack --- .github/workflows/infection.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 137e9f5..f5c5225 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -120,6 +120,7 @@ jobs: --fail-on-risky php -r '$xml = new DOMDocument(); if (!$xml->load("build/coverage/coverage-xml/index.xml")) { exit(1); }' php -r 'require "vendor-bin/infection/vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' + php -r 'require "vendor-bin/infection/vendor/autoload.php"; require "vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' - name: Run Infection working-directory: apps/${{ env.APP_NAME }} From 5a31cb8038c141248793054b9799e7572b8b2b42 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:52:45 -0300 Subject: [PATCH 172/283] test: include Nextcloud bootstrap in parser diagnosis --- .github/workflows/infection.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index f5c5225..6a4c019 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -121,6 +121,7 @@ jobs: php -r '$xml = new DOMDocument(); if (!$xml->load("build/coverage/coverage-xml/index.xml")) { exit(1); }' php -r 'require "vendor-bin/infection/vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' php -r 'require "vendor-bin/infection/vendor/autoload.php"; require "vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' + php -r 'require "vendor-bin/infection/vendor/autoload.php"; require "tests/php/bootstrap.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' - name: Run Infection working-directory: apps/${{ env.APP_NAME }} From 85563e25733f4cb32e83ceb8716d2ce0a8e65af0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:54:20 -0300 Subject: [PATCH 173/283] test: add isolated Infection bootstrap --- tests/php/infection-bootstrap.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/php/infection-bootstrap.php diff --git a/tests/php/infection-bootstrap.php b/tests/php/infection-bootstrap.php new file mode 100644 index 0000000..b4931e1 --- /dev/null +++ b/tests/php/infection-bootstrap.php @@ -0,0 +1,14 @@ + Date: Tue, 8 Sep 2026 07:54:33 -0300 Subject: [PATCH 174/283] test: use isolated Infection bootstrap --- infection.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infection.json5 b/infection.json5 index c418312..e4f6d02 100644 --- a/infection.json5 +++ b/infection.json5 @@ -12,7 +12,7 @@ "Migration" ] }, - "bootstrap": "tests/php/bootstrap.php", + "bootstrap": "tests/php/infection-bootstrap.php", "phpUnit": { "configDir": "tests/php" }, From 6607dbf1236054d4e992d4f73d3510b246d5c95c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 07:55:01 -0300 Subject: [PATCH 175/283] ci: finalize Infection workflow --- .github/workflows/infection.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 6a4c019..568e2c1 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -18,7 +18,6 @@ jobs: runs-on: ubuntu-latest outputs: php-min: ${{ steps.versions.outputs.php-min }} - php-max: ${{ steps.versions.outputs.php-max }} branches-max: ${{ steps.versions.outputs.branches-max }} steps: @@ -118,10 +117,6 @@ jobs: --colors=always \ --fail-on-warning \ --fail-on-risky - php -r '$xml = new DOMDocument(); if (!$xml->load("build/coverage/coverage-xml/index.xml")) { exit(1); }' - php -r 'require "vendor-bin/infection/vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' - php -r 'require "vendor-bin/infection/vendor/autoload.php"; require "vendor/autoload.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' - php -r 'require "vendor-bin/infection/vendor/autoload.php"; require "tests/php/bootstrap.php"; Infection\TestFramework\XML\SafeDOMXPath::fromFile("build/coverage/coverage-xml/index.xml");' - name: Run Infection working-directory: apps/${{ env.APP_NAME }} @@ -143,8 +138,6 @@ jobs: name: infection-results path: | apps/${{ env.APP_NAME }}/infection.log - apps/${{ env.APP_NAME }}/build/coverage/junit.xml - apps/${{ env.APP_NAME }}/build/coverage/coverage-xml/index.xml apps/${{ env.APP_NAME }}/vendor-bin/infection/composer.lock if-no-files-found: ignore retention-days: 14 From 27dcc29a3b4fff47fe69b7b1846f7fa7e7fe1c7a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:17:49 -0300 Subject: [PATCH 176/283] ci: scope PCOV to app sources --- .github/workflows/infection.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 568e2c1..0684c05 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -86,7 +86,7 @@ jobs: extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib coverage: pcov ini-file: development - ini-values: disable_functions=, memory_limit=2G + ini-values: disable_functions=, memory_limit=2G, pcov.directory=${{ github.workspace }}/apps/${{ env.APP_NAME }}/lib env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -138,6 +138,8 @@ jobs: name: infection-results path: | apps/${{ env.APP_NAME }}/infection.log + apps/${{ env.APP_NAME }}/build/coverage/junit.xml + apps/${{ env.APP_NAME }}/build/coverage/coverage-xml/index.xml apps/${{ env.APP_NAME }}/vendor-bin/infection/composer.lock if-no-files-found: ignore retention-days: 14 From 55420fd89aa2dc24b43fec209c1124aea1d6b8d2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:26:02 -0300 Subject: [PATCH 177/283] test: harden report contract boundaries --- tests/php/Unit/Service/ReportFactoryTest.php | 160 ++++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/tests/php/Unit/Service/ReportFactoryTest.php b/tests/php/Unit/Service/ReportFactoryTest.php index 39c1452..1450c9c 100644 --- a/tests/php/Unit/Service/ReportFactoryTest.php +++ b/tests/php/Unit/Service/ReportFactoryTest.php @@ -13,8 +13,16 @@ final class ReportFactoryTest extends TestCase { public function testBuildsValidReportAndNormalizesPeriodToUtc(): void { $report = (new ReportFactory())->fromPayload($this->validPayload()); + self::assertSame(1, $report->protocolVersion); self::assertSame('libresign', $report->application); + self::assertSame(str_repeat('a', 64), $report->installationId); + self::assertSame(1, $report->schemaVersion); + self::assertSame('2026-08-01T03:00:00+00:00', $report->periodStart->format(DATE_ATOM)); + self::assertSame('2026-09-01T03:00:00+00:00', $report->periodEnd->format(DATE_ATOM)); self::assertSame('UTC', $report->periodStart->getTimezone()->getName()); + self::assertSame('usage', $report->metrics[0]->category); + self::assertSame('requests_completed', $report->metrics[0]->key); + self::assertSame('integer', $report->metrics[0]->type); self::assertSame(72, $report->metrics[0]->value); } @@ -26,6 +34,19 @@ public function testRejectsDuplicateMetric(): void { (new ReportFactory())->fromPayload($payload); } + public function testAllowsSameKeyInDifferentCategories(): void { + $payload = $this->validPayload(); + $payload['metrics'][] = [ + 'category' => 'other', + 'key' => 'requests_completed', + 'type' => 'integer', + 'value' => 1, + ]; + + $report = (new ReportFactory())->fromPayload($payload); + self::assertCount(2, $report->metrics); + } + #[DataProvider('invalidTimestampProvider')] public function testRejectsNonRfc3339OrInvalidTimestamp(string $timestamp): void { $payload = $this->validPayload(); @@ -41,8 +62,143 @@ public static function invalidTimestampProvider(): iterable { yield 'space separator' => ['2026-08-01 00:00:00Z']; yield 'missing timezone' => ['2026-08-01T00:00:00']; yield 'invalid calendar date' => ['2026-02-30T00:00:00Z']; - yield 'invalid hour' => ['2026-08-01T25:00:00Z']; - yield 'invalid timezone' => ['2026-08-01T00:00:00+25:00']; + yield 'invalid hour' => ['2026-08-01T24:00:00Z']; + yield 'invalid minute' => ['2026-08-01T23:60:00Z']; + yield 'invalid second' => ['2026-08-01T23:59:60Z']; + yield 'invalid timezone hour' => ['2026-08-01T00:00:00+24:00']; + yield 'invalid timezone minute' => ['2026-08-01T00:00:00+23:60']; + } + + #[DataProvider('invalidPeriodProvider')] + public function testRejectsInvalidPeriod(string $start, string $end): void { + $payload = $this->validPayload(); + $payload['period'] = ['start' => $start, 'end' => $end]; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + /** @return iterable */ + public static function invalidPeriodProvider(): iterable { + yield 'same instant' => ['2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z']; + yield 'end before start' => ['2026-08-02T00:00:00Z', '2026-08-01T00:00:00Z']; + yield 'more than 31 days' => ['2026-08-01T00:00:00Z', '2026-09-01T00:00:01Z']; + } + + public function testAcceptsMaximumPeriodLength(): void { + $payload = $this->validPayload(); + $payload['period'] = [ + 'start' => '2026-08-01T00:00:00Z', + 'end' => '2026-09-01T00:00:00Z', + ]; + + self::assertInstanceOf(\DateTimeImmutable::class, (new ReportFactory())->fromPayload($payload)->periodEnd); + } + + #[DataProvider('invalidScalarProvider')] + public function testRejectsInvalidTopLevelScalar(string $field, mixed $value): void { + $payload = $this->validPayload(); + $payload[$field] = $value; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + /** @return iterable */ + public static function invalidScalarProvider(): iterable { + yield 'protocol version' => ['protocolVersion', 2]; + yield 'schema version zero' => ['schemaVersion', 0]; + yield 'schema version string' => ['schemaVersion', '1']; + yield 'application empty' => ['application', '']; + yield 'application invalid chars' => ['application', 'libre sign']; + yield 'application too long' => ['application', str_repeat('a', 129)]; + yield 'installation id too long' => ['installationId', str_repeat('a', 129)]; + } + + #[DataProvider('invalidMetricsContainerProvider')] + public function testRejectsInvalidMetricsContainer(mixed $metrics): void { + $payload = $this->validPayload(); + $payload['metrics'] = $metrics; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + /** @return iterable */ + public static function invalidMetricsContainerProvider(): iterable { + yield 'null' => [null]; + yield 'object-like array' => [['metric' => []]]; + yield 'empty list' => [[]]; + yield 'too many' => [array_fill(0, 257, [ + 'category' => 'usage', + 'key' => 'same', + 'type' => 'integer', + 'value' => 1, + ])]; + } + + public function testAcceptsMaximumNumberOfMetrics(): void { + $payload = $this->validPayload(); + $payload['metrics'] = []; + for ($i = 0; $i < 256; ++$i) { + $payload['metrics'][] = [ + 'category' => 'usage', + 'key' => 'metric_' . $i, + 'type' => 'integer', + 'value' => $i, + ]; + } + + self::assertCount(256, (new ReportFactory())->fromPayload($payload)->metrics); + } + + #[DataProvider('invalidMetricProvider')] + public function testRejectsInvalidMetric(array $metric): void { + $payload = $this->validPayload(); + $payload['metrics'] = [$metric]; + + $this->expectException(InvalidReport::class); + (new ReportFactory())->fromPayload($payload); + } + + /** @return iterable}> */ + public static function invalidMetricProvider(): iterable { + yield 'unknown type' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'unknown', 'value' => 1, + ]]; + yield 'integer with float' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'value' => 1.5, + ]]; + yield 'number with string' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'number', 'value' => '1', + ]]; + yield 'number with infinity' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'number', 'value' => INF, + ]]; + yield 'boolean with integer' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'boolean', 'value' => 1, + ]]; + yield 'string too long' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'string', 'value' => str_repeat('x', 1025), + ]]; + yield 'category too long' => [[ + 'category' => str_repeat('a', 129), 'key' => 'x', 'type' => 'integer', 'value' => 1, + ]]; + yield 'key too long' => [[ + 'category' => 'usage', 'key' => str_repeat('a', 513), 'type' => 'integer', 'value' => 1, + ]]; + } + + public function testAcceptsBoundaryMetricValues(): void { + $payload = $this->validPayload(); + $payload['metrics'] = [ + ['category' => str_repeat('a', 128), 'key' => str_repeat('b', 512), 'type' => 'string', 'value' => str_repeat('x', 1024)], + ['category' => 'usage', 'key' => 'number_int', 'type' => 'number', 'value' => 1], + ['category' => 'usage', 'key' => 'number_float', 'type' => 'number', 'value' => 1.5], + ['category' => 'usage', 'key' => 'flag', 'type' => 'boolean', 'value' => false], + ]; + + self::assertCount(4, (new ReportFactory())->fromPayload($payload)->metrics); } #[DataProvider('unknownFieldProvider')] From e04faaa911838f129db792d78665e1c285e078f7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:27:16 -0300 Subject: [PATCH 178/283] test: harden schema validation contracts --- .../php/Unit/Service/SchemaValidatorTest.php | 254 +++++++++++++++--- 1 file changed, 218 insertions(+), 36 deletions(-) diff --git a/tests/php/Unit/Service/SchemaValidatorTest.php b/tests/php/Unit/Service/SchemaValidatorTest.php index e2d5716..033e5f0 100644 --- a/tests/php/Unit/Service/SchemaValidatorTest.php +++ b/tests/php/Unit/Service/SchemaValidatorTest.php @@ -18,37 +18,209 @@ protected function setUp(): void { $this->validator = new SchemaValidator(); } - public function testValidDefinitionAndReport(): void { + public function testValidDefinitionIsNormalized(): void { $definition = $this->validator->validateDefinition($this->definition()); - $report = new Report( - 1, - 'libresign', - 'installation-1', - 1, - new \DateTimeImmutable('2026-08-01T00:00:00Z'), - new \DateTimeImmutable('2026-09-01T00:00:00Z'), - [ - new Metric('server', 'version', 'string', '12.0.0'), - new Metric('usage', 'requests_completed', 'integer', 42), - ], - ); + self::assertSame('libresign', $definition['application']); + self::assertSame(1, $definition['schemaVersion']); + self::assertCount(2, $definition['metrics']); + self::assertSame([ + 'category' => 'server', + 'key' => 'version', + 'type' => 'string', + 'kind' => 'snapshot', + 'aggregation' => 'distribution', + 'description' => 'LibreSign version', + 'required' => true, + ], $definition['metrics'][0]); + } - $this->validator->validateReport($report, $definition); + public function testDefaultsDescriptionAndRequired(): void { + $definition = $this->definition(); + unset($definition['metrics'][0]['description'], $definition['metrics'][0]['required']); + + $validated = $this->validator->validateDefinition($definition); + self::assertSame('', $validated['metrics'][0]['description']); + self::assertFalse($validated['metrics'][0]['required']); + } + + #[DataProvider('invalidDefinitionScalarProvider')] + public function testRejectsInvalidDefinitionScalar(string $field, mixed $value): void { + $definition = $this->definition(); + $definition[$field] = $value; + + $this->expectException(InvalidReport::class); + $this->validator->validateDefinition($definition); + } + + /** @return iterable */ + public static function invalidDefinitionScalarProvider(): iterable { + yield 'empty application' => ['application', '']; + yield 'invalid application chars' => ['application', 'libre sign']; + yield 'application too long' => ['application', str_repeat('a', 129)]; + yield 'schema version zero' => ['schemaVersion', 0]; + yield 'schema version string' => ['schemaVersion', '1']; + } + + #[DataProvider('invalidMetricsContainerProvider')] + public function testRejectsInvalidMetricsContainer(mixed $metrics): void { + $definition = $this->definition(); + $definition['metrics'] = $metrics; + + $this->expectException(InvalidReport::class); + $this->validator->validateDefinition($definition); + } + + /** @return iterable */ + public static function invalidMetricsContainerProvider(): iterable { + yield 'null' => [null]; + yield 'object-like array' => [['metric' => []]]; + yield 'empty list' => [[]]; + yield 'too many metrics' => [array_fill(0, 257, [ + 'category' => 'usage', + 'key' => 'same', + 'type' => 'integer', + 'kind' => 'period', + 'aggregation' => 'numerical', + ])]; + } + + public function testAcceptsMaximumNumberOfMetrics(): void { + $definition = $this->definition(); + $definition['metrics'] = []; + for ($i = 0; $i < 256; ++$i) { + $definition['metrics'][] = [ + 'category' => 'usage', + 'key' => 'metric_' . $i, + 'type' => 'integer', + 'kind' => 'period', + 'aggregation' => 'numerical', + ]; + } + + self::assertCount(256, $this->validator->validateDefinition($definition)['metrics']); + } + + #[DataProvider('invalidMetricDefinitionProvider')] + public function testRejectsInvalidMetricDefinition(array $metric): void { + $definition = $this->definition(); + $definition['metrics'] = [$metric]; + + $this->expectException(InvalidReport::class); + $this->validator->validateDefinition($definition); + } + + /** @return iterable}> */ + public static function invalidMetricDefinitionProvider(): iterable { + yield 'category too long' => [[ + 'category' => str_repeat('a', 129), 'key' => 'x', 'type' => 'integer', 'kind' => 'period', 'aggregation' => 'numerical', + ]]; + yield 'key too long' => [[ + 'category' => 'usage', 'key' => str_repeat('a', 513), 'type' => 'integer', 'kind' => 'period', 'aggregation' => 'numerical', + ]]; + yield 'unknown type' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'unknown', 'kind' => 'period', 'aggregation' => 'none', + ]]; + yield 'non-string type' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 1, 'kind' => 'period', 'aggregation' => 'none', + ]]; + yield 'unknown kind' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 'unknown', 'aggregation' => 'none', + ]]; + yield 'non-string kind' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 1, 'aggregation' => 'none', + ]]; + yield 'unknown aggregation' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 'period', 'aggregation' => 'unknown', + ]]; + yield 'non-string aggregation' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 'period', 'aggregation' => 1, + ]]; + yield 'numerical string' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'string', 'kind' => 'period', 'aggregation' => 'numerical', + ]]; + yield 'description too long' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 'period', 'aggregation' => 'numerical', 'description' => str_repeat('a', 513), + ]]; + yield 'description wrong type' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 'period', 'aggregation' => 'numerical', 'description' => 1, + ]]; + yield 'required wrong type' => [[ + 'category' => 'usage', 'key' => 'x', 'type' => 'integer', 'kind' => 'period', 'aggregation' => 'numerical', 'required' => 1, + ]]; + } + + public function testAcceptsMetricBoundaryLengths(): void { + $definition = $this->definition(); + $definition['metrics'] = [[ + 'category' => str_repeat('a', 128), + 'key' => str_repeat('b', 512), + 'type' => 'number', + 'kind' => 'period', + 'aggregation' => 'numerical', + 'description' => str_repeat('x', 512), + 'required' => false, + ]]; + + $validated = $this->validator->validateDefinition($definition); + self::assertSame(str_repeat('x', 512), $validated['metrics'][0]['description']); + } + + public function testRejectsDuplicateMetricIdentity(): void { + $definition = $this->definition(); + $definition['metrics'][] = $definition['metrics'][0]; + + $this->expectException(InvalidReport::class); + $this->validator->validateDefinition($definition); + } + + public function testAllowsSameKeyInDifferentCategories(): void { + $definition = $this->definition(); + $definition['metrics'][] = [ + 'category' => 'other', + 'key' => 'version', + 'type' => 'string', + 'kind' => 'snapshot', + 'aggregation' => 'distribution', + ]; + + self::assertCount(3, $this->validator->validateDefinition($definition)['metrics']); + } + + public function testValidReport(): void { + $this->validator->validateReport($this->report(), $this->validator->validateDefinition($this->definition())); self::assertTrue(true); } + #[DataProvider('reportIdentityMismatchProvider')] + public function testRejectsReportIdentityMismatch(string $application, int $schemaVersion): void { + $definition = $this->validator->validateDefinition($this->definition()); + $report = $this->report($application, $schemaVersion); + + $this->expectException(InvalidReport::class); + $this->validator->validateReport($report, $definition); + } + + /** @return iterable */ + public static function reportIdentityMismatchProvider(): iterable { + yield 'application differs' => ['other', 1]; + yield 'schema version differs' => ['libresign', 2]; + } + public function testRejectsUnknownMetric(): void { $definition = $this->validator->validateDefinition($this->definition()); - $report = new Report( - 1, - 'libresign', - 'installation-1', - 1, - new \DateTimeImmutable('2026-08-01T00:00:00Z'), - new \DateTimeImmutable('2026-09-01T00:00:00Z'), - [new Metric('usage', 'unknown', 'integer', 1)], - ); + $report = $this->report(metrics: [new Metric('usage', 'unknown', 'integer', 1)]); + + $this->expectException(InvalidReport::class); + $this->validator->validateReport($report, $definition); + } + + public function testRejectsMetricTypeMismatch(): void { + $definition = $this->validator->validateDefinition($this->definition()); + $report = $this->report(metrics: [ + new Metric('server', 'version', 'integer', 1), + new Metric('usage', 'requests_completed', 'integer', 42), + ]); $this->expectException(InvalidReport::class); $this->validator->validateReport($report, $definition); @@ -56,26 +228,20 @@ public function testRejectsUnknownMetric(): void { public function testRejectsMissingRequiredMetric(): void { $definition = $this->validator->validateDefinition($this->definition()); - $report = new Report( - 1, - 'libresign', - 'installation-1', - 1, - new \DateTimeImmutable('2026-08-01T00:00:00Z'), - new \DateTimeImmutable('2026-09-01T00:00:00Z'), - [new Metric('server', 'version', 'string', '12.0.0')], - ); + $report = $this->report(metrics: [new Metric('server', 'version', 'string', '12.0.0')]); $this->expectException(InvalidReport::class); $this->validator->validateReport($report, $definition); } - public function testRejectsNumericalAggregationForStringMetric(): void { + public function testAllowsMissingOptionalMetric(): void { $definition = $this->definition(); - $definition['metrics'][0]['aggregation'] = 'numerical'; + $definition['metrics'][1]['required'] = false; + $definition = $this->validator->validateDefinition($definition); + $report = $this->report(metrics: [new Metric('server', 'version', 'string', '12.0.0')]); - $this->expectException(InvalidReport::class); - $this->validator->validateDefinition($definition); + $this->validator->validateReport($report, $definition); + self::assertTrue(true); } #[DataProvider('unknownSchemaFieldProvider')] @@ -98,6 +264,22 @@ public static function unknownSchemaFieldProvider(): iterable { yield 'metric' => ['metric']; } + /** @param list|null $metrics */ + private function report(string $application = 'libresign', int $schemaVersion = 1, ?array $metrics = null): Report { + return new Report( + 1, + $application, + 'installation-1', + $schemaVersion, + new \DateTimeImmutable('2026-08-01T00:00:00Z'), + new \DateTimeImmutable('2026-09-01T00:00:00Z'), + $metrics ?? [ + new Metric('server', 'version', 'string', '12.0.0'), + new Metric('usage', 'requests_completed', 'integer', 42), + ], + ); + } + /** @return array */ private function definition(): array { return [ From 25bd2af9e57d01c47ba2049c46b96d00ad036d4f Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:27:39 -0300 Subject: [PATCH 179/283] test: cover schema compatibility across versions --- .../Integration/Db/SchemaRepositoryTest.php | 100 ++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/tests/php/Integration/Db/SchemaRepositoryTest.php b/tests/php/Integration/Db/SchemaRepositoryTest.php index e805ade..bbfd4db 100644 --- a/tests/php/Integration/Db/SchemaRepositoryTest.php +++ b/tests/php/Integration/Db/SchemaRepositoryTest.php @@ -7,6 +7,7 @@ use OCA\UsageStatisticsServer\Db\SchemaRepository; use OCP\IDBConnection; use OCP\Server; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Test\TestCase; @@ -28,20 +29,107 @@ protected function tearDown(): void { } public function testRegistrationIsIdempotentAndImmutable(): void { - $definition = [ - 'application' => 'libresign', - 'schemaVersion' => 1, - 'metrics' => [], - ]; + $definition = $this->definition(1); self::assertTrue($this->schemas->store('libresign', 1, $definition)); self::assertFalse($this->schemas->store('libresign', 1, $definition)); self::assertSame($definition, $this->schemas->find('libresign', 1)); $changed = $definition; - $changed['metrics'][] = ['category' => 'server']; + $changed['metrics'][0]['description'] = 'Changed within the same version'; $this->expectException(\LogicException::class); $this->schemas->store('libresign', 1, $changed); } + + public function testCompatibleMetricCanEvolvePresentationAcrossVersions(): void { + $first = $this->definition(1); + $second = $this->definition(2); + $second['metrics'][0]['description'] = 'New description'; + $second['metrics'][0]['required'] = false; + + self::assertTrue($this->schemas->store('libresign', 1, $first)); + self::assertTrue($this->schemas->store('libresign', 2, $second)); + self::assertSame($first['metrics'][0], $this->schemas->findMetric('libresign', 'usage', 'requests_completed')); + self::assertSame($second, $this->schemas->find('libresign', 2)); + } + + public function testCompatibilityChecksAllPreviousSchemaVersions(): void { + $first = $this->definition(1); + $second = $this->definition(2); + $second['metrics'] = [[ + 'category' => 'server', + 'key' => 'version', + 'type' => 'string', + 'kind' => 'snapshot', + 'aggregation' => 'distribution', + 'description' => 'Version', + 'required' => false, + ]]; + $third = $this->definition(3); + $third['metrics'][0]['type'] = 'number'; + + self::assertTrue($this->schemas->store('libresign', 1, $first)); + self::assertTrue($this->schemas->store('libresign', 2, $second)); + + $this->expectException(\LogicException::class); + $this->schemas->store('libresign', 3, $third); + } + + #[DataProvider('incompatibleMetricProvider')] + public function testRejectsIncompatibleMetricSemantics(string $field, string $value): void { + $first = $this->definition(1); + $second = $this->definition(2); + $second['metrics'][0][$field] = $value; + + self::assertTrue($this->schemas->store('libresign', 1, $first)); + + $this->expectException(\LogicException::class); + $this->schemas->store('libresign', 2, $second); + } + + /** @return iterable */ + public static function incompatibleMetricProvider(): iterable { + yield 'type' => ['type', 'number']; + yield 'kind' => ['kind', 'snapshot']; + yield 'aggregation' => ['aggregation', 'none']; + } + + public function testMetricLookupSearchesAllStoredDefinitions(): void { + $first = $this->definition(1); + $second = $this->definition(2); + $second['metrics'] = [[ + 'category' => 'server', + 'key' => 'version', + 'type' => 'string', + 'kind' => 'snapshot', + 'aggregation' => 'distribution', + 'description' => 'Version', + 'required' => false, + ]]; + + $this->schemas->store('libresign', 1, $first); + $this->schemas->store('libresign', 2, $second); + + self::assertSame($first['metrics'][0], $this->schemas->findMetric('libresign', 'usage', 'requests_completed')); + self::assertSame($second['metrics'][0], $this->schemas->findMetric('libresign', 'server', 'version')); + self::assertNull($this->schemas->findMetric('libresign', 'missing', 'metric')); + } + + /** @return array */ + private function definition(int $version): array { + return [ + 'application' => 'libresign', + 'schemaVersion' => $version, + 'metrics' => [[ + 'category' => 'usage', + 'key' => 'requests_completed', + 'type' => 'integer', + 'kind' => 'period', + 'aggregation' => 'numerical', + 'description' => 'Completed signing requests', + 'required' => true, + ]], + ]; + } } From 815f40f5241bfdd584672b9e2d701c842e60005a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:28:19 -0300 Subject: [PATCH 180/283] test: strengthen statistics aggregation contracts --- .../Db/StatisticsRepositoryTest.php | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/tests/php/Integration/Db/StatisticsRepositoryTest.php b/tests/php/Integration/Db/StatisticsRepositoryTest.php index ed04c5c..e02aba9 100644 --- a/tests/php/Integration/Db/StatisticsRepositoryTest.php +++ b/tests/php/Integration/Db/StatisticsRepositoryTest.php @@ -79,12 +79,63 @@ public function testCurrentStateAndHistoricalNumericalAggregations(): void { new \DateTimeImmutable('2026-08-02T00:00:00Z'), ); - self::assertCount(2, $history); - self::assertSame(1, $history[0]['count']); - self::assertSame(10.0, $history[0]['total']); - self::assertSame(2, $history[1]['count']); - self::assertSame(30.0, $history[1]['average']); - self::assertSame(60.0, $history[1]['total']); + self::assertSame([ + [ + 'periodStart' => '2026-06-01 00:00:00', + 'periodEnd' => '2026-07-01 00:00:00', + 'count' => 1, + 'average' => 10.0, + 'min' => 10.0, + 'max' => 10.0, + 'total' => 10.0, + ], + [ + 'periodStart' => '2026-07-01 00:00:00', + 'periodEnd' => '2026-08-01 00:00:00', + 'count' => 2, + 'average' => 30.0, + 'min' => 20.0, + 'max' => 40.0, + 'total' => 60.0, + ], + ], $history); + } + + public function testDistributionIsSortedByCountThenValue(): void { + $this->store('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '2.0.0', 1); + $this->store('installation-b', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0', 2); + $this->store('installation-c', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0', 3); + $this->store('installation-d', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '3.0.0', 4); + + self::assertSame([ + ['value' => '1.0.0', 'count' => 2], + ['value' => '2.0.0', 'count' => 1], + ['value' => '3.0.0', 'count' => 1], + ], $this->statistics->currentDistribution('libresign', 'server', 'version')); + } + + public function testNumericalEvaluationPreservesPrecisionAndExtremes(): void { + $this->store('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0', 10); + $this->store('installation-b', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0', 11); + $this->store('installation-c', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.0.0', 11); + + self::assertSame([ + 'count' => 3, + 'average' => 10.67, + 'min' => 10.0, + 'max' => 11.0, + 'total' => 32.0, + ], $this->statistics->currentNumericalEvaluation('libresign', 'usage', 'requests_completed')); + } + + public function testEmptyNumericalEvaluationUsesNullForDerivedValues(): void { + self::assertSame([ + 'count' => 0, + 'average' => null, + 'min' => null, + 'max' => null, + 'total' => null, + ], $this->statistics->currentNumericalEvaluation('libresign', 'usage', 'missing')); } public function testMetricValuesAreStoredInTheirTypedColumn(): void { @@ -134,12 +185,18 @@ public function testSameLogicalPeriodWithDifferentSchemaIsRejected(): void { $this->reports->store($this->factory->fromPayload($conflicting)); } - public function testSameLogicalReportIsIdempotent(): void { + public function testSameLogicalReportIsIdempotentAndReturnsStableIdentity(): void { $payload = $this->payload('installation-a', '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z', '1.1.0', 20, 1); $report = $this->factory->fromPayload($payload); - self::assertTrue($this->reports->store($report)['created']); - self::assertFalse($this->reports->store($report)['created']); + $first = $this->reports->store($report); + $second = $this->reports->store($report); + + self::assertTrue($first['created']); + self::assertFalse($second['created']); + self::assertIsInt($first['id']); + self::assertIsInt($second['id']); + self::assertSame($first['id'], $second['id']); $qb = $this->db->getQueryBuilder(); self::assertSame(1, (int)$qb->select($qb->func()->count())->from('usage_stats_reports')->executeQuery()->fetchOne()); From e185aa62dfa8480603924539d2a8c5b44f604227 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:29:32 -0300 Subject: [PATCH 181/283] refactor: centralize metric identity --- lib/Service/MetricIdentity.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 lib/Service/MetricIdentity.php diff --git a/lib/Service/MetricIdentity.php b/lib/Service/MetricIdentity.php new file mode 100644 index 0000000..61dbfa5 --- /dev/null +++ b/lib/Service/MetricIdentity.php @@ -0,0 +1,16 @@ + Date: Tue, 8 Sep 2026 08:30:09 -0300 Subject: [PATCH 182/283] refactor: use metric identity helper --- lib/Service/ReportFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/ReportFactory.php b/lib/Service/ReportFactory.php index b610fb0..00b9d54 100644 --- a/lib/Service/ReportFactory.php +++ b/lib/Service/ReportFactory.php @@ -61,7 +61,7 @@ public function fromPayload(array $payload): Report { $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', self::MAX_KEY_LENGTH); $type = $rawMetric['type'] ?? null; $value = $rawMetric['value'] ?? null; - $identity = $category . ':' . $key; + $identity = MetricIdentity::fromParts($category, $key); if (isset($seen[$identity])) { throw new InvalidReport('Duplicate metric.'); } From a71a758c99aafec0f876abe7550981b0c4f7c3d4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:30:28 -0300 Subject: [PATCH 183/283] refactor: use metric identity helper in schemas --- lib/Service/SchemaValidator.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Service/SchemaValidator.php b/lib/Service/SchemaValidator.php index 8e3556f..77e02d3 100644 --- a/lib/Service/SchemaValidator.php +++ b/lib/Service/SchemaValidator.php @@ -38,7 +38,7 @@ public function validateDefinition(array $definition): array { $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', 128); $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', 512); - $identity = $category . ':' . $key; + $identity = MetricIdentity::fromParts($category, $key); if (isset($seen[$identity])) { throw new InvalidReport('Duplicate schema metric.'); } @@ -99,7 +99,7 @@ public function validateReport(Report $report, array $definition): void { if (!is_array($metric)) { throw new InvalidReport('Invalid registered application schema.'); } - $identity = ($metric['category'] ?? '') . ':' . ($metric['key'] ?? ''); + $identity = MetricIdentity::fromParts((string)($metric['category'] ?? ''), (string)($metric['key'] ?? '')); $allowed[$identity] = $metric; if (($metric['required'] ?? false) === true) { $required[$identity] = true; @@ -107,7 +107,7 @@ public function validateReport(Report $report, array $definition): void { } foreach ($report->metrics as $metric) { - $identity = $metric->category . ':' . $metric->key; + $identity = MetricIdentity::fromParts($metric->category, $metric->key); $schemaMetric = $allowed[$identity] ?? null; if (!is_array($schemaMetric)) { throw new InvalidReport('Report contains a metric that is not registered in the application schema.'); From 6a9254e6338b5d9a01c001bbd270c27d172457ab Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:30:45 -0300 Subject: [PATCH 184/283] refactor: use collision-safe metric identities --- lib/Db/SchemaRepository.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Db/SchemaRepository.php b/lib/Db/SchemaRepository.php index 70c3c7a..d8c6cc8 100644 --- a/lib/Db/SchemaRepository.php +++ b/lib/Db/SchemaRepository.php @@ -4,6 +4,7 @@ namespace OCA\UsageStatisticsServer\Db; +use OCA\UsageStatisticsServer\Service\MetricIdentity; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -102,7 +103,7 @@ private function assertMetricCompatibility(string $application, array $definitio if (!is_array($metric)) { continue; } - $identity = ($metric['category'] ?? '') . ':' . ($metric['key'] ?? ''); + $identity = MetricIdentity::fromParts((string)($metric['category'] ?? ''), (string)($metric['key'] ?? '')); $existingMetrics[$identity] = $metric; } } @@ -111,7 +112,7 @@ private function assertMetricCompatibility(string $application, array $definitio if (!is_array($metric)) { continue; } - $identity = ($metric['category'] ?? '') . ':' . ($metric['key'] ?? ''); + $identity = MetricIdentity::fromParts((string)($metric['category'] ?? ''), (string)($metric['key'] ?? '')); $existing = $existingMetrics[$identity] ?? null; if (!is_array($existing)) { continue; @@ -119,7 +120,7 @@ private function assertMetricCompatibility(string $application, array $definitio foreach (['type', 'kind', 'aggregation'] as $field) { if (($existing[$field] ?? null) !== ($metric[$field] ?? null)) { - throw new \LogicException("Metric {$identity} changes {$field}; use a new metric key for incompatible semantics."); + throw new \LogicException("Metric {$metric['category']}:{$metric['key']} changes {$field}; use a new metric key for incompatible semantics."); } } } From 558fd36c783b984df40332e24a741a80ce1c5b19 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:30:58 -0300 Subject: [PATCH 185/283] test: cover collision-safe metric identities --- tests/php/Unit/Service/MetricIdentityTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/php/Unit/Service/MetricIdentityTest.php diff --git a/tests/php/Unit/Service/MetricIdentityTest.php b/tests/php/Unit/Service/MetricIdentityTest.php new file mode 100644 index 0000000..f5294ee --- /dev/null +++ b/tests/php/Unit/Service/MetricIdentityTest.php @@ -0,0 +1,38 @@ + */ + public static function distinctIdentityProvider(): iterable { + yield 'colon moves from category to key' => ['a:b', 'c', 'a', 'b:c']; + yield 'same category different key' => ['usage', 'a', 'usage', 'b']; + yield 'different category same key' => ['usage', 'count', 'server', 'count']; + } + + public function testSamePairHasStableIdentity(): void { + self::assertSame( + MetricIdentity::fromParts('usage:period', 'request:count'), + MetricIdentity::fromParts('usage:period', 'request:count'), + ); + } +} From 50cd84c43c302abc8b943f59f7107128937d2566 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 08:59:09 -0300 Subject: [PATCH 186/283] ci: show all escaped mutations --- .github/workflows/infection.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/infection.yml b/.github/workflows/infection.yml index 0684c05..d13459b 100644 --- a/.github/workflows/infection.yml +++ b/.github/workflows/infection.yml @@ -128,6 +128,7 @@ jobs: --coverage=build/coverage \ --skip-initial-tests \ --threads=max \ + --show-mutations=max \ --no-progress \ --no-interaction 2>&1 | tee infection.log From 749b5f8ceb2e75b3e6e857669ae4a5a41eebcc88 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 09:01:11 -0300 Subject: [PATCH 187/283] refactor: simplify schema validation state --- lib/Service/SchemaValidator.php | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/Service/SchemaValidator.php b/lib/Service/SchemaValidator.php index 77e02d3..d18a6bc 100644 --- a/lib/Service/SchemaValidator.php +++ b/lib/Service/SchemaValidator.php @@ -39,10 +39,10 @@ public function validateDefinition(array $definition): array { $category = $this->identifier($rawMetric['category'] ?? null, 'metric category', 128); $key = $this->identifier($rawMetric['key'] ?? null, 'metric key', 512); $identity = MetricIdentity::fromParts($category, $key); - if (isset($seen[$identity])) { + if (array_key_exists($identity, $seen)) { throw new InvalidReport('Duplicate schema metric.'); } - $seen[$identity] = true; + $seen[$identity] = null; $type = $rawMetric['type'] ?? null; $kind = $rawMetric['kind'] ?? null; @@ -99,26 +99,25 @@ public function validateReport(Report $report, array $definition): void { if (!is_array($metric)) { throw new InvalidReport('Invalid registered application schema.'); } + $identity = MetricIdentity::fromParts((string)($metric['category'] ?? ''), (string)($metric['key'] ?? '')); $allowed[$identity] = $metric; if (($metric['required'] ?? false) === true) { - $required[$identity] = true; + $required[] = $identity; } } + $reported = []; foreach ($report->metrics as $metric) { $identity = MetricIdentity::fromParts($metric->category, $metric->key); $schemaMetric = $allowed[$identity] ?? null; - if (!is_array($schemaMetric)) { - throw new InvalidReport('Report contains a metric that is not registered in the application schema.'); - } - if (($schemaMetric['type'] ?? null) !== $metric->type) { - throw new InvalidReport('Report metric type does not match the application schema.'); + if (!is_array($schemaMetric) || ($schemaMetric['type'] ?? null) !== $metric->type) { + throw new InvalidReport('Report metric does not match the application schema.'); } - unset($required[$identity]); + $reported[] = $identity; } - if ($required !== []) { + if (array_diff($required, $reported) !== []) { throw new InvalidReport('Report is missing a required metric.'); } } From 3df0bc44bd6b124da64e086ed5c8692baae5346c Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 09:01:27 -0300 Subject: [PATCH 188/283] refactor: simplify duplicate metric tracking --- lib/Service/ReportFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Service/ReportFactory.php b/lib/Service/ReportFactory.php index 00b9d54..e84825d 100644 --- a/lib/Service/ReportFactory.php +++ b/lib/Service/ReportFactory.php @@ -62,10 +62,10 @@ public function fromPayload(array $payload): Report { $type = $rawMetric['type'] ?? null; $value = $rawMetric['value'] ?? null; $identity = MetricIdentity::fromParts($category, $key); - if (isset($seen[$identity])) { + if (array_key_exists($identity, $seen)) { throw new InvalidReport('Duplicate metric.'); } - $seen[$identity] = true; + $seen[$identity] = null; $valid = match ($type) { 'integer' => is_int($value), From 0a98df2198bae2ca2d1534923684619225d9eae6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 09:03:27 -0300 Subject: [PATCH 189/283] refactor: simplify RFC3339 validation --- lib/Service/ReportFactory.php | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/lib/Service/ReportFactory.php b/lib/Service/ReportFactory.php index e84825d..a83fa68 100644 --- a/lib/Service/ReportFactory.php +++ b/lib/Service/ReportFactory.php @@ -6,7 +6,7 @@ final class ReportFactory { private const IDENTIFIER_PATTERN = '/^[A-Za-z0-9_.:-]+$/'; - private const RFC3339_PATTERN = '/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|[+-]\d{2}:\d{2})$/D'; + private const RFC3339_PATTERN = '/^(?\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01]))T(?