diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 198996b96..000000000 --- a/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = tab -indent_size = 4 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true diff --git a/.tx/config b/.tx similarity index 100% rename from .tx/config rename to .tx diff --git a/appinfo/info.xml b/appinfo/info.xml index 5fa7b3bff..1a943d679 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -51,7 +51,12 @@ + + OCA\Contacts\Cron\SocialUpdateRegistration + + OCA\Contacts\Settings\AdminSettings + diff --git a/appinfo/routes.php b/appinfo/routes.php index 6bdf24a32..88f430cc5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -27,7 +27,9 @@ ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], ['name' => 'page#index', 'url' => '/{group}', 'verb' => 'GET', 'postfix' => 'group'], ['name' => 'page#index', 'url' => '/{group}/{contact}', 'verb' => 'GET', 'postfix' => 'group.contact'], - ['name' => 'social_api#update_contact', 'url' => '/api/v1/social/avatar/{network}/{addressbookId}/{contactId}', 'verb' => 'PUT'], - ['name' => 'social_api#set_app_config', 'url' => '/api/v1/social/config/{key}', 'verb' => 'POST'], + ['name' => 'social_api#update_contact', 'url' => '/api/v1/social/avatar/{network}/{addressbookId}/{contactId}', 'verb' => 'PUT'], + ['name' => 'social_api#set_app_config', 'url' => '/api/v1/social/config/global/{key}', 'verb' => 'PUT'], + ['name' => 'social_api#set_user_config', 'url' => '/api/v1/social/config/user/{key}', 'verb' => 'PUT'], + ['name' => 'social_api#get_user_config', 'url' => '/api/v1/social/config/user/{key}', 'verb' => 'GET'], ] ]; diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index e94510cd0..60fd2e8b0 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -3,6 +3,7 @@ * @copyright Copyright (c) 2018 John Molakvoæ * * @author John Molakvoæ + * @author Matthias Heinisch * * @license GNU AGPL version 3 or any later version * @@ -23,12 +24,14 @@ namespace OCA\Contacts\Controller; -use OCA\Contacts\AppInfo\Application; use OCA\Contacts\Service\SocialApiService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\TemplateResponse; + +use OCA\Contacts\AppInfo\Application; use OCP\IConfig; use OCP\IInitialStateService; +use OCP\IUserSession; use OCP\IRequest; use OCP\L10N\IFactory; use OCP\Util; @@ -43,6 +46,9 @@ class PageController extends Controller { /** @var IFactory */ private $languageFactory; + /** @var IUserSession */ + private $userSession; + /** @var SocialApiService */ private $socialApiService; @@ -50,12 +56,15 @@ public function __construct(IRequest $request, IConfig $config, IInitialStateService $initialStateService, IFactory $languageFactory, + IUserSession $userSession, SocialApiService $socialApiService) { parent::__construct(Application::APP_ID, $request); + $this->appName = Application::APP_ID; $this->config = $config; $this->initialStateService = $initialStateService; $this->languageFactory = $languageFactory; + $this->userSession = $userSession; $this->socialApiService = $socialApiService; } @@ -66,17 +75,30 @@ public function __construct(IRequest $request, * Default routing */ public function index(): TemplateResponse { + $user = $this->userSession->getUser(); + $userId = ''; + if (!is_null($user)) { + $userId = $user->getUid(); + } + $locales = $this->languageFactory->findAvailableLocales(); - $defaultProfile = $this->config->getAppValue(Application::APP_ID, 'defaultProfile', 'HOME'); + $defaultProfile = $this->config->getAppValue($this->appName, 'defaultProfile', 'HOME'); $supportedNetworks = $this->socialApiService->getSupportedNetworks(); + $syncAllowedByAdmin = $this->config->getAppValue($this->appName, 'allowSocialSync', 'yes'); // allow users to retrieve avatars from social networks (default: yes) + $bgSyncEnabledByUser = $this->config->getUserValue($userId, $this->appName, 'enableSocialSync', 'no'); // automated background syncs for social avatars (default: no) - $this->initialStateService->provideInitialState(Application::APP_ID, 'locales', $locales); - $this->initialStateService->provideInitialState(Application::APP_ID, 'defaultProfile', $defaultProfile); - $this->initialStateService->provideInitialState(Application::APP_ID, 'supportedNetworks', $supportedNetworks); + $this->initialStateService->provideInitialState($this->appName, 'locales', $locales); + $this->initialStateService->provideInitialState($this->appName, 'defaultProfile', $defaultProfile); + $this->initialStateService->provideInitialState($this->appName, 'supportedNetworks', $supportedNetworks); + $this->initialStateService->provideInitialState($this->appName, 'locales', $locales); + $this->initialStateService->provideInitialState($this->appName, 'defaultProfile', $defaultProfile); + $this->initialStateService->provideInitialState($this->appName, 'supportedNetworks', $supportedNetworks); + $this->initialStateService->provideInitialState($this->appName, 'allowSocialSync', $syncAllowedByAdmin); + $this->initialStateService->provideInitialState($this->appName, 'enableSocialSync', $bgSyncEnabledByUser); - Util::addScript(Application::APP_ID, 'contacts'); - Util::addStyle(Application::APP_ID, 'contacts'); + Util::addScript($this->appName, 'contacts'); + Util::addStyle($this->appName, 'contacts'); - return new TemplateResponse(Application::APP_ID, 'main'); + return new TemplateResponse($this->appName, 'main'); } } diff --git a/lib/Controller/SocialApiController.php b/lib/Controller/SocialApiController.php index 4898d9151..b09eafdb7 100644 --- a/lib/Controller/SocialApiController.php +++ b/lib/Controller/SocialApiController.php @@ -30,21 +30,29 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\IConfig; use OCP\IRequest; +use OCP\IUserSession; class SocialApiController extends ApiController { + protected $appName; /** @var IConfig */ private $config; + /** @var IUserSession */ + private $userSession; + /** @var SocialApiService */ private $socialApiService; public function __construct(IRequest $request, IConfig $config, + IUserSession $userSession, SocialApiService $socialApiService) { parent::__construct(Application::APP_ID, $request); $this->config = $config; + $this->appName = Application::APP_ID; + $this->userSession = $userSession; $this->socialApiService = $socialApiService; } @@ -66,6 +74,46 @@ public function setAppConfig($key, $allow) { return new JSONResponse([], Http::STATUS_OK); } + /** + * @NoAdminRequired + * + * update appconfig (user setting) + * + * @param {String} key the identifier to change + * @param {String} allow the value to set + * + * @returns {JSONResponse} an empty JSONResponse with respective http status code + */ + public function setUserConfig($key, $allow) { + $user = $this->userSession->getUser(); + if (is_null($user)) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + $userId = $user->getUid(); + $this->config->setUserValue($userId, $this->appName, $key, $allow); + return new JSONResponse([], Http::STATUS_OK); + } + + + /** + * @NoAdminRequired + * + * retrieve appconfig (user setting) + * + * @param {String} key the identifier to retrieve + * + * @returns {string} the desired value or null if not existing + */ + public function getUserConfig($key) { + $user = $this->userSession->getUser(); + if (is_null($user)) { + return null; + } + $userId = $user->getUid(); + return $this->config->getUserValue($userId, $this->appName, $key, 'null'); + } + + /** * @NoAdminRequired * diff --git a/lib/Cron/SocialUpdate.php b/lib/Cron/SocialUpdate.php new file mode 100644 index 000000000..fe245cdca --- /dev/null +++ b/lib/Cron/SocialUpdate.php @@ -0,0 +1,42 @@ + + * + * @author Matthias Heinisch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Contacts\Cron; + +use OCA\Contacts\Service\SocialApiService; + +class SocialUpdate extends \OC\BackgroundJob\QueuedJob { + /** @var SocialUpdateService */ + private $social; + + public function __construct(SocialApiService $social) { + $this->social = $social; + } + + protected function run($arguments) { + $userId = $arguments['userId']; + + // update contacts with first available social media profile + $this->social->updateAddressbooks('any', $userId); + } +} diff --git a/lib/Cron/SocialUpdateRegistration.php b/lib/Cron/SocialUpdateRegistration.php new file mode 100644 index 000000000..58c7a51a3 --- /dev/null +++ b/lib/Cron/SocialUpdateRegistration.php @@ -0,0 +1,93 @@ + + * + * @author Georg Ehrke + * @author Roeland Jago Douma + * @author Matthias Heinisch + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Contacts\Cron; + +use OCA\Contacts\AppInfo\Application; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\IUser; +use OCP\IConfig; +use OCP\IUserManager; + +class SocialUpdateRegistration extends \OC\BackgroundJob\TimedJob { + private $appName; + + /** @var IUserManager */ + private $userManager; + + /** @var IJobList */ + private $jobList; + + /** @var IConfig */ + private $config; + + /** + * RegisterSocialUpdate constructor. + * + * @param ITimeFactory $time + * @param IUserManager $userManager + * @param IJobList $jobList + */ + public function __construct( + // ITimeFactory $time, + IUserManager $userManager, + IConfig $config, + IJobList $jobList) { + //parent::__construct($time); + + $this->appName = Application::APP_ID; + $this->userManager = $userManager; + $this->config = $config; + $this->jobList = $jobList; + + // Run once a week + parent::setInterval(7 * 24 * 60 * 60); + } + + /** + * @inheritDoc + */ + protected function run($arguments) { + + // check if admin allows for social updates: + $syncAllowedByAdmin = $this->config->getAppValue($this->appName, 'allowSocialSync', 'yes'); + if (!($syncAllowedByAdmin === 'yes')) { + return; + } + + $this->userManager->callForSeenUsers(function (IUser $user) { + + // check that user opted-in: + $bgSyncEnabledByUser = $this->config->getUserValue($user->getUID(), $this->appName, 'enableSocialSync', 'no'); + if ($bgSyncEnabledByUser === 'yes') { + $this->jobList->add(SocialUpdate::class, [ + 'userId' => $user->getUID() + ]); + } + }); + } +} diff --git a/lib/Service/SocialApiService.php b/lib/Service/SocialApiService.php index 154e0295a..38b650316 100644 --- a/lib/Service/SocialApiService.php +++ b/lib/Service/SocialApiService.php @@ -29,13 +29,18 @@ use OCP\Contacts\IManager; use OCP\IAddressBook; +use OCP\Util; use OCP\IConfig; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\Http\Client\IClientService; +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\CardDAV\ContactsManager; +use OCP\IURLGenerator; +use OCP\IL10N; class SocialApiService { - + private $appName; /** @var CompositeSocialProvider */ private $socialProvider; /** @var IManager */ @@ -44,16 +49,30 @@ class SocialApiService { private $config; /** @var IClientService */ private $clientService; + /** @var IL10N */ + private $l10n; + /** @var IURLGenerator */ + private $urlGen; + /** @var CardDavBackend */ + private $davBackend; + public function __construct( CompositeSocialProvider $socialProvider, IManager $manager, IConfig $config, - IClientService $clientService) { + IClientService $clientService, + IL10N $l10n, + IURLGenerator $urlGen, + CardDavBackend $davBackend) { + $this->appName = Application::APP_ID; $this->socialProvider = $socialProvider; $this->manager = $manager; $this->config = $config; $this->clientService = $clientService; + $this->l10n = $l10n; + $this->urlGen = $urlGen; + $this->davBackend = $davBackend; } @@ -65,8 +84,8 @@ public function __construct( * @returns {array} array of the supported social networks */ public function getSupportedNetworks() : array { - $isAdminEnabled = $this->config->getAppValue(Application::APP_ID, 'allowSocialSync', 'yes'); - if ($isAdminEnabled !== 'yes') { + $syncAllowedByAdmin = $this->config->getAppValue($this->appName, 'allowSocialSync', 'yes'); + if ($syncAllowedByAdmin !== 'yes') { return []; } return $this->socialProvider->getSupportedNetworks(); @@ -124,6 +143,20 @@ protected function getAddressBook(string $addressbookId) : ?IAddressBook { } + /** + * @NoAdminRequired + * + * Retrieves and initiates all addressbooks from a user + * + * @param {string} userId the user to query + * @param {IManager} the contact manager to load + */ + protected function registerAddressbooks($userId, IManager $manager) { + $coma = new ContactsManager($this->davBackend, $this->l10n); + $coma->setupContactsProvider($manager, $userId, $this->urlGen); + $this->manager = $manager; + } + /** * @NoAdminRequired * @@ -182,4 +215,105 @@ public function updateContact(string $addressbookId, string $contactId, string $ } return new JSONResponse([], Http::STATUS_OK); } + + /** + * @NoAdminRequired + * + * Stores the result of social avatar updates for each contact + * (used during batch updates in updateAddressbooks) + * + * @param {array} report where the results are added + * @param {String} entry the element to add + * @param {string} status the (http) status code + * + * @returns {array} the report including the new entry + */ + protected function registerUpdateResult(array $report, string $entry, string $status) : array { + // initialize report on first call + if (empty($report)) { + $report = [ + 'updated' => [], + 'checked' => [], + 'failed' => [], + ]; + } + // add entry to respective sub-array + switch ($status) { + case Http::STATUS_OK: + array_push($report['updated'], $entry); + break; + case Http::STATUS_NOT_MODIFIED: + array_push($report['checked'], $entry); + break; + default: + if (!isset($report['failed'][$status])) { + $report['failed'][$status] = []; + } + array_push($report['failed'][$status], $entry); + } + return $report; + } + + + /** + * @NoAdminRequired + * + * Updates social profile data for all contacts of an addressbook + * + * @param {String} network the social network to use (fallback: take first match) + * @param {String} userId the address book owner + * + * @returns {JSONResponse} JSONResponse with the list of changed and failed contacts + */ + public function updateAddressbooks(string $network, string $userId) : JSONResponse { + + // double check! + $syncAllowedByAdmin = $this->config->getAppValue($this->appName, 'allowSocialSync', 'yes'); + $bgSyncEnabledByUser = $this->config->getUserValue($userId, $this->appName, 'enableSocialSync', 'no'); + if (($syncAllowedByAdmin !== 'yes') || ($bgSyncEnabledByUser !== 'yes')) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + $delay = 1; + $response = []; + + // get corresponding addressbook + $this->registerAddressbooks($userId, $this->manager); + $addressBooks = $this->manager->getUserAddressBooks(); + + foreach ($addressBooks as $addressBook) { + if (is_null($addressBook)) { + continue; + } + if (Util::getVersion()[0] >= 20) { + //TODO: remove version check ^ when dependency for contacts is min NCv20 (see info.xml) + if ($addressBook->isShared() || $addressBook->isSystemAddressBook()) { + // TODO: filter out deactivated books, see https://github.com/nextcloud/server/issues/17537 + continue; + } + } + + // get contacts in that addressbook + $contacts = $addressBook->search('', ['UID'], ['types' => true]); + // TODO: can be optimized by: + // $contacts = $addressBook->search('', ['X-SOCIALPROFILE'], ['types' => true]); + // but see https://github.com/nextcloud/contacts/pull/1722#discussion_r463782429 + // and the referenced PR before activating this (index has to be re-created!) + + // update one contact after another + foreach ($contacts as $contact) { + // delay to prevent rate limiting issues + // TODO: do we need to send an Http::STATUS_PROCESSING ? + sleep($delay); + + try { + $r = $this->updateContact($addressBook->getURI(), $contact['UID'], $network); + $response = $this->registerUpdateResult($response, $contact['FN'], $r->getStatus()); + } catch (Exception $e) { + $response = $this->registerUpdateResult($response, $contact['FN'], '-1'); + } + } + } + return new JSONResponse([$response], Http::STATUS_OK); + } } diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index c84609179..00f987fa9 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -30,8 +30,9 @@ use OCP\Settings\ISettings; class AdminSettings implements ISettings { + protected $appName; - /** @var IConfig */ + /** @var IConfig */ private $config; /** @var IInitialStateService */ diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index aa65f0968..415048cec 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -1,3 +1,25 @@ + +