Skip to content

Commit 5b70cba

Browse files
redblommickenordinMahdiBaghbani
committed
feat(OCM-Invites): Implement exchanging cloud IDs through OCM invitation workflow
see https://github.com/cs3org/OCM-API/blob/v1.2.1/IETF-RFC.md Features: - Button to invite remote users to exchange cloudIDs. - Button to manually accept invite to exchange cloudIDs. - WAYF page allowing the receiver of the invite to open and accept the invitation. - Listing of open invitations. - Option to resend, revoke open invitations. - Administer invitation settings Signed-off-by: Antoon P. <antoon.prins@surf.nl> Co-authored-by: Micke Nordin <kano@sunet.se> Co-authored-by: Mahdi Baghbani <mahdi-baghbani@azadehafzar.io>
1 parent 0ad2fbf commit 5b70cba

51 files changed

Lines changed: 7801 additions & 119 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

appinfo/info.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@
4949

5050
<background-jobs>
5151
<job>OCA\Contacts\Cron\SocialUpdateRegistration</job>
52+
<job>OCA\Contacts\Cron\UpdateOcmProviders</job>
5253
</background-jobs>
5354

55+
<commands>
56+
<command>OCA\Contacts\Command\OcmInvitesConfig</command>
57+
</commands>
58+
5459
<settings>
5560
<admin>OCA\Contacts\Settings\AdminSettings</admin>
5661
</settings>

jest.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module.exports = {
1010
preset: 'ts-jest',
1111
moduleFileExtensions: ['js', 'vue', 'ts'],
1212
collectCoverageFrom: [
13-
'src/**/*.{js,vue}',
13+
'src/**/*.{js,ts,vue}',
1414
'!**/node_modules/**',
1515
],
1616
setupFilesAfterEnv: [

lib/AppInfo/Application.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@
77
namespace OCA\Contacts\AppInfo;
88

99
use OCA\Contacts\Capabilities;
10+
use OCA\Contacts\ConfigLexicon;
1011
use OCA\Contacts\Dav\PatchPlugin;
1112
use OCA\Contacts\Event\LoadContactsOcaApiEvent;
13+
use OCA\Contacts\Listener\FederatedInviteAcceptedListener;
1214
use OCA\Contacts\Listener\LoadContactsFilesActions;
1315
use OCA\Contacts\Listener\LoadContactsOcaApi;
16+
use OCA\Contacts\Listener\OcmDiscoveryListener;
1417
use OCA\DAV\Events\SabrePluginAddEvent;
1518
use OCA\Files\Event\LoadAdditionalScriptsEvent;
1619
use OCP\AppFramework\App;
1720
use OCP\AppFramework\Bootstrap\IBootContext;
1821
use OCP\AppFramework\Bootstrap\IBootstrap;
1922
use OCP\AppFramework\Bootstrap\IRegistrationContext;
2023
use OCP\EventDispatcher\IEventDispatcher;
24+
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
25+
use OCP\OCM\Events\OCMEndpointRequestEvent;
26+
use OCP\OCM\Events\ResourceTypeRegisterEvent;
2127

2228
class Application extends App implements IBootstrap {
2329
public const APP_ID = 'contacts';
@@ -33,8 +39,15 @@ public function __construct() {
3339
#[\Override]
3440
public function register(IRegistrationContext $context): void {
3541
$context->registerCapability(Capabilities::class);
42+
$context->registerConfigLexicon(ConfigLexicon::class);
43+
3644
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadContactsFilesActions::class);
3745
$context->registerEventListener(LoadContactsOcaApiEvent::class, LoadContactsOcaApi::class);
46+
$context->registerEventListener(OCMEndpointRequestEvent::class, FederatedInviteAcceptedListener::class);
47+
$ocmDiscoveryEvent = class_exists(LocalOCMDiscoveryEvent::class)
48+
? LocalOCMDiscoveryEvent::class
49+
: ResourceTypeRegisterEvent::class;
50+
$context->registerEventListener($ocmDiscoveryEvent, OcmDiscoveryListener::class);
3851
}
3952

4053
#[\Override]

lib/Command/OcmInvitesConfig.php

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Contacts\Command;
11+
12+
use OCA\Contacts\AppInfo\Application;
13+
use OCA\Contacts\ConfigLexicon;
14+
use OCA\Contacts\Service\FederatedInvitesService;
15+
use OCP\IAppConfig;
16+
use Symfony\Component\Console\Command\Command;
17+
use Symfony\Component\Console\Helper\Table;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
22+
/**
23+
* Manage OCM invite configuration.
24+
*
25+
* Usage:
26+
* occ contacts:ocm-invites-config
27+
* occ contacts:ocm-invites-config <key>
28+
* occ contacts:ocm-invites-config <key> <value>
29+
*
30+
* Boolean values accept on/off, true/false, 1/0, and yes/no.
31+
*/
32+
class OcmInvitesConfig extends Command {
33+
public function __construct(
34+
private IAppConfig $appConfig,
35+
) {
36+
parent::__construct();
37+
}
38+
39+
#[\Override]
40+
protected function configure(): void {
41+
$supportedOptions = implode(', ', $this->getSupportedOptions());
42+
$help = <<<HELP
43+
The <info>%command.name%</info> command reads and writes Contacts OCM invite settings.
44+
45+
Supported keys:
46+
{$supportedOptions}
47+
48+
Key info:
49+
<info>ocm_invites_enabled</info> - If set to true then the capability to send and accept invitations to exchange contact info is enabled in the app - bool, default false
50+
<info>ocm_invites_optional_mail</info> - If set to true then sending an invitation by email is optional - bool, default false
51+
<info>ocm_invites_cc_sender</info> - If set to true then the option to send a copy of the invitation to the sender is displayed - bool, default true
52+
<info>ocm_invites_encoded_copy_button</info> - If set to true then the button to copy the encoded invitation is displayed - bool, default false
53+
<info>ocm_invites_disable_ssrf_guard</info> - If set to true SSRF guard will be turned off. Warning: This is for development/testing purposes only!
54+
In production environments the value of this key should always be false - bool, default false
55+
<info>mesh_providers_service</info> - The url that returns the list of mesh providers that will be displayed on the WAYF page - string, default empty
56+
57+
Boolean values accept: on/off, true/false, 1/0, yes/no.
58+
59+
Examples:
60+
<info>occ %command.name%</info>
61+
<info>occ %command.name% ocm_invites_enabled on</info>
62+
<info>occ %command.name% mesh_providers_service "https://mesh.example"</info>
63+
64+
HELP;
65+
66+
$this
67+
->setName('contacts:ocm-invites-config')
68+
->setDescription('Manage OCM invite configuration.')
69+
->addArgument(
70+
'option',
71+
InputArgument::OPTIONAL,
72+
'Config key to read or write. Omit to list supported keys.',
73+
)
74+
->addArgument(
75+
'value',
76+
InputArgument::OPTIONAL,
77+
'Value to write. Omit to read the current value.',
78+
)
79+
->setHelp($help);
80+
}
81+
82+
#[\Override]
83+
protected function execute(InputInterface $input, OutputInterface $output): int {
84+
$option = $input->getArgument('option');
85+
$value = $input->getArgument('value');
86+
87+
if ($option === null) {
88+
return $this->listAll($output);
89+
}
90+
91+
if (!in_array($option, $this->getSupportedOptions(), true)) {
92+
$output->writeln(sprintf(
93+
'<error>Unknown OCM invite config key "%s". Allowed: %s.</error>',
94+
$option,
95+
implode(', ', $this->getSupportedOptions()),
96+
));
97+
return self::FAILURE;
98+
}
99+
100+
if ($value === null) {
101+
$output->writeln($this->getCurrentValue($option));
102+
return self::SUCCESS;
103+
}
104+
105+
if ($option === ConfigLexicon::MESH_PROVIDERS_SERVICE) {
106+
$normalised = trim($value);
107+
$current = $this->appConfig->getValueString(Application::APP_ID, $option);
108+
if ($current === $normalised) {
109+
$output->writeln(sprintf('%s is already "%s".', $option, $normalised));
110+
return self::SUCCESS;
111+
}
112+
113+
$this->appConfig->setValueString(Application::APP_ID, $option, $normalised);
114+
$output->writeln(sprintf('%s: "%s"', $option, $normalised));
115+
return self::SUCCESS;
116+
}
117+
118+
$parsed = $this->parseBool($value);
119+
if ($parsed === null) {
120+
$output->writeln(sprintf(
121+
'<error>Cannot parse "%s" as boolean. Use on/off, true/false, 1/0, or yes/no.</error>',
122+
$value,
123+
));
124+
return self::INVALID;
125+
}
126+
127+
$current = $this->appConfig->getValueBool(Application::APP_ID, $option);
128+
if ($current === $parsed) {
129+
$output->writeln(sprintf('%s is already %s.', $option, $this->formatBool($parsed)));
130+
return self::SUCCESS;
131+
}
132+
133+
$this->appConfig->setValueBool(Application::APP_ID, $option, $parsed);
134+
$output->writeln(sprintf('%s: %s', $option, $this->formatBool($parsed)));
135+
return self::SUCCESS;
136+
}
137+
138+
private function listAll(OutputInterface $output): int {
139+
$table = new Table($output);
140+
$table->setHeaders(['option', 'type', 'value']);
141+
foreach ($this->getSupportedOptions() as $key) {
142+
$table->addRow([
143+
$key,
144+
$this->isBooleanOption($key) ? 'bool' : 'string',
145+
$this->getCurrentValue($key),
146+
]);
147+
}
148+
$table->render();
149+
return self::SUCCESS;
150+
}
151+
152+
private function getSupportedOptions(): array {
153+
return [
154+
ConfigLexicon::OCM_INVITES_ENABLED,
155+
...FederatedInvitesService::OCM_INVITES_BOOL_KEYS,
156+
ConfigLexicon::MESH_PROVIDERS_SERVICE,
157+
];
158+
}
159+
160+
private function isBooleanOption(string $option): bool {
161+
return in_array($option, [
162+
ConfigLexicon::OCM_INVITES_ENABLED,
163+
...FederatedInvitesService::OCM_INVITES_BOOL_KEYS,
164+
], true);
165+
}
166+
167+
private function getCurrentValue(string $option): string {
168+
if ($this->isBooleanOption($option)) {
169+
return $this->formatBool($this->appConfig->getValueBool(Application::APP_ID, $option));
170+
}
171+
172+
return $this->appConfig->getValueString(Application::APP_ID, $option);
173+
}
174+
175+
private function formatBool(bool $value): string {
176+
return $value ? 'on' : 'off';
177+
}
178+
179+
private function parseBool(string $raw): ?bool {
180+
$normalised = strtolower(trim($raw));
181+
if (in_array($normalised, ['true', '1', 'on', 'yes'], true)) {
182+
return true;
183+
}
184+
if (in_array($normalised, ['false', '0', 'off', 'no'], true)) {
185+
return false;
186+
}
187+
return null;
188+
}
189+
}

lib/ConfigLexicon.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Contacts;
11+
12+
use OCP\Config\Lexicon\Entry;
13+
use OCP\Config\Lexicon\ILexicon;
14+
use OCP\Config\Lexicon\Strictness;
15+
use OCP\Config\ValueType;
16+
use OCP\IAppConfig;
17+
18+
/**
19+
* Config Lexicon for contacts.
20+
*
21+
* Please add and manage your config keys in this file and keep the
22+
* Lexicon up to date.
23+
*
24+
* {@see ILexicon}
25+
*/
26+
class ConfigLexicon implements ILexicon {
27+
public const OCM_INVITES_ENABLED = 'ocm_invites_enabled';
28+
public const OCM_INVITES_OPTIONAL_MAIL = 'ocm_invites_optional_mail';
29+
public const OCM_INVITES_CC_SENDER = 'ocm_invites_cc_sender';
30+
public const OCM_INVITES_ENCODED_COPY_BUTTON = 'ocm_invites_encoded_copy_button';
31+
public const OCM_INVITES_DISABLE_SSRF_GUARD = 'ocm_invites_disable_ssrf_guard';
32+
public const MESH_PROVIDERS_SERVICE = 'mesh_providers_service';
33+
public const WAYF_ENDPOINT = 'wayf_endpoint';
34+
public const FEDERATIONS_CACHE = 'federations_cache';
35+
36+
public function getStrictness(): Strictness {
37+
return Strictness::NOTICE;
38+
}
39+
40+
public function getAppConfigs(): array {
41+
return [
42+
new Entry(
43+
self::OCM_INVITES_ENABLED,
44+
ValueType::BOOL,
45+
defaultRaw: false,
46+
definition: 'Whether OCM invites for contacts are enabled.',
47+
lazy: true,
48+
),
49+
new Entry(
50+
self::OCM_INVITES_OPTIONAL_MAIL,
51+
ValueType::BOOL,
52+
defaultRaw: false,
53+
definition: 'Whether the recipient email field is optional when creating an OCM invite.',
54+
lazy: true,
55+
),
56+
new Entry(
57+
self::OCM_INVITES_CC_SENDER,
58+
ValueType::BOOL,
59+
defaultRaw: true,
60+
definition: 'Whether the sender is CC-ed on the OCM invite email.',
61+
lazy: true,
62+
),
63+
new Entry(
64+
self::OCM_INVITES_ENCODED_COPY_BUTTON,
65+
ValueType::BOOL,
66+
defaultRaw: false,
67+
definition: 'Whether the invite email "Open invite" button uses the encoded WAYF URL instead of the raw token.',
68+
lazy: true,
69+
),
70+
new Entry(
71+
self::OCM_INVITES_DISABLE_SSRF_GUARD,
72+
ValueType::BOOL,
73+
defaultRaw: false,
74+
definition: 'Unsafe development override that disables private-host and localhost checks for OCM invite discovery.',
75+
lazy: true,
76+
),
77+
new Entry(
78+
self::MESH_PROVIDERS_SERVICE,
79+
ValueType::STRING,
80+
defaultRaw: '',
81+
definition: 'Space-separated list of mesh provider service URLs used for WAYF discovery.',
82+
lazy: true,
83+
),
84+
new Entry(
85+
self::WAYF_ENDPOINT,
86+
ValueType::STRING,
87+
defaultRaw: '',
88+
definition: 'Optional override for the base WAYF endpoint used in invite links.',
89+
note: 'If empty, the app route is used at runtime.',
90+
lazy: true,
91+
),
92+
new Entry(
93+
self::FEDERATIONS_CACHE,
94+
ValueType::ARRAY,
95+
defaultRaw: [],
96+
definition: 'Internal cron-maintained cache for discovered federations and expiry metadata.',
97+
flags: IAppConfig::FLAG_SENSITIVE,
98+
lazy: true,
99+
),
100+
];
101+
}
102+
103+
public function getUserConfigs(): array {
104+
return [];
105+
}
106+
}

0 commit comments

Comments
 (0)