Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ RCS/*
tests/ui-artifacts/
docs/*
!docs/flexible-storage-api-v1.md
!docs/ocs-reports-api-v1.md

# Mac OS
.DS_Store
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog

## Unreleased
### Added
- OCS endpoints to list accessible reports and owned datasets, retrieve report metadata and filtered data including shared reports, and add or delete dataset rows.

### Fixed
- Update bundled Moment.js to 2.31.0, which includes the fix for CVE-2026-17495.
- Update bundled PhpSpreadsheet to 5.10.0 to address known spreadsheet processing vulnerabilities.
Expand Down
82 changes: 82 additions & 0 deletions docs/ocs-reports-api-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<!--
SPDX-FileCopyrightText: 2026 Marcel Scherello
SPDX-License-Identifier: AGPL-3.0-or-later
-->

# OCS reports API v1

All paths below are relative to `/ocs/v2.php/apps/analytics/api/v1`.
Authenticate as a Nextcloud user (for example with HTTP Basic authentication and
an app password), send `OCS-APIRequest: true`, and request JSON with
`Accept: application/json`. Responses use the standard `ocs.meta` / `ocs.data`
envelope. Existing `/apps/analytics/api/1.0` through `4.0` APIs are unchanged.

| Method | Path | Result in `ocs.data` |
| --- | --- | --- |
| GET | `/reports` | Accessible reports with only `id`, `name`, and `dataset`, excluding folders |
| GET | `/reports/{reportId}` | Report metadata and saved visualization/filter options |
| GET | `/reports/{reportId}/data` | Viewer data payload including rows, headers and report options |

Access includes owned reports and reports shared with the user, their groups,
or through shared report folders, following the report navigation access rules.
Public-link possession and panorama-only access do not grant standalone report
access through these endpoints. Missing, inaccessible, and folder IDs return
an OCS not-found error (HTTP 404 on OCS v2).

Metadata excludes datasource connection settings, owner IDs and share secrets.
The data endpoint uses the same backend pipeline as the report viewer, including
saved filters, dynamic filter variables, sorting and transformations for internal
and external datasources. It accepts no temporary filter or visualization
overrides. It returns the viewer's backend data, not rendered chart images or
client-side table formatting/pivot output. Datasource failures retain the viewer
payload's `error` field. Conditional caching is not enabled for this endpoint.

Example:

```sh
curl --user 'USER:APP_PASSWORD' \
--header 'OCS-APIRequest: true' \
--header 'Accept: application/json' \
'https://cloud.example/ocs/v2.php/apps/analytics/api/v1/reports/42/data'
```

The machine-readable contract is maintained in the repository root `openapi.json`.
This repository currently has no configured OpenAPI extractor; keep the specification
in sync when changing the controller.

## Dataset operations

| Method | Path | Result in `ocs.data` |
| --- | --- | --- |
| GET | `/datasets` | Owned datasets: `id`, `name`, `dimension1`, `dimension2`, `value` |
| POST | `/datasets/{datasetId}/data/add` | Existing V4 add/update result |
| POST | `/datasets/{datasetId}/data/delete` | Existing V4 deletion result, including `delete` count |

The OCS controller delegates these operations to the existing external API handlers.
The dataset list exposes only `id`, `name`, `dimension1`, `dimension2`, and `value`;
internal navigation, scheduling, and storage fields are omitted. The legacy V3
dataset response is unchanged.
Dataset writes require ownership and legacy storage; shared report access does not
allow dataset writes. Flexible datasets continue to use the stable-column record API.
The URL dataset ID takes precedence over any body parameter.

Add/update body (technical keys or configured dataset column names):

```json
{"data":[{"dimension1":"2026-10-15","dimension2":"Sales","value":"42"}]}
```

Delete body (a nonempty filter is required):

```json
{"filter":{"dimension1":{"option":"EQ","value":"2026-10-15"}}}
```

Deletion supports `dimension1`, `dimension2`, or their configured names, and the
V4 operators `EQ`, `GT`, `LT`, `IN`, `LIKE`, `NOTLIKE`, and `BETWEEN`, including
dynamic date variables. These endpoints retain V4 application error semantics:
HTTP 200 with `ocs.data.success=false` and `ocs.data.error.code/message`.
Codes are 9001 (unknown), 9002 (invalid input), 9003 (missing or unauthorized
dataset), and 9004 (unsupported storage mode). Check `success`, not just the OCS
metadata. Successful writes return `success=true` and a message; deletion also
returns the deleted row count. Authentication failures remain HTTP 401.
100 changes: 95 additions & 5 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
namespace OCA\Analytics\Controller;

use OCA\Analytics\Service\ReportService;
use OCA\Analytics\Service\ShareService;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
Expand All @@ -22,10 +24,14 @@ class ApiController extends OCSController {
private ReportService $reportService;
private $logger;

public function __construct(string $appName,
IRequest $request,
LoggerInterface $logger,
ReportService $reportService
public function __construct(
string $appName,
IRequest $request,
LoggerInterface $logger,
ReportService $reportService,
private ShareService $shareService,
private OutputController $outputController,
private ApiDataController $apiDataController
) {
parent::__construct($appName, $request);
$this->reportService = $reportService;
Expand Down Expand Up @@ -68,4 +74,88 @@ public function createFromDataFile($fileId): DataResponse {
return new DataResponse(['error' => 'fileId missing'], HTTP::STATUS_BAD_REQUEST);
}
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/reports')]
public function index(): DataResponse {
$reports = array_values(array_filter(
$this->reportService->index(),
static fn (array $report): bool => (int)$report['type'] !== ReportService::REPORT_TYPE_GROUP
));
return new DataResponse(array_map(
static fn (array $report): array => array_intersect_key($report, array_flip(['id', 'name', 'dataset'])),
$reports
));
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/reports/{reportId}')]
public function read(int $reportId): DataResponse {
return new DataResponse($this->publicMetadata($this->accessibleReport($reportId)));
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/reports/{reportId}/data')]
public function data(int $reportId): DataResponse {
// Use the viewer pipeline with saved options only, without request overrides
// or conditional caching: every successful response contains the report data.
$result = $this->outputController->getData($this->accessibleReport($reportId));
unset($result['cache']);
if (isset($result['options'])) {
$result['options'] = $this->publicMetadata($result['options']);
}
return new DataResponse($result);
}

/** List owned datasets with their column labels. */
#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/datasets')]
public function datasets(): DataResponse {
return new DataResponse(array_map(
static fn (array $dataset): array => array_intersect_key($dataset, array_flip([
'id', 'name', 'dimension1', 'dimension2', 'value',
])),
$this->apiDataController->datasetIndexV3()
));
}

/** Add or update rows using the existing V4 validation and write path. */
#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'POST', url: '/api/v1/datasets/{datasetId}/data/add')]
public function addData(int $datasetId): DataResponse {
return $this->apiDataController->addDataV4($datasetId);
}

/** Delete rows using the existing V4 structured-filter write path. */
#[NoAdminRequired]
#[NoCSRFRequired]
#[ApiRoute(verb: 'POST', url: '/api/v1/datasets/{datasetId}/data/delete')]
public function deleteData(int $datasetId): DataResponse {
return $this->apiDataController->deleteDataV4($datasetId);
}

private function accessibleReport(int $reportId): array {
$report = $this->reportService->read($reportId);
if (empty($report)) {
$report = $this->shareService->getSharedReport($reportId);
}
if (empty($report) || (int)$report['type'] === ReportService::REPORT_TYPE_GROUP) {
throw new OCSNotFoundException('Report not found');
}
return $report;
}

private function publicMetadata(array $report): array {
return array_intersect_key($report, array_flip([
'id', 'name', 'subheader', 'type', 'parent', 'dataset', 'version',
'visualization', 'chart', 'chartoptions', 'dataoptions', 'tableoptions',
'filteroptions', 'refresh', 'permissions', 'isShare',
'storageMode', 'schemaVersion', 'columns', 'dimension1', 'dimension2', 'value',
]));
}
}
Loading
Loading