diff --git a/appinfo/info.xml b/appinfo/info.xml index ed6233431..5a8f696f6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -41,4 +41,8 @@ OCA\Contacts\ContactsMenu\Providers\DetailsProvider + + + OCA\Contacts\Settings\AdminSettings + diff --git a/appinfo/routes.php b/appinfo/routes.php index e09de7636..647b8f27d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,6 +25,8 @@ 'routes' => [ ['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' => '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'], ] ]; diff --git a/css/icons.scss b/css/icons.scss index 0d7c99d04..0685ea095 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -30,6 +30,14 @@ @include icon-black-white('no-calendar', 'contacts', 1); @include icon-black-white('language', 'contacts', 2); @include icon-black-white('clone', 'contacts', 2); +@include icon-black-white('sync', 'contacts', 2); + +// social network icons: +@include icon-black-white('facebook', 'contacts', 2); // “facebook (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/facebook?style=brands) +@include icon-black-white('instagram', 'contacts', 2); // “instagram (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/instagram?style=brands) +@include icon-black-white('mastodon', 'contacts', 2); // “mastodon (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/mastodon?style=brands) +@include icon-black-white('tumblr', 'contacts', 2); // “tumblr (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/tumblr?style=brands) +@include icon-black-white('twitter', 'contacts', 2); // “twitter (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/twitter?style=brands) .icon-up-force-white { // using #fffffe to trick the accessibility dark theme icon invert diff --git a/img/facebook.svg b/img/facebook.svg new file mode 100644 index 000000000..649b49451 --- /dev/null +++ b/img/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/img/instagram.svg b/img/instagram.svg new file mode 100644 index 000000000..53ab31190 --- /dev/null +++ b/img/instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/img/license.txt b/img/license.txt new file mode 100644 index 000000000..d4e74fdbe --- /dev/null +++ b/img/license.txt @@ -0,0 +1,6 @@ +# social network icons: +* “facebook (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/facebook?style=brands) +* “instagram (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/instagram?style=brands) +* “mastodon (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/mastodon?style=brands) +* “tumblr (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/tumblr?style=brands) +* “twitter (fab)” by fontawesome.com is licensed under CC BY 4.0. (https://fontawesome.com/icons/twitter?style=brands) diff --git a/img/mastodon.svg b/img/mastodon.svg new file mode 100644 index 000000000..4257e7c64 --- /dev/null +++ b/img/mastodon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/img/sync.svg b/img/sync.svg new file mode 100644 index 000000000..6cecc8d0a --- /dev/null +++ b/img/sync.svg @@ -0,0 +1,3 @@ + + + diff --git a/img/tumblr.svg b/img/tumblr.svg new file mode 100644 index 000000000..763bc4739 --- /dev/null +++ b/img/tumblr.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/img/twitter.svg b/img/twitter.svg new file mode 100644 index 000000000..9ac9e0b99 --- /dev/null +++ b/img/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3f382cf93..658e0b2e1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -33,6 +33,10 @@ class Application extends App { public function __construct() { parent::__construct(self::APP_ID); } + + public const AVAIL_SETTINGS = [ + 'allowSocialSync' => 'yes', + ]; public function register() { $server = $this->getContainer()->getServer(); diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index e52dfbcdd..846725aee 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -30,6 +30,7 @@ use OCP\IRequest; use OCP\L10N\IFactory; use OCP\Util; +use OCA\Contacts\Service\SocialApiService; class PageController extends Controller { @@ -44,17 +45,22 @@ class PageController extends Controller { /** @var IFactory */ private $languageFactory; + /** @var SocialApiService */ + private $socialApiService; + public function __construct(string $appName, - IRequest $request, - IConfig $config, - IInitialStateService $initialStateService, - IFactory $languageFactory) { + IRequest $request, + IConfig $config, + IInitialStateService $initialStateService, + IFactory $languageFactory, + SocialApiService $socialApiService) { parent::__construct($appName, $request); $this->appName = $appName; $this->config = $config; $this->initialStateService = $initialStateService; $this->languageFactory = $languageFactory; + $this->socialApiService = $socialApiService; } /** @@ -66,10 +72,12 @@ public function __construct(string $appName, public function index(): TemplateResponse { $locales = $this->languageFactory->findAvailableLocales(); $defaultProfile = $this->config->getAppValue($this->appName, 'defaultProfile', 'HOME'); + $supportedNetworks = $this->socialApiService->getSupportedNetworks(); $this->initialStateService->provideInitialState($this->appName, 'locales', $locales); $this->initialStateService->provideInitialState($this->appName, 'defaultProfile', $defaultProfile); - + $this->initialStateService->provideInitialState($this->appName, 'supportedNetworks', $supportedNetworks); + Util::addScript($this->appName, 'contacts'); Util::addStyle($this->appName, 'contacts'); diff --git a/lib/Controller/SocialApiController.php b/lib/Controller/SocialApiController.php new file mode 100644 index 000000000..5fa22cdad --- /dev/null +++ b/lib/Controller/SocialApiController.php @@ -0,0 +1,97 @@ + + * + * @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\Controller; + +use OCP\IConfig; +use OCP\AppFramework\ApiController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +use OCA\Contacts\Service\SocialApiService; +use OCA\Contacts\AppInfo\Application; + + +class SocialApiController extends ApiController { + + /** @var IConfig */ + private $config; + /** @var SocialApiService */ + private $socialApiService; + + public function __construct( + IRequest $request, + IConfig $config, + SocialApiService $socialApiService) { + parent::__construct(Application::APP_ID, $request); + + $this->config = $config; + $this->socialApiService = $socialApiService; + } + + + /** + * update appconfig (admin 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 setAppConfig($key, $allow) { + $permittedKeys = ['allowSocialSync']; + if (!in_array($key, $permittedKeys)) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + $this->config->setAppValue(Application::APP_ID, $key, $allow); + return new JSONResponse([], Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * returns an array of supported social networks + * + * @returns {array} array of the supported social networks + */ + public function getSupportedNetworks() : array { + return $this->socialApiService->getSupportedNetworks(); + } + + + /** + * @NoAdminRequired + * + * Retrieves social profile data for a contact and updates the entry + * + * @param {String} addressbookId the addressbook identifier + * @param {String} contactId the contact identifier + * @param {String} network the social network to use (if unkown: take first match) + * + * @returns {JSONResponse} an empty JSONResponse with respective http status code + */ + public function updateContact(string $addressbookId, string $contactId, string $network) : JSONResponse { + return $this->socialApiService->updateContact($addressbookId, $contactId, $network); + } +} diff --git a/lib/Service/Social/CompositeSocialProvider.php b/lib/Service/Social/CompositeSocialProvider.php new file mode 100644 index 000000000..28487342f --- /dev/null +++ b/lib/Service/Social/CompositeSocialProvider.php @@ -0,0 +1,97 @@ + + * + * @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\Service\Social; + +/** + * Composition of all social providers for easier usage + */ +class CompositeSocialProvider { + + /** @var ISocialProvider[] */ + private $providers; + + public function __construct(InstagramProvider $instagramProvider, + MastodonProvider $mastodonProvider, + FacebookProvider $facebookProvider, + TwitterProvider $twitterProvider, + TumblrProvider $tumblrProvider) { + + // This determines the priority of known providers + $this->providers = [ + 'instagram' => $instagramProvider, + 'mastodon' => $mastodonProvider, + 'twitter' => $twitterProvider, + 'facebook' => $facebookProvider, + 'tumblr' => $tumblrProvider, + ]; + } + + /** + * returns an array of supported social providers + * + * @returns String[] array of the supported social networks + */ + public function getSupportedNetworks() : array { + return array_keys($this->providers); + } + + + /** + * generate download url for a social entry + * + * @param array socialEntries all social data from the contact + * @param String network the choice which network to use (fallback: take first available) + * + * @returns String the url to the requested information or null in case of errors + */ + public function getSocialConnector(array $socialEntries, string $network) : ?string { + + $connector = null; + $selection = $this->providers; + // check if dedicated network selected + if (isset($this->providers[$network])) { + $selection = [$network => $this->providers[$network]]; + } + + // check selected providers in order + foreach($selection as $type => $socialProvider) { + + // search for this network in user's profile + foreach ($socialEntries as $socialEntry) { + + if (strtolower($type) === strtolower($socialEntry['type'])) { + $profileId = $socialProvider->cleanupId($socialEntry['value']); + if (!is_null($profileId)) { + $connector = $socialProvider->getImageUrl($profileId); + } + break; + } + } + if ($connector) { + break; + } + } + return ($connector); + } +} diff --git a/lib/Service/Social/FacebookProvider.php b/lib/Service/Social/FacebookProvider.php new file mode 100644 index 000000000..dc6066266 --- /dev/null +++ b/lib/Service/Social/FacebookProvider.php @@ -0,0 +1,55 @@ + + * + * @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\Service\Social; + +class FacebookProvider implements ISocialProvider { + + public function __construct() { + } + + /** + * Returns the profile-id + * + * @param {string} the value from the contact's x-socialprofile + * + * @return string + */ + public function cleanupId(string $candidate):?string { + return basename($candidate); + } + + /** + * Returns the profile-picture url + * + * @param {string} profileId the profile-id + * + * @return string|null + */ + public function getImageUrl(string $profileId):?string { + $recipe = 'https://graph.facebook.com/{socialId}/picture?width=720'; + $connector = str_replace("{socialId}", $profileId, $recipe); + return $connector; + } + +} diff --git a/lib/Service/Social/ISocialProvider.php b/lib/Service/Social/ISocialProvider.php new file mode 100644 index 000000000..1415f4d09 --- /dev/null +++ b/lib/Service/Social/ISocialProvider.php @@ -0,0 +1,45 @@ + + * + * @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\Service\Social; + +interface ISocialProvider { + + /** + * Returns the profile-id + * + * @param {string} the value from the contact's x-socialprofile + * + * @return string + */ + public function cleanupId(string $candidate):?string ; + + /** + * Returns the profile-picture url + * + * @param {string} profileId the profile-id + * + * @return string|null + */ + public function getImageUrl(string $profileId):?string ; +} diff --git a/lib/Service/Social/InstagramProvider.php b/lib/Service/Social/InstagramProvider.php new file mode 100644 index 000000000..673a0c9bb --- /dev/null +++ b/lib/Service/Social/InstagramProvider.php @@ -0,0 +1,88 @@ + + * + * @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\Service\Social; + +use OCP\Http\Client\IClientService; + +class InstagramProvider implements ISocialProvider { + + /** @var IClientService */ + private $httpClient; + + public function __construct(IClientService $httpClient) { + $this->httpClient = $httpClient->NewClient(); + } + + /** + * Returns the profile-id + * + * @param {string} the value from the contact's x-socialprofile + * + * @return string + */ + public function cleanupId(string $candidate):?string { + return basename($candidate); + } + + /** + * Returns the profile-picture url + * + * @param {string} profileId the profile-id + * + * @return string|null + */ + public function getImageUrl(string $profileId):?string { + $recipe = 'https://www.instagram.com/{socialId}/?__a=1'; + $connector = str_replace("{socialId}", $profileId, $recipe); + $connector = $this->getFromJson($connector, 'graphql->user->profile_pic_url_hd'); + return $connector; + } + + /** + * extracts desired value from a json + * + * @param {string} url the target from where to fetch the json + * @param {String} the desired key to filter for (nesting possible with '->') + * + * @returns {String} the extracted value or null if not present + */ + protected function getFromJson(string $url, string $desired) : ?string { + try { + $result = $this->httpClient->get($url); + + $jsonResult = json_decode($result->getBody(),true); + $location = explode ('->' , $desired); + foreach ($location as $loc) { + if (!isset($jsonResult[$loc])) { + return null; + } + $jsonResult = $jsonResult[$loc]; + } + return $jsonResult; + } + catch (Exception $e) { + return null; + } + } +} diff --git a/lib/Service/Social/MastodonProvider.php b/lib/Service/Social/MastodonProvider.php new file mode 100644 index 000000000..8895d61d1 --- /dev/null +++ b/lib/Service/Social/MastodonProvider.php @@ -0,0 +1,80 @@ + + * + * @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\Service\Social; + +use OCP\Http\Client\IClientService; + +class MastodonProvider implements ISocialProvider { + + /** @var IClientService */ + private $httpClient; + + public function __construct(IClientService $httpClient) { + $this->httpClient = $httpClient->NewClient(); + } + + /** + * Returns the profile-id + * + * @param {string} the value from the contact's x-socialprofile + * + * @return string + */ + public function cleanupId(string $candidate):?string { + try { + if (strpos($candidate, '@') === 0) { + $user_server = explode ('@', $candidate); + $candidate = 'https://' . $user_server[2] . '/@' . $user_server[1]; + } + } + catch (Exception $e) { + $candidate = null; + } + return $candidate; + } + + /** + * Returns the profile-picture url + * + * @param {string} profileUrl link to the profile + * + * @return string|null + */ + public function getImageUrl(string $profileUrl):?string { + try { + $result = $this->httpClient->get($profileUrl); + + $htmlResult = new \DOMDocument(); + $htmlResult->loadHTML($result->getBody()); + $img = $htmlResult->getElementById('profile_page_avatar'); + if (!is_null($img)) { + return $img->getAttribute("data-original"); + } + return null; + } + catch (Exception $e) { + return null; + } + } +} diff --git a/lib/Service/Social/TumblrProvider.php b/lib/Service/Social/TumblrProvider.php new file mode 100644 index 000000000..7a904b9f7 --- /dev/null +++ b/lib/Service/Social/TumblrProvider.php @@ -0,0 +1,59 @@ + + * + * @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\Service\Social; + +class TumblrProvider implements ISocialProvider { + + public function __construct() { + } + + /** + * Returns the profile-id + * + * @param {string} the value from the contact's x-socialprofile + * + * @return string + */ + public function cleanupId(string $candidate):?string { + $subdomain = '/(?:http[s]*\:\/\/)*(.*?)\.(?=[^\/]*\..{2,5})/i'; // subdomain + if (preg_match($subdomain, $candidate, $matches)) { + $candidate = $matches[1]; + } + return $candidate; + } + + /** + * Returns the profile-picture url + * + * @param {string} profileId the profile-id + * + * @return string|null + */ + public function getImageUrl(string $profileId):?string { + $recipe = 'https://api.tumblr.com/v2/blog/{socialId}/avatar/512'; + $connector = str_replace("{socialId}", $profileId, $recipe); + return $connector; + } + +} diff --git a/lib/Service/Social/TwitterProvider.php b/lib/Service/Social/TwitterProvider.php new file mode 100644 index 000000000..b0ee75ed9 --- /dev/null +++ b/lib/Service/Social/TwitterProvider.php @@ -0,0 +1,96 @@ + + * + * @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\Service\Social; + +use OCP\Http\Client\IClientService; + +class TwitterProvider implements ISocialProvider { + + /** @var IClientService */ + private $httpClient; + + public function __construct(IClientService $httpClient) { + $this->httpClient = $httpClient->NewClient(); + } + + /** + * Returns the profile-id + * + * @param {string} the value from the contact's x-socialprofile + * + * @return string + */ + public function cleanupId(string $candidate):?string { + $candidate = basename($candidate); + if ($candidate[0] === '@') { + $candidate = substr($candidate, 1); + } + return $candidate; + } + + /** + * Returns the profile-picture url + * + * @param {string} profileId the profile-id + * + * @return string|null + */ + public function getImageUrl(string $profileId):?string { + $recipe = 'https://mobile.twitter.com/{socialId}'; + $connector = str_replace("{socialId}", $profileId, $recipe); + $connector = $this->getFromHtml($connector, '_normal'); + return $connector; + } + + /** + * extracts desired value from an html page + * + * @param {string} url the target from where to fetch the content + * @param {String} the desired catchword to filter for + * + * @returns {String} the extracted value (first match) or null if not present + */ + protected function getFromHtml(string $url, string $desired) : ?string { + try { + $result = $this->httpClient->get($url); + + $htmlResult = new \DOMDocument(); + $htmlResult->loadHTML($result->getBody()); + $imgs = $htmlResult->getElementsByTagName('img'); + foreach ($imgs as $img) { + foreach ($img->attributes as $attr) { + $value = $attr->nodeValue; + if (strpos($value, $desired)) { + $value = str_replace("normal", "400x400", $value); + return $value; + } + } + } + return null; + } + catch (Exception $e) { + return null; + } + } +} diff --git a/lib/Service/SocialApiService.php b/lib/Service/SocialApiService.php new file mode 100644 index 000000000..ecca2f8e8 --- /dev/null +++ b/lib/Service/SocialApiService.php @@ -0,0 +1,192 @@ + + * + * @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\Service; + +use OCA\Contacts\Service\Social\CompositeSocialProvider; +use OCA\Contacts\AppInfo\Application; + +use OCP\Contacts\IManager; +use OCP\IAddressBook; + +use OCP\IConfig; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\Client\IClientService; + + +class SocialApiService { + + /** @var CompositeSocialProvider */ + private $socialProvider; + /** @var IManager */ + private $manager; + /** @var IConfig */ + private $config; + /** @var IClientService */ + private $clientService; + + public function __construct( + CompositeSocialProvider $socialProvider, + IManager $manager, + IConfig $config, + IClientService $clientService) { + + $this->socialProvider = $socialProvider; + $this->manager = $manager; + $this->config = $config; + $this->clientService = $clientService; + } + + + /** + * @NoAdminRequired + * + * returns an array of supported social networks + * + * @returns {array} array of the supported social networks + */ + public function getSupportedNetworks() : array { + $isAdminEnabled = $this->config->getAppValue(Application::APP_ID, 'allowSocialSync', 'yes'); + if ($isAdminEnabled !== 'yes') { + return array(); + } + return $this->socialProvider->getSupportedNetworks(); + } + + + /** + * @NoAdminRequired + * + * Adds/updates photo for contact + * + * @param {pointer} contact reference to the contact to update + * @param {string} imageType the image type of the photo + * @param {string} photo the photo as base64 string + */ + protected function addPhoto(array &$contact, string $imageType, string $photo) { + + $version = $contact['VERSION']; + + if (!empty($contact['PHOTO'])) { + // overwriting without notice! + } + + if ($version >= 4.0) { + // overwrite photo + $contact['PHOTO'] = "data:" . $imageType . ";base64," . $photo; + } + + elseif ($version >= 3.0) { + // add new photo + $imageType = str_replace('image/', '', $imageType); + $contact['PHOTO;ENCODING=b;TYPE=' . $imageType . ';VALUE=BINARY'] = $photo; + + // remove previous photo (necessary as new attribute is not equal to 'PHOTO') + $contact['PHOTO'] = ''; + } + } + + + /** + * @NoAdminRequired + * + * Gets the addressbook of an addressbookId + * + * @param {String} addressbookId the identifier of the addressbook + * + * @returns {IAddressBook} the corresponding addressbook or null + */ + protected function getAddressBook(string $addressbookId) : ?IAddressBook { + $addressBook = null; + $addressBooks = $this->manager->getUserAddressBooks(); + foreach($addressBooks as $ab) { + if ($ab->getUri() === $addressbookId) { + $addressBook = $ab; + } + } + return $addressBook; + } + + + /** + * @NoAdminRequired + * + * Retrieves social profile data for a contact and updates the entry + * + * @param {String} addressbookId the addressbook identifier + * @param {String} contactId the contact identifier + * @param {String} network the social network to use (if unkown: take first match) + * + * @returns {JSONResponse} an empty JSONResponse with respective http status code + */ + public function updateContact(string $addressbookId, string $contactId, string $network) : JSONResponse { + + $url = null; + + try { + // get corresponding addressbook + $addressBook = $this->getAddressBook($addressbookId); + if (is_null($addressBook)) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + // search contact in that addressbook, get social data + $contact = $addressBook->search($contactId, ['UID'], ['types' => true])[0]; + if (!isset($contact['X-SOCIALPROFILE'])) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + $socialprofiles = $contact['X-SOCIALPROFILE']; + // retrieve data + $url = $this->socialProvider->getSocialConnector($socialprofiles, $network); + + if (empty($url)) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + $httpResult = $this->clientService->NewClient()->get($url); + $socialdata = $httpResult->getBody(); + $imageType = $httpResult->getHeader('content-type'); + + if (!$socialdata || $imageType === null) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // update contact + $changes = array(); + $changes['URI'] = $contact['URI']; + $changes['VERSION'] = $contact['VERSION']; + $this->addPhoto($changes, $imageType, base64_encode($socialdata)); + + if (isset($contact['PHOTO']) && $changes['PHOTO'] === $contact['PHOTO']) { + return new JSONResponse([], Http::STATUS_NOT_MODIFIED); + } + + $addressBook->createOrUpdate($changes, $addressbookId); + } + catch (Exception $e) { + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + return new JSONResponse([], Http::STATUS_OK); + } +} diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php new file mode 100644 index 000000000..7408f2d85 --- /dev/null +++ b/lib/Settings/AdminSettings.php @@ -0,0 +1,80 @@ + + * + * @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\Settings; + +use OCA\Contacts\AppInfo\Application; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IConfig; +use OCP\IInitialStateService; +use OCP\Settings\ISettings; + +class AdminSettings implements ISettings { + + /** @var IConfig */ + private $config; + + /** @var IInitialStateService */ + private $initialStateService; + + /** + * Admin constructor. + * + * @param IConfig $config + * @param IL10N $l + */ + public function __construct(IConfig $config, IInitialStateService $initialStateService) { + $this->appName = Application::APP_ID; + $this->config = $config; + $this->initialStateService = $initialStateService; + } + + /** + * @return TemplateResponse + */ + public function getForm() { + foreach (Application::AVAIL_SETTINGS as $key => $default) { + $data = $this->config->getAppValue($this->appName, $key, $default); + $this->initialStateService->provideInitialState($this->appName, $key, $data); + } + return new TemplateResponse($this->appName, 'settings/admin'); + + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection() { + return 'groupware'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + */ + public function getPriority() { + return 75; + } + +} diff --git a/package.json b/package.json index 109d88db2..3aec92933 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@nextcloud/paths": "^1.1.2", "@nextcloud/router": "^1.0.2", "@nextcloud/vue": "2.0.0", + "@nextcloud/axios": "^1.3.2", "axios": "^0.19.2", "cdav-library": "git+https://github.com/nextcloud/cdav-library.git", "core-js": "^3.6.5", diff --git a/src/adminSettings.js b/src/adminSettings.js new file mode 100644 index 000000000..d9635b2a4 --- /dev/null +++ b/src/adminSettings.js @@ -0,0 +1,34 @@ +/** + * @copyright Copyright (c) 2020 Gary Kim + * + * @author Gary Kim + * + * @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 . + * + */ + +import Vue from 'vue' +import AdminSettings from './components/AdminSettings' + +document.addEventListener('DOMContentLoaded', main) + +function main() { + Vue.prototype.t = t + + const View = Vue.extend(AdminSettings) + const view = new View() + view.$mount('#contacts-settings') +} diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue new file mode 100644 index 000000000..ac840085d --- /dev/null +++ b/src/components/AdminSettings.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index 9553bb1c2..6d4c6fd90 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -39,7 +39,9 @@
- + diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue index 7b211c8c5..3fc6f1ec9 100644 --- a/src/components/ContactDetails/ContactDetailsAvatar.vue +++ b/src/components/ContactDetails/ContactDetailsAvatar.vue @@ -3,6 +3,7 @@ - - @author Team Popcorn - @author John Molakvoæ + - @author Matthias Heinisch - - @license GNU AGPL version 3 or any later version - @@ -25,6 +26,7 @@
+
- - + + {{ t('contacts', 'Upload a new picture') }} - + {{ t('contacts', 'Choose from files') }} + + {{ t('contacts', 'Get from ' + network) }} +
@@ -93,11 +102,14 @@ import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import Modal from '@nextcloud/vue/dist/Components/Modal' import { getFilePickerBuilder } from '@nextcloud/dialogs' -import { generateRemoteUrl } from '@nextcloud/router' +import { generateUrl, generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { loadState } from '@nextcloud/initial-state' import sanitizeSVG from '@mattkrick/sanitize-svg' -const axios = () => import('axios') +import axios from '@nextcloud/axios' + +const supportedNetworks = loadState('contacts', 'supportedNetworks') export default { name: 'ContactDetailsAvatar', @@ -133,6 +145,16 @@ export default { } return false }, + supportedSocial() { + // get social networks set for the current contact + const available = this.contact.vCard.getAllProperties('x-socialprofile') + .map(a => a.jCal[1].type.toString().toLowerCase()) + // get list of social networks that allow for avatar download + const supported = supportedNetworks.map(v => v.toLowerCase()) + // return supported social networks which are set + return supported.filter(i => available.includes(i)) + .map(j => this.capitalize(j)) + }, }, mounted() { // update image size on window resize @@ -215,7 +237,15 @@ export default { this.$refs.uploadInput.value = '' this.loading = false }, - + /** + * Return the word with (only) the first letter capitalized + * + * @param {string} word the word to handle + * @returns {string} the word with the first letter capitalized + */ + capitalize(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + }, /** * Return the mimetype based on the first magix byte * @@ -314,8 +344,7 @@ export default { if (file) { this.loading = true try { - const { get } = await axios() - const response = await get(`${this.root}${file}`, { + const response = await axios.get(`${this.root}${file}`, { responseType: 'arraybuffer', }) const type = response.headers['content-type'] @@ -330,6 +359,47 @@ export default { } }, + /** + * Downloads the Avatar from social media + * + * @param {String} network the social network to use (or 'any' for first match) + */ + async getSocialAvatar(network) { + + if (!this.loading) { + + this.loading = true + try { + const response = await axios.put(generateUrl('/apps/contacts/api/v1/social/avatar/{network}/{id}/{uid}', { + network: network.toLowerCase(), + id: this.contact.addressbook.id, + uid: this.contact.uid, + })) + if (response.status !== 200) { + throw new URIError('Download of social profile avatar failed') + } + + // Fetch newly updated contact + await this.$store.dispatch('fetchFullContact', { contact: this.contact, forceReFetch: true }) + + // Update local clone + const contact = this.$store.getters.getContact(this.contact.key) + await this.$emit('updateLocalContact', contact) + + // Notify user + OC.Notification.showTemporary(t('contacts', 'Avatar downloaded from social network')) + } catch (error) { + if (error.response.status === 304) { + OC.Notification.showTemporary(t('contacts', 'Avatar already up to date')) + } else { + OC.Notification.showTemporary(t('contacts', 'Avatar download failed')) + console.debug(error) + } + } + } + this.loading = false + }, + /** * Menu handling */ diff --git a/src/store/contacts.js b/src/store/contacts.js index f2602f36f..fed655873 100644 --- a/src/store/contacts.js +++ b/src/store/contacts.js @@ -372,11 +372,11 @@ const actions = { * @param {string} data.etag the contact etag to override in case of conflict * @returns {Promise} */ - async fetchFullContact(context, { contact, etag = '' }) { + async fetchFullContact(context, { contact, etag = '', forceReFetch = false }) { if (etag.trim() !== '') { await context.commit('updateContactEtag', { contact, etag }) } - return contact.dav.fetchCompleteData() + return contact.dav.fetchCompleteData(forceReFetch) .then((response) => { const newContact = new Contact(contact.dav.data, contact.addressbook) context.commit('updateContact', newContact) diff --git a/templates/settings/admin.php b/templates/settings/admin.php new file mode 100644 index 000000000..347b1b168 --- /dev/null +++ b/templates/settings/admin.php @@ -0,0 +1,5 @@ + + +
diff --git a/tests/unit/Controller/PageControllerTest.php b/tests/unit/Controller/PageControllerTest.php index 1673269ec..7b188eb99 100644 --- a/tests/unit/Controller/PageControllerTest.php +++ b/tests/unit/Controller/PageControllerTest.php @@ -29,9 +29,9 @@ use OCP\IInitialStateService; use OCP\IRequest; use OCP\L10N\IFactory; +use OCA\Contacts\Service\SocialApiService; use ChristophWurst\Nextcloud\Testing\TestCase; - class PageControllerTest extends TestCase { private $controller; @@ -39,31 +39,34 @@ class PageControllerTest extends TestCase { /** @var IRequest|MockObject */ private $request; + /** @var IConfig|MockObject*/ + private $config; + /** @var IInitialStateService|MockObject */ private $initialStateService; /** @var IFactory|MockObject */ private $languageFactory; - /** @var IConfig|MockObject*/ - private $config; - + /** @var SocialApiService|MockObject*/ + private $socialApi; public function setUp() { parent::setUp(); $this->request = $this->createMock(IRequest::class); + $this->config = $this->createMock(IConfig::class); $this->initialStateService = $this->createMock(IInitialStateService::class); $this->languageFactory = $this->createMock(IFactory::class); - $this->config = $this->createMock(IConfig::class); + $this->socialApi = $this->createMock(SocialApiService::class); $this->controller = new PageController( 'contacts', $this->request, $this->config, $this->initialStateService, - $this->languageFactory - + $this->languageFactory, + $this->socialApi ); } diff --git a/tests/unit/Service/SocialApiServiceTest.php b/tests/unit/Service/SocialApiServiceTest.php new file mode 100644 index 000000000..4a911a078 --- /dev/null +++ b/tests/unit/Service/SocialApiServiceTest.php @@ -0,0 +1,136 @@ + + * + * @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\Service; + +use OCA\Contacts\Service\Social\CompositeSocialProvider; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IResponse; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\Contacts\IManager; +use OCP\IAddressBook; + +use PHPUnit\Framework\MockObject\MockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; + + +class SocialApiServiceTest extends TestCase { + + private $service; + + /** @var CompositeSocialProvider */ + private $socialProvider; + /** @var IManager|MockObject */ + private $manager; + /** @var IConfig|MockObject */ + private $config; + /** @var IClientService|MockObject */ + private $clientService; + + public function socialProfileProvider() { + return [ + 'no social profiles set' => [null, 'someConnector', 'someResult', Http::STATUS_PRECONDITION_FAILED], + 'valid social profile' => [[['type' => 'someNetwork', 'value' => 'someId']], 'someConnector', 'someResult', Http::STATUS_OK], + 'bad formatted profile id' => [[['type' => 'someNetwork', 'value' => 'someId']], null, 'someResult', Http::STATUS_BAD_REQUEST], + 'not existing profile id' => [[['type' => 'someNetwork', 'value' => 'someId']], 'someConnector', '', Http::STATUS_NOT_FOUND], + 'unchanged data' => [[['type' => 'someNetwork', 'value' => 'someId']], 'someConnector', 'thePhoto', Http::STATUS_NOT_MODIFIED], + ]; + } + + public function setUp() { + parent::setUp(); + + $this->manager = $this->createMock(IManager::class); + $this->config = $this->createMock(IConfig::class); + $this->socialProvider = $this->createMock(CompositeSocialProvider::class); + $this->clientService = $this->createMock(IClientService::class); + $this->service = new SocialApiService( + $this->socialProvider, + $this->manager, + $this->config, + $this->clientService + ); + } + + public function testDeactivatedSocial() { + $this->config + ->method('getAppValue') + ->willReturn('no'); + + $result = $this->service->getSupportedNetworks(); + $this->assertEmpty($result); + } + + + /** + * @dataProvider socialProfileProvider + */ + public function testUpdateContact($social, $connector, $httpResult, $expected) { + + $contact = [ + 'URI' => '3225c0d5-1bd2-43e5-a08c-4e65eaa406b0', + 'VERSION' => '4.0', + 'PHOTO' => "data:" . $httpResult . ";base64," . base64_encode('thePhoto'), + 'X-SOCIALPROFILE' => $social, + ]; + $addressbook = $this->createMock(IAddressBook::class); + $addressbook + ->method('getUri') + ->willReturn('contacts'); + $addressbook + ->method('search') + ->willReturn(array($contact)); + + $this->manager + ->method('getUserAddressBooks') + ->willReturn(array($addressbook)); + + $this->socialProvider + ->method('getSocialConnector') + ->willReturn($connector); + + $response = $this->createMock(IResponse::class); + $response + ->method('getBody') + ->willReturn($httpResult); + $response + ->method('getHeader') + ->willReturn($httpResult); + $client = $this->createMock(IClient::class); + $client + ->method('get') + ->willReturn($response); + $this->clientService + ->method('NewClient') + ->willReturn($client); + + $result = $this->service->updateContact('contacts', '3225c0d5-1bd2-43e5-a08c-4e65eaa406b0', 'theSocialNetwork'); + + $this->assertEquals($expected, $result->getStatus()); + } +} diff --git a/webpack.common.js b/webpack.common.js index 63df5680e..8aff48847 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -3,15 +3,16 @@ const webpack = require('webpack') const { VueLoaderPlugin } = require('vue-loader') const StyleLintPlugin = require('stylelint-webpack-plugin') const packageJson = require('./package.json') -const appName = packageJson.name const appVersion = JSON.stringify(packageJson.version) module.exports = { - entry: path.join(__dirname, 'src', 'main.js'), + entry: { + adminSettings: path.join(__dirname, 'src', 'adminSettings.js'), + contacts: path.join(__dirname, 'src', 'main.js'), + }, output: { path: path.resolve(__dirname, './js'), publicPath: '/js/', - filename: `${appName}.js`, chunkFilename: 'chunks/[name]-[hash].js' }, module: {