Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.62% covered (warning)
84.62%
44 / 52
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignerGeolocationMetadataValidator
84.62% covered (warning)
84.62%
44 / 52
50.00% covered (danger)
50.00%
2 / 4
38.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalize
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
24
 mergeDeviceIntoMetadata
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 validateSubmission
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\SignerGeolocation;
10
11use OCA\Libresign\Db\SignRequest;
12use OCA\Libresign\Enum\SignerGeolocationCollectionStatus;
13use OCA\Libresign\Enum\SignerGeolocationMode;
14use OCA\Libresign\Exception\LibresignException;
15use OCP\IL10N;
16
17class SignerGeolocationMetadataValidator {
18    public const METADATA_GEOLOCATION_KEY = 'geolocation';
19    public const METADATA_DEVICE_KEY = 'device';
20    public const METADATA_IP_KEY = 'ip';
21
22    public function __construct(
23        private SignerGeolocationPolicyService $signerGeolocationPolicyService,
24        private IL10N $l10n,
25    ) {
26    }
27
28    /**
29     * @return array{
30     *     status: string,
31     *     latitude?: float,
32     *     longitude?: float,
33     *     accuracy?: float,
34     *     timestamp?: int,
35     * }|null
36     */
37    public function normalize(mixed $rawValue): ?array {
38        if ($rawValue === null || $rawValue === []) {
39            return null;
40        }
41
42        if (!is_array($rawValue)) {
43            throw new LibresignException($this->l10n->t('Invalid geolocation payload.'));
44        }
45
46        $status = SignerGeolocationCollectionStatus::tryFrom((string)($rawValue['status'] ?? ''));
47        if ($status === null) {
48            throw new LibresignException($this->l10n->t('Invalid geolocation collection status.'));
49        }
50
51        $normalized = [
52            'status' => $status->value,
53        ];
54
55        if ($status === SignerGeolocationCollectionStatus::COLLECTED) {
56            if (!array_key_exists('latitude', $rawValue) || !array_key_exists('longitude', $rawValue)) {
57                throw new LibresignException($this->l10n->t('Collected geolocation requires latitude and longitude.'));
58            }
59
60            $latitude = filter_var($rawValue['latitude'], FILTER_VALIDATE_FLOAT);
61            $longitude = filter_var($rawValue['longitude'], FILTER_VALIDATE_FLOAT);
62            if ($latitude === false || $longitude === false) {
63                throw new LibresignException($this->l10n->t('Invalid geolocation coordinates.'));
64            }
65
66            if ($latitude < -90.0 || $latitude > 90.0 || $longitude < -180.0 || $longitude > 180.0) {
67                throw new LibresignException($this->l10n->t('Geolocation coordinates are out of range.'));
68            }
69
70            $normalized['latitude'] = (float)$latitude;
71            $normalized['longitude'] = (float)$longitude;
72
73            if (array_key_exists('accuracy', $rawValue) && $rawValue['accuracy'] !== null && $rawValue['accuracy'] !== '') {
74                $accuracy = filter_var($rawValue['accuracy'], FILTER_VALIDATE_FLOAT);
75                if ($accuracy === false || $accuracy < 0.0) {
76                    throw new LibresignException($this->l10n->t('Invalid geolocation accuracy.'));
77                }
78                $normalized['accuracy'] = (float)$accuracy;
79            }
80
81            if (array_key_exists('timestamp', $rawValue) && $rawValue['timestamp'] !== null && $rawValue['timestamp'] !== '') {
82                $timestamp = filter_var($rawValue['timestamp'], FILTER_VALIDATE_INT);
83                if ($timestamp === false || $timestamp < 0) {
84                    throw new LibresignException($this->l10n->t('Invalid geolocation timestamp.'));
85                }
86                $normalized['timestamp'] = (int)$timestamp;
87            }
88        }
89
90        return $normalized;
91    }
92
93    /**
94     * Nest normalized device geolocation under geolocation.device.
95     *
96     * @param array<string, mixed> $metadata
97     * @param array<string, mixed> $deviceGeolocation
98     * @return array<string, mixed>
99     */
100    public function mergeDeviceIntoMetadata(array $metadata, array $deviceGeolocation): array {
101        $geolocation = $metadata[self::METADATA_GEOLOCATION_KEY] ?? [];
102        if (!is_array($geolocation)) {
103            $geolocation = [];
104        }
105        $geolocation[self::METADATA_DEVICE_KEY] = $deviceGeolocation;
106        $metadata[self::METADATA_GEOLOCATION_KEY] = $geolocation;
107        return $metadata;
108    }
109
110    public function validateSubmission(SignRequest $signRequest, ?array $geolocation): void {
111        $requirement = $this->signerGeolocationPolicyService->getFrozenRequirement($signRequest)
112            ?? SignerGeolocationMode::DISABLED;
113
114        if ($requirement === SignerGeolocationMode::DISABLED) {
115            if ($geolocation !== null) {
116                throw new LibresignException($this->l10n->t('Geolocation is not enabled for this signature request.'));
117            }
118            return;
119        }
120
121        if ($geolocation === null) {
122            if ($requirement === SignerGeolocationMode::REQUIRED) {
123                throw new LibresignException($this->l10n->t('Geolocation is required to sign this document.'));
124            }
125            return;
126        }
127
128        if ($requirement === SignerGeolocationMode::REQUIRED
129            && ($geolocation['status'] ?? '') !== SignerGeolocationCollectionStatus::COLLECTED->value) {
130            throw new LibresignException($this->l10n->t('Geolocation is required to sign this document.'));
131        }
132    }
133}