From 3b3132e9d6ccf9fd52a68f602bd5f172ff860f2a Mon Sep 17 00:00:00 2001 From: Erik Hanson Date: Fri, 6 Oct 2023 14:05:01 -0700 Subject: [PATCH] WIP: ORCID integration into core --- api/v1/orcid/OrcidController.php | 50 +++ api/v1/orcid/index.php | 3 + .../forms/context/OrcidSettingsForm.php | 148 +++++++++ .../forms/site/OrcidSiteSettingsForm.php | 40 +++ .../upgrade/v3_5_0/OrcidMigration.php | 62 ++++ classes/orcid/OrcidManager.php | 288 ++++++++++++++++ classes/orcid/OrcidReview.php | 121 +++++++ classes/orcid/OrcidWork.php | 312 ++++++++++++++++++ classes/orcid/actions/AuthorizeUserData.php | 138 ++++++++ classes/orcid/actions/SendAuthorMail.php | 64 ++++ .../orcid/actions/VerifyAuthorWithOrcid.php | 143 ++++++++ jobs/orcid/DepositOrcidSubmission.php | 128 +++++++ jobs/orcid/PublishReviewerWorkToOrcid.php | 106 ++++++ pages/article/ArticleHandler.php | 2 + pages/orcid/OrcidHandler.php | 148 +++++++++ pages/orcid/index.php | 8 + plugins/themes/default/styles/index.less | 2 + registry/emailTemplates.xml | 2 + schemas/context.json | 58 ++++ templates/frontend/pages/orcidAbout.tpl | 35 ++ templates/frontend/pages/orcidVerify.tpl | 61 ++++ 21 files changed, 1919 insertions(+) create mode 100644 api/v1/orcid/OrcidController.php create mode 100644 api/v1/orcid/index.php create mode 100644 classes/components/forms/context/OrcidSettingsForm.php create mode 100644 classes/components/forms/site/OrcidSiteSettingsForm.php create mode 100644 classes/migration/upgrade/v3_5_0/OrcidMigration.php create mode 100644 classes/orcid/OrcidManager.php create mode 100644 classes/orcid/OrcidReview.php create mode 100644 classes/orcid/OrcidWork.php create mode 100644 classes/orcid/actions/AuthorizeUserData.php create mode 100644 classes/orcid/actions/SendAuthorMail.php create mode 100644 classes/orcid/actions/VerifyAuthorWithOrcid.php create mode 100644 jobs/orcid/DepositOrcidSubmission.php create mode 100644 jobs/orcid/PublishReviewerWorkToOrcid.php create mode 100644 pages/orcid/OrcidHandler.php create mode 100644 pages/orcid/index.php create mode 100644 templates/frontend/pages/orcidAbout.tpl create mode 100644 templates/frontend/pages/orcidVerify.tpl diff --git a/api/v1/orcid/OrcidController.php b/api/v1/orcid/OrcidController.php new file mode 100644 index 00000000000..4cf3e873ec2 --- /dev/null +++ b/api/v1/orcid/OrcidController.php @@ -0,0 +1,50 @@ +authorizeOrcid(...)) + ->name('orcid.authorize'); + Route::post('verify', $this->verify(...)) + ->name('orcid.verify'); + } + + public function authorizeOrcid(Request $illuminateRequest): JsonResponse + { + return response()->json([], Response::HTTP_OK); + } + + public function verify(Request $illuminateRequest): JsonResponse + { + return response()->json([], Response::HTTP_OK); + } +} diff --git a/api/v1/orcid/index.php b/api/v1/orcid/index.php new file mode 100644 index 00000000000..08f607485ad --- /dev/null +++ b/api/v1/orcid/index.php @@ -0,0 +1,3 @@ +action = $action; + $this->locales = $locales; + $this->context = $context; + + $this->addGroup(['id' => self::ORCID_DEFAULT_GROUP]) + ->addGroup([ + 'id' => self::ORCID_SETTINGS_GROUP, + 'showWhen' => OrcidManager::ENABLED + ]) + // TODO: Handle disabling form requirement when ORCID functionality is disabled. + ->addField(new FieldOptions(OrcidManager::ENABLED, [ + 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath'), + 'groupId' => self::ORCID_DEFAULT_GROUP, + 'options' => [ + // TODO: Remove temporary hard-coded string + ['value' => true, 'label' => 'Enable ORCID Profile functionality'] + ], + 'value' => (bool) $context->getData(OrcidManager::ENABLED) ?? false + ])) + ->addField(new FieldHTML('settingsDescription', [ + 'groupId' => self::ORCID_DEFAULT_GROUP, + 'description' => __('orcidProfile.manager.settings.description'), + ])); + + // ORCID API settings can be configured globally via config file or from this settings form + if (OrcidManager::isGloballyConfigured()) { + $site = Application::get()->getRequest()->getSite(); + + $this->addField(new FieldHTML(OrcidManager::API_TYPE, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath'), + 'description' => $this->getLocalizedApiTypeString($site->getData(OrcidManager::API_TYPE)) + ])) + ->addField(new FieldHTML(OrcidManager::CLIENT_ID, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => __('orcidProfile.manager.settings.orcidClientId'), + 'description' => $site->getData(OrcidManager::CLIENT_ID), + ])) + ->addField(new FieldHTML(OrcidManager::CLIENT_SECRET, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => __('orcidProfile.manager.settings.orcidClientSecret'), + 'description' => $site->getData(OrcidManager::CLIENT_SECRET), + ])); + + } else { + $this->addField(new FieldSelect(OrcidManager::API_TYPE, [ + 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath'), + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'isRequired' => true, + 'options' => [ + ['value' => OrcidManager::API_PUBLIC_PRODUCTION, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.public')], + ['value' => OrcidManager::API_PUBLIC_SANDBOX, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.publicSandbox')], + ['value' => OrcidManager::API_MEMBER_PRODUCTION, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.member')], + ['value' => OrcidManager::API_MEMBER_SANDBOX, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.memberSandbox')], + ], + 'value' => $context->getData(OrcidManager::API_TYPE) ?? OrcidManager::API_PUBLIC_PRODUCTION, + ])) + ->addField(new FieldText(OrcidManager::CLIENT_ID, [ + 'label' => __('orcidProfile.manager.settings.orcidClientId'), + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'isRequired' => true, + 'value' => $context->getData(OrcidManager::CLIENT_ID) ?? '', + ])) + ->addField(new FieldText(OrcidManager::CLIENT_SECRET, [ + 'label' => __('orcidProfile.manager.settings.orcidClientSecret'), + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'isRequired' => true, + 'value' => $context->getData(OrcidManager::CLIENT_SECRET) ?? '', + ])); + } + + // TODO: Labeled as OJS-specific in settingsForm.tpl. Check status + $countries = []; + foreach (Locale::getCountries() as $country) { + $countries[] = [ + 'value' => $country->getAlpha2(), + 'label' => $country->getLocalName(), + ]; + } + usort($countries, function ($a, $b) { + return strcmp($a['label'], $b['label']); + }); + $this->addField(new FieldSelect(OrcidManager::COUNTRY, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => __('orcidProfile.manager.settings.country'), + 'description' => __('orcidProfile.manager.settings.review.help'), + 'options' => $countries, + 'value' => $context->getData(OrcidManager::COUNTRY) ?? '', + ])) + ->addField(new FieldText(OrcidManager::CITY, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => 'orcidProfile.manager.settings.city', + 'value' => $context->getData(OrcidManager::CITY) ?? '', + ])) + ->addField(new FieldOptions(OrcidManager::SEND_MAIL_TO_AUTHORS_ON_PUBLICATION, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => __('orcidProfile.manager.settings.mailSectionTitle'), + 'options' => [ + ['value' => true, 'label' => __('orcidProfile.manager.settings.sendMailToAuthorsOnPublication')] + ], + 'value' => (bool) $context->getData(OrcidManager::SEND_MAIL_TO_AUTHORS_ON_PUBLICATION) ?? false, + ])) + ->addField(new FieldSelect(OrcidManager::LOG_LEVEL, [ + 'groupId' => self::ORCID_SETTINGS_GROUP, + 'label' => __('orcidProfile.manager.settings.logSectionTitle'), + 'description' => __('orcidProfile.manager.settings.logLevel.help'), + 'options' => [ + ['value' => 'ERROR', 'label' => __('orcidProfile.manager.settings.logLevel.error')], + ['value' => 'ALL', 'label' => __('orcidProfile.manager.settings.logLevel.all')], + ], + 'value' => $context->getData(OrcidManager::LOG_LEVEL) ?? 'ERROR', + ])); + } + + private function getLocalizedApiTypeString(string $apiType): string + { + return match ($apiType) { + OrcidManager::API_PUBLIC_PRODUCTION => __('orcidProfile.manager.settings.orcidProfileAPIPath.public'), + OrcidManager::API_PUBLIC_SANDBOX => __('orcidProfile.manager.settings.orcidProfileAPIPath.publicSandbox'), + OrcidManager::API_MEMBER_PRODUCTION => __('orcidProfile.manager.settings.orcidProfileAPIPath.member'), + OrcidManager::API_MEMBER_SANDBOX => __('orcidProfile.manager.settings.orcidProfileAPIPath.memberSandbox'), + }; + } +} diff --git a/classes/components/forms/site/OrcidSiteSettingsForm.php b/classes/components/forms/site/OrcidSiteSettingsForm.php new file mode 100644 index 00000000000..66605c38da7 --- /dev/null +++ b/classes/components/forms/site/OrcidSiteSettingsForm.php @@ -0,0 +1,40 @@ +id, $this->method, $action, $locales); + + // TODO: How to handle all should be blank or completed (all or nothing) + $this->addField(new FieldSelect(OrcidManager::API_TYPE, [ + 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath'), + 'options' => [ + ['value' => OrcidManager::API_PUBLIC_PRODUCTION, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.public')], + ['value' => OrcidManager::API_PUBLIC_SANDBOX, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.publicSandbox')], + ['value' => OrcidManager::API_MEMBER_PRODUCTION, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.member')], + ['value' => OrcidManager::API_MEMBER_SANDBOX, 'label' => __('orcidProfile.manager.settings.orcidProfileAPIPath.memberSandbox')], + ], + 'value' => $site->getData(OrcidManager::API_TYPE) ?? OrcidManager::API_PUBLIC_PRODUCTION, + ])) + ->addField(new FieldText(OrcidManager::CLIENT_ID, [ + 'label' => __('orcidProfile.manager.settings.orcidClientId'), + 'value' => $site->getData(OrcidManager::CLIENT_ID) ?? '', + ])) + ->addField(new FieldText(OrcidManager::CLIENT_SECRET, [ + 'label' => __('orcidProfile.manager.settings.orcidClientSecret'), + 'value' => $site->getData(OrcidManager::CLIENT_SECRET) ?? '', + ])); + } +} diff --git a/classes/migration/upgrade/v3_5_0/OrcidMigration.php b/classes/migration/upgrade/v3_5_0/OrcidMigration.php new file mode 100644 index 00000000000..b068fbd8cdd --- /dev/null +++ b/classes/migration/upgrade/v3_5_0/OrcidMigration.php @@ -0,0 +1,62 @@ +where('plugin_name', '=', 'orcidprofileplugin') + ->where('context_id', '<>', 0) + ->select(['context_id', 'setting_name', 'setting_value']); + + $results = $q->get(); + $mappedResults = $results->map(function ($item) { + if(!Str::startsWith($item->setting_name, 'orcid')) { + $item->setting_name = 'orcid' . Str::ucfirst($item->setting_name); + } + $item->journal_id = $item->context_id; + unset($item->context_id); + + return (array) $item; + }); + + DB::table('journal_settings') + ->insert($mappedResults->toArray()); + + // TODO: To be tested still. Keeping in plugin_settings during dev. + // DB::table('plugin_settings') + // ->where('plugin_name', '=', 'orcidprofileplugin') + // ->delete(); + + Repo::emailTemplate()->dao->installEmailTemplates( + Repo::emailTemplate()->dao->getMainEmailTemplatesFilename(), + [], + 'ORCID_COLLECT_AUTHOR_ID', + true, + ); + Repo::emailTemplate()->dao->installEmailTemplates( + Repo::emailTemplate()->dao->getMainEmailTemplatesFilename(), + [], + 'ORCID_REQUEST_AUTHOR_AUTHORIZATION', + true, + ); + } + + /** + * @inheritDoc + */ + public function down(): void + { + // TODO: Implement down() method. + } +} diff --git a/classes/orcid/OrcidManager.php b/classes/orcid/OrcidManager.php new file mode 100644 index 00000000000..3fe2ca0d8ea --- /dev/null +++ b/classes/orcid/OrcidManager.php @@ -0,0 +1,288 @@ +getRequest()->getSite(); + $apiType = $site->getData(self::API_TYPE); + $clientId = $site->getData(self::CLIENT_ID); + $clientSecret = $site->getData(self::CLIENT_SECRET); + return isset($apiType) && trim($apiType) && isset($clientId) && trim($clientId) && + isset($clientSecret) && trim($clientSecret); + } + + /** + * Return a string of the ORCiD SVG icon + * + */ + public static function getIcon(): string + { + $path = Core::getBaseDir() . '/' . PKP_LIB_PATH . '/templates/images/orcid.svg'; + return file_exists($path) ? file_get_contents($path) : ''; + } + + public static function isEnabled(?Context $context = null): bool + { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + + return (bool) $context?->getData(self::ENABLED); + } + + public static function getOrcidUrl(?Context $context = null): string + { + if (self::isGloballyConfigured()) { + $apiType = Application::get()->getRequest()->getSite()->getData(self::API_TYPE); + } else { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + $apiType = $context->getData(self::API_TYPE); + } + return in_array($apiType, [self::API_PUBLIC_PRODUCTION, self::API_MEMBER_PRODUCTION]) ? self::ORCID_URL : self::ORCID_URL_SANDBOX; + } + + public static function getApiPath(?Context $context = null): string + { + if (self::isGloballyConfigured()) { + $apiType = Application::get()->getRequest()->getSite()->getData(OrcidManager::API_TYPE); + } else { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + $apiType = $context->getData(OrcidManager::API_TYPE); + } + + return match ($apiType) { + self::API_PUBLIC_SANDBOX => self::ORCID_API_URL_PUBLIC_SANDBOX, + self::API_MEMBER_PRODUCTION => self::ORCID_API_URL_MEMBER, + self::API_MEMBER_SANDBOX => self::ORCID_API_URL_MEMBER_SANDBOX, + default => self::ORCID_API_URL_PUBLIC, + }; + } + + public static function isSandbox(?Context $context = null): bool + { + if (self::isGloballyConfigured()) { + $apiType = Application::get()->getRequest()->getSite()->getData(OrcidManager::API_TYPE); + } else { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + $apiType = $context->getData(OrcidManager::API_TYPE); + } + + return in_array($apiType, [self::API_PUBLIC_SANDBOX, self::API_MEMBER_SANDBOX]); + } + + /** + * TODO: update as needed for new API + * + * @param string $handlerMethod Previously: containting a valid method of the OrcidProfileHandler + * @param array $redirectParams Additional request parameters for the redirect URL + * + * @throws \Exception + */ + public static function buildOAuthUrl(string $handlerMethod, array $redirectParams): string + { + $request = Application::get()->getRequest(); + $context = $request->getContext(); + if ($context === null) { + throw new \Exception('OAuth URLs should can only be made in a Context, not site wide'); + } + + $scope = self::isMemberApiEnabled() ? self::ORCID_API_SCOPE_MEMBER : self::ORCID_API_SCOPE_PUBLIC; + + // TODO: This is the previous URL. Will be an API URL in new iteration + // We need to construct a page url, but the request is using the component router. + // Use the Dispatcher to construct the url and set the page router. + $redirectUrl = $request->getDispatcher()->url( + $request, + Application::ROUTE_PAGE, + null, + 'orcid', + $handlerMethod, + null, + $redirectParams + ); + + return self::getOauthPath() . 'authorize?' . http_build_query( + [ + 'client_id' => self::getClientId($context), + 'response_type' => 'code', + 'scope' => $scope, + 'redirect_uri' => $redirectUrl] + ); + } + + public static function getCity(?Context $context = null): string + { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + + return $context->getData(self::CITY) ?? ''; + } + + public static function getCountry(?Context $context = null): string + { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + return $context->getData(OrcidManager::COUNTRY) ?? ''; + } + + public static function isMemberApiEnabled(?Context $context = null): bool + { + if (self::isGloballyConfigured()) { + $apiType = Application::get()->getRequest()->getSite()->getData(self::API_TYPE); + } else { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + $apiType = $context->getData(OrcidManager::API_TYPE); + } + + if (in_array($apiType, [self::API_MEMBER_PRODUCTION, self::API_MEMBER_SANDBOX])) { + return true; + } else { + return false; + } + } + + public static function getLogLevel(?Context $context = null): string + { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + + return $context->getData(OrcidManager::LOG_LEVEL) ?? 'ERROR'; + } + + public static function shouldSendMailToAuthors(?Context $context = null): bool + { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + + return $context->getData(OrcidManager::SEND_MAIL_TO_AUTHORS_ON_PUBLICATION) ?? false; + } + + public static function getOauthPath(): string + { + return self::getOrcidUrl() . 'oauth/'; + } + + public static function getClientId(?Context $context = null): string + { + if (self::isGloballyConfigured()) { + return Application::get()->getRequest()->getSite()->getData(self::CLIENT_ID); + } else { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + + return $context->getData(self::CLIENT_ID) ?? ''; + } + } + + public static function getClientSecret(?Context $context = null): string + { + if (self::isGloballyConfigured()) { + return Application::get()->getRequest()->getSite()->getData(self::CLIENT_SECRET); + } else { + if ($context === null) { + $context = Application::get()->getRequest()->getContext(); + } + + return $context->getData(self::CLIENT_SECRET) ?? ''; + } + } + + /** + * Remove all data fields, which belong to an ORCID access token from the + * given Author object. Also updates fields in the db. + * + * @param bool $updateAuthor If true, update the author fields in the database. + * Use only if not called from a function, which will already update the author. + */ + public static function removeOrcidAccessToken(Author $author, bool $updateAuthor = false): void + { + $author->setData('orcidAccessToken', null); + $author->setData('orcidAccessScope', null); + $author->setData('orcidRefreshToken', null); + $author->setData('orcidAccessExpiresOn', null); + $author->setData('orcidSandbox', null); + + if ($updateAuthor) { + Repo::author()->dao->update($author); + } + } + + public static function logInfo(string $message): void + { + if (self::getLogLevel() !== 'INFO') { + return; + } + self::writeLog($message, 'INFO'); + } + public static function logError(string $message): void + { + if (self::getLogLevel() !== 'ERROR') { + return; + } + self::writeLog($message, 'ERROR'); + } + + private static function writeLog(string $message, string $level): void + { + $fineStamp = date('Y-m-d H:i:s') . substr(microtime(), 1, 4); + $logFilePath = Config::getVar('files', 'files_dir') . '/orcid.log'; + error_log("{$fineStamp} {$level} {$message}\n", 3, $logFilePath); + } +} diff --git a/classes/orcid/OrcidReview.php b/classes/orcid/OrcidReview.php new file mode 100644 index 00000000000..cf0028c5f8b --- /dev/null +++ b/classes/orcid/OrcidReview.php @@ -0,0 +1,121 @@ +data = $this->buildOrcidReview(); + } + + public function toArray(): array + { + return $this->data; + } + + private function buildOrcidReview(): array + { + $publicationUrl = Application::get()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_PAGE, + $this->context->getPath(), + 'article', + 'view', + $this->submission->getId(), + ); + + $publicationLocale = ($this->submission->getData('locale')) ? $this->submission->getData('locale') : 'en'; + // TODO: Check why it shouldn't be removed + $pubIdPlugins = PluginRegistry::loadCategory('pubIds', true, $this->context->getId()); // DO not remove + $supportedSubmissionLocales = $this->context->getSupportedSubmissionLocales(); + + if (!empty($this->review->getData('dateCompleted')) && $this->context->getData('onlineIssn')) { + $reviewCompletionDate = Carbon::parse($this->review->getData('dateCompleted')); + + $orcidReview = [ + 'reviewer-role' => 'reviewer', + 'review-type' => 'review', + 'review-completion-date' => [ + 'year' => [ + 'value' => $reviewCompletionDate->format('Y') + ], + 'month' => [ + 'value' => $reviewCompletionDate->format('m') + ], + 'day' => [ + 'value' => $reviewCompletionDate->format('d') + ] + ], + 'review-group-id' => 'issn:' . $this->context->getData('onlineIssn'), + + 'convening-organization' => [ + 'name' => $this->context->getData('publisherInstitution'), + 'address' => [ + 'city' => OrcidManager::getCity($this->context), + 'country' => OrcidManager::getCountry($this->context), + + ] + ], + 'review-identifiers' => ['external-id' => [ + [ + 'external-id-type' => 'source-work-id', + 'external-id-value' => $this->review->getData('reviewRoundId'), + 'external-id-relationship' => 'part-of'] + ]] + ]; + if ($this->review->getReviewMethod() == ReviewAssignment::SUBMISSION_REVIEW_METHOD_OPEN) { + $orcidReview['subject-url'] = ['value' => $publicationUrl]; + $orcidReview['review-url'] = ['value' => $publicationUrl]; + $orcidReview['subject-type'] = 'journal-article'; + $orcidReview['subject-name'] = [ + 'title' => ['value' => $this->submission->getCurrentPublication()->getLocalizedData('title') ?? ''] + ]; + + + if (!empty($this->submission->getData('pub-id::doi'))) { + $externalIds = [ + + 'external-id-type' => 'doi', + 'external-id-value' => $this->submission->getData('pub-id::doi'), + 'external-id-url' => [ + 'value' => 'https://doi.org/' . $this->submission->getData('pub-id::doi') + ], + 'external-id-relationship' => 'self' + + ]; + $orcidReview['subject-external-identifier'] = $externalIds; + } + } + + $translatedTitleAvailable = false; + foreach ($supportedSubmissionLocales as $defaultLanguage) { + if ($defaultLanguage !== $publicationLocale) { + $iso2LanguageCode = substr($defaultLanguage, 0, 2); + $defaultTitle = $this->submission->getLocalizedData($iso2LanguageCode); + if (strlen($defaultTitle) > 0 && !$translatedTitleAvailable) { + $orcidReview['subject-name']['translated-title'] = ['value' => $defaultTitle, 'language-code' => $iso2LanguageCode]; + $translatedTitleAvailable = true; + } + } + } + return $orcidReview; + } else { + // TODO: Check how this should be handled. + // It seems like this should be blocked earlier rather than letting it get to this point. + return []; + } + } +} diff --git a/classes/orcid/OrcidWork.php b/classes/orcid/OrcidWork.php new file mode 100644 index 00000000000..518ffec4b49 --- /dev/null +++ b/classes/orcid/OrcidWork.php @@ -0,0 +1,312 @@ + 'doi', 'other::urn' => 'urn']; + public const USER_GROUP_TO_ORCID_ROLE = ['Author' => 'AUTHOR', 'Translator' => 'CHAIR_OR_TRANSLATOR', 'Journal manager' => 'AUTHOR']; + + private array $data = []; + + public function __construct( + private Publication $publication, + private Context $context, + private array $authors, + private ?Issue $issue = null + ) { + $this->data = $this->build(); + } + + public function toArray(): array + { + return $this->data; + } + + private function build(): array + { + $submission = Repo::submission()->get($this->publication->getData('submissionId')); + + $applicationName = Application::get()->getName(); + $bibtexCitation = ''; + + $publicationLocale = ($this->publication->getData('locale')) ? $this->publication->getData('locale') : 'en'; + $supportedSubmissionLocales = $this->context->getSupportedSubmissionLocales(); + + + $publicationUrl = Application::get()->getDispatcher()->url( + Application::get()->getRequest(), + Application::ROUTE_PAGE, + $this->context->getPath(), + 'article', + 'view', + $submission->getId() + ); + + $orcidWork = [ + 'title' => [ + 'title' => [ + 'value' => trim(strip_tags($this->publication->getLocalizedTitle($publicationLocale))) ?? '' + ], + 'subtitle' => [ + 'value' => trim(strip_tags($this->publication->getLocalizedData('subtitle', $publicationLocale))) ?? '' + ] + ], + 'journal-title' => [ + 'value' => $this->context->getName($publicationLocale) ?? '' + ], + 'short-description' => trim(strip_tags($this->publication->getLocalizedData('abstract', $publicationLocale))) ?? '', + + 'external-ids' => [ + 'external-id' => $this->buildOrcidExternalIds($submission, $this->publication, $this->context, $this->issue, $publicationUrl) + ], + 'publication-date' => $this->buildOrcidPublicationDate($this->publication, $this->issue), + 'url' => $publicationUrl, + 'language-code' => substr($publicationLocale, 0, 2), + 'contributors' => [ + 'contributor' => $this->buildOrcidContributors($this->authors, $this->context, $this->publication) + ] + ]; + + if ($applicationName == 'ojs2') { + PluginRegistry::loadCategory('generic'); + $citationPlugin = PluginRegistry::getPlugin('generic', 'citationstylelanguageplugin'); + /** @var CitationStyleLanguagePlugin $citationPlugin */ + $bibtexCitation = trim(strip_tags($citationPlugin->getCitation($this->request, $submission, 'bibtex', $this->issue, $this->publication))); + $orcidWork['citation'] = [ + 'citation-type' => 'bibtex', + 'citation-value' => $bibtexCitation, + ]; + $orcidWork['type'] = 'journal-article'; + } elseif ($applicationName == 'ops') { + $orcidWork['type'] = 'preprint'; + } + + $translatedTitleAvailable = false; + foreach ($supportedSubmissionLocales as $defaultLanguage) { + if ($defaultLanguage !== $publicationLocale) { + $iso2LanguageCode = substr($defaultLanguage, 0, 2); + $defaultTitle = $this->publication->getLocalizedData($iso2LanguageCode); + if (strlen($defaultTitle) > 0 && !$translatedTitleAvailable) { + $orcidWork['title']['translated-title'] = ['value' => $defaultTitle, 'language-code' => $iso2LanguageCode]; + $translatedTitleAvailable = true; + } + } + } + + return $orcidWork; + } + + /** + * Build the external identifiers ORCID JSON structure from article, journal and issue meta data. + * + * @see https://pub.orcid.org/v2.0/identifiers Table of valid ORCID identifier types. + * + * @param Submission $submission The Article object for which the external identifiers should be build. + * @param Publication $publication The Article object for which the external identifiers should be build. + * @param Journal $context Context the Submission is part of. + * @param Issue $issue The Issue object the Article object belongs to. + * + * @return array An associative array corresponding to ORCID external-id JSON. + */ + private function buildOrcidExternalIds($submission, $publication, $context, $issue, $articleUrl) + { + $contextId = $context->getId(); + + $externalIds = []; + $pubIdPlugins = PluginRegistry::loadCategory('pubIds', true, $contextId); + // Add doi, urn, etc. for article + $articleHasStoredPubId = false; + if (is_array($pubIdPlugins) || $context->areDoisEnabled()) { + // Handle non-DOI pubIds + if (is_array($pubIdPlugins)) { + foreach ($pubIdPlugins as $plugin) { + if (!$plugin->getEnabled()) { + continue; + } + + $pubIdType = $plugin->getPubIdType(); + + # Add article ids + $pubId = $publication->getStoredPubId($pubIdType); + + if ($pubId) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID[$pubIdType], + 'external-id-value' => $pubId, + 'external-id-url' => [ + 'value' => $plugin->getResolvingURL($contextId, $pubId) + ], + 'external-id-relationship' => 'self' + ]; + + $articleHasStoredPubId = true; + } + + # Add issue ids if they exist + $pubId = $issue->getStoredPubId($pubIdType); + if ($pubId) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID[$pubIdType], + 'external-id-value' => $pubId, + 'external-id-url' => [ + 'value' => $plugin->getResolvingURL($contextId, $pubId) + ], + 'external-id-relationship' => 'part-of' + ]; + } + } + + // Handle DOIs + if ($context->areDoisEnabled()) { + # Add article ids + $doiObject = $publication->getData('doiObject'); + + if ($doiObject) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID['doi'], + 'external-id-value' => $doiObject->getData('doi'), + 'external-id-url' => [ + 'value' => $doiObject->getResolvingUrl() + ], + 'external-id-relationship' => 'self' + ]; + + $articleHasStoredPubId = true; + } + } + + # Add issue ids if they exist + if ($issue) { + $doiObject = $issue->getData('doiObject'); + if ($doiObject) { + $externalIds[] = [ + 'external-id-type' => self::PUBID_TO_ORCID_EXT_ID['doi'], + 'external-id-value' => $doiObject->getData('doi'), + 'external-id-url' => [ + 'value' => $doiObject->getResolvingUrl() + ], + 'external-id-relationship' => 'part-of' + ]; + } + } + } + } else { + error_log('OrcidProfilePlugin::buildOrcidExternalIds: No pubId plugins could be loaded'); + } + + if (!$articleHasStoredPubId) { + // No pubidplugins available or article does not have any stored pubid + // Use URL as an external-id + $externalIds[] = [ + 'external-id-type' => 'uri', + 'external-id-value' => $articleUrl, + 'external-id-relationship' => 'self' + ]; + } + + // Add journal online ISSN + // TODO What about print ISSN? + if ($context->getData('onlineIssn')) { + $externalIds[] = [ + 'external-id-type' => 'issn', + 'external-id-value' => $context->getData('onlineIssn'), + 'external-id-relationship' => 'part-of' + ]; + } + + return $externalIds; + } + + /** + * Parse issue year and publication date and use the older on of the two as + * the publication date of the ORCID work. + * + * @param null|mixed $issue + * + * @return array Associative array with year, month and day or only year + */ + private function buildOrcidPublicationDate($publication, $issue = null) + { + $publicationPublishDate = Carbon::parse($publication->getData('datePublished')); + + return [ + 'year' => ['value' => $publicationPublishDate->format('Y')], + 'month' => ['value' => $publicationPublishDate->format('m')], + 'day' => ['value' => $publicationPublishDate->format('d')] + ]; + } + + /** + * Build associative array fitting for ORCID contributor mentions in an + * ORCID work from the supplied Authors array. + * + * @param Author[] $authors Array of Author objects + * + * @return array[] Array of associative arrays, + * one for each contributor + */ + private function buildOrcidContributors($authors, $context, $publication) + { + $contributors = []; + $first = true; + + foreach ($authors as $author) { + // TODO Check if e-mail address should be added + $fullName = $author->getLocalizedGivenName() . ' ' . $author->getLocalizedFamilyName(); + + if (strlen($fullName) == 0) { + OrcidManager::logError('Contributor Name not defined' . $author->getAllData()); + } + $contributor = [ + 'credit-name' => $fullName, + 'contributor-attributes' => [ + 'contributor-sequence' => $first ? 'first' : 'additional' + ] + ]; + + $userGroup = $author->getUserGroup(); + $role = self::USER_GROUP_TO_ORCID_ROLE[$userGroup->getName('en')]; + + if ($role) { + $contributor['contributor-attributes']['contributor-role'] = $role; + } + + if ($author->getOrcid()) { + $orcid = basename(parse_url($author->getOrcid(), PHP_URL_PATH)); + + if ($author->getData('orcidSandbox')) { + $uri = ORCID_URL_SANDBOX . $orcid; + $host = 'sandbox.orcid.org'; + } else { + $uri = $author->getOrcid(); + $host = 'orcid.org'; + } + + $contributor['contributor-orcid'] = [ + 'uri' => $uri, + 'path' => $orcid, + 'host' => $host + ]; + } + + $first = false; + + $contributors[] = $contributor; + } + + return $contributors; + } +} diff --git a/classes/orcid/actions/AuthorizeUserData.php b/classes/orcid/actions/AuthorizeUserData.php new file mode 100644 index 00000000000..e334eeaa80f --- /dev/null +++ b/classes/orcid/actions/AuthorizeUserData.php @@ -0,0 +1,138 @@ +request->getContext(); + $httpClient = Application::get()->getHttpClient(); + + // API Request: GetOAuth token and ORCID + $tokenResponse = $httpClient->request( + 'POST', + $url = OrcidManager::getApiPath($context) . OrcidManager::OAUTH_TOKEN_URL, + [ + 'form_params' => [ + 'code' => $this->request->getUserVar('code'), + 'grant_type' => 'authorization_code', + 'client_id' => OrcidManager::getClientId($context), + 'client_secret' => OrcidManager::getClientSecret($context), + ], + 'headers' => ['Accept' => 'application/json'], + 'allow_redirects' => ['strict' => true], + ] + ); + + if ($tokenResponse->getStatusCode() !== 200) { + error_log('ORCID token URL error: ' . $tokenResponse->getStatusCode() . ' (' . __FILE__ . ' line ' . __LINE__ . ', URL ' . $url . ')'); + $orcid = null; + $orcidUri = null; + $accessToken = null; + $tokenData = []; + } else { + $tokenData = json_decode($tokenResponse->getBody(), true); + $orcid = $tokenData['orcid']; + $orcidUri = (OrcidManager::isSandbox($context) ? OrcidManager::ORCID_URL_SANDBOX : OrcidManager::ORCID_URL) . $orcid; + $accessToken = $tokenData['access_token']; + } + + switch ($this->request->getUserVar('targetOp')) { + case 'register': + // API request: get user profile (for names; email; etc) + $profileResponse = $httpClient->request( + 'GET', + $url = OrcidManager::getApiPath($context) . ORCID_API_VERSION_URL . urlencode($orcid) . '/' . ORCID_PROFILE_URL, + [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $accessToken, + ], + ] + ); + if ($profileResponse->getStatusCode() != 200) { + error_log('ORCID profile URL error: ' . $profileResponse->getStatusCode() . ' (' . __FILE__ . ' line ' . __LINE__ . ', URL ' . $url . ')'); + $profileJson = null; + } else { + $profileJson = json_decode($profileResponse->getBody(), true); + } + + // API request: get employments (for affiliation field) + $employmentsResponse = $httpClient->request( + 'GET', + $url = OrcidManager::getApiPath($context) . ORCID_API_VERSION_URL . urlencode($orcid) . '/' . ORCID_EMPLOYMENTS_URL, + [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $accessToken, + ], + ] + ); + if ($employmentsResponse->getStatusCode() != 200) { + error_log('ORCID deployments URL error: ' . $employmentsResponse->getStatusCode() . ' (' . __FILE__ . ' line ' . __LINE__ . ', URL ' . $url . ')'); + $employmentJson = null; + } else { + $employmentJson = json_decode($employmentsResponse->getBody(), true); + } + + // Suppress errors for nonexistent array indexes + echo ' + + '; + break; + case 'profile': + $user = $this->request->getUser(); + // Store the access token and other data for the user + $user = $this->setOrcidData($user, $orcidUri, $tokenData); + Repo::user()->edit($user, ['orcidAccessDenied', 'orcidAccessToken', 'orcidAccessScope', 'orcidRefreshToken', 'orcidAccessExpiresOn']); + + // Reload the public profile tab (incl. form) + echo ' + + '; + break; + default: + throw new \Exception('Invalid targetOp'); + } + } + + private function setOrcidData($userOrAuthor, $orcidUri, $orcidResponse) + { + // Save the access token + $orcidAccessExpiresOn = Carbon::now(); + // expires_in field from the response contains the lifetime in seconds of the token + // See https://members.orcid.org/api/get-oauthtoken + $orcidAccessExpiresOn->addSeconds($orcidResponse['expires_in']); + $userOrAuthor->setOrcid($orcidUri); + // remove the access denied marker, because now the access was granted + $userOrAuthor->setData('orcidAccessDenied', null); + $userOrAuthor->setData('orcidAccessToken', $orcidResponse['access_token']); + $userOrAuthor->setData('orcidAccessScope', $orcidResponse['scope']); + $userOrAuthor->setData('orcidRefreshToken', $orcidResponse['refresh_token']); + $userOrAuthor->setData('orcidAccessExpiresOn', $orcidAccessExpiresOn->toDateTimeString()); + return $userOrAuthor; + } +} diff --git a/classes/orcid/actions/SendAuthorMail.php b/classes/orcid/actions/SendAuthorMail.php new file mode 100644 index 00000000000..09196f973b3 --- /dev/null +++ b/classes/orcid/actions/SendAuthorMail.php @@ -0,0 +1,64 @@ +getRequest()->getContext(); + if ($context === null) { + throw new \Exception('Author ORCID emails should only be sent from a Context, never site-wide'); + } + + $contextId = $context->getId(); + $publicationId = $this->author->getData('publicationId'); + $publication = Repo::publication()->get($publicationId); + $submission = Repo::submission()->get($publication->getData('submissionId')); + + $emailToken = md5(microtime() . $this->author->getEmail()); + $this->author->setData('orcidEmailToken', $emailToken); + $oauthUrl = OrcidManager::buildOAuthUrl('verify', ['token' => $emailToken, 'state' => $publicationId]); + + if (OrcidManager::isMemberApiEnabled($context)) { + $mailable = new OrcidRequestAuthorAuthorization($context, $submission, $oauthUrl); + } else { + $mailable = new OrcidCollectAuthorId($context, $submission, $oauthUrl); + } + + // Set From to primary journal contact + $mailable->from($context->getData('contactEmail'), $context->getData('contactName')); + + // Send to author + $mailable->recipients([$this->author]); + $emailTemplateKey = $mailable::getEmailTemplateKey(); + $emailTemplate = Repo::emailTemplate()->getByKey($contextId, $emailTemplateKey); + $mailable->body($emailTemplate->getLocalizedData('body')) + ->subject($emailTemplate->getLocalizedData('subject')); + Mail::send($mailable); + + if ($this->updateAuthor) { + Repo::author()->dao->update($this->author); + } + } +} diff --git a/classes/orcid/actions/VerifyAuthorWithOrcid.php b/classes/orcid/actions/VerifyAuthorWithOrcid.php new file mode 100644 index 00000000000..d1e48cc2861 --- /dev/null +++ b/classes/orcid/actions/VerifyAuthorWithOrcid.php @@ -0,0 +1,143 @@ +request->getContext(); + + // Fetch the access token + $oauthTokenUrl = OrcidManager::getApiPath($context) . OrcidManager::OAUTH_TOKEN_URL; + + $httpClient = Application::get()->getHttpClient(); + $headers = ['Accept' => 'application/json']; + $postData = [ + 'code' => $this->request->getUserVar('code'), + 'grant_type' => 'authorization_code', + 'client_id' => OrcidManager::getClientId($context), + 'client_secret' => OrcidManager::getClientSecret($context) + ]; + + OrcidManager::logInfo('POST ' . $oauthTokenUrl); + OrcidManager::logInfo('Request headers: ' . var_export($headers, true)); + OrcidManager::logInfo('Request body: ' . http_build_query($postData)); + + try { + $response = $httpClient->request( + 'POST', + $oauthTokenUrl, + [ + 'headers' => $headers, + 'form_params' => $postData, + 'allow_redirects' => ['strict' => true], + ], + ); + + if ($response->getStatusCode() !== 200) { + OrcidManager::logError('VerifyAuthorWithOrcid::execute - unexpected response: ' . $response->getStatusCode()); + $this->addTemplateVar('authFailure', true); + } + $results = json_decode($response->getBody(), true); + + // Check for errors + OrcidManager::logInfo('Response body: ' . print_r($results, true)); + if (($results['error'] ?? null) === 'invalid_grant') { + OrcidManager::logError('Authorization code invalid, maybe already used'); + $this->addTemplateVar('authFailure', true); + } + if (isset($results['error'])) { + OrcidManager::logError('Invalid ORCID response: ' . $results['error']); + $this->addTemplateVar('authFailure', true); + } + + // Check for duplicate ORCID for author + $orcidUri = OrcidManager::getOrcidUrl($context) . $results['orcid']; + if (!empty($this->author->getOrcid()) && $orcidUri !== $this->author->getOrcid()) { + $this->addTemplateVar('duplicateOrcid', true); + } + $this->addTemplateVar('orcid', $orcidUri); + + $this->author->setOrcid($orcidUri); + if (OrcidManager::isSandbox($context)) { + $this->author->setData('orcidEmailToken', null); + } + $this->setOrcidAccessData($orcidUri, $results); + Repo::author()->dao->update($this->author); + + // Send member submissions to ORCID + if (OrcidManager::isMemberApiEnabled($context)) { + $publicationId = $this->request->getUserVar('state'); + $publication = Repo::publication()->get($publicationId); + + if ($publication->getData('status') == PKPSubmission::STATUS_PUBLISHED) { + $this->addTemplateVar('sendSubmission', true); + // TODO: Sort out sending + // $sendResult = $this->plugin->sendSubmissionToOrcid($publication, $request); + // if ($sendResult === true || (is_array($sendResult) && $sendResult[$response['orcid']])) { + // $this->addTemplateVar('sendSubmissionSuccess', true); + // } + } else { + $this->addTemplateVar('submissionNotPublished', true); + } + } + + $this->addTemplateVar('verifySuccess', true); + $this->addTemplateVar('orcidIcon', OrcidManager::getIcon()); + } catch (GuzzleException $exception) { + OrcidManager::logError('Publication fail: ' . $exception->getMessage()); + $this->addTemplateVar('orcidAPIError', $exception->getMessage()); + } + + // TODO: I don't think this should be happening here + $this->addTemplateVar('authFailure', true); + return $this; + } + + public function updateTemplateMgrVars(TemplateManager &$templateMgr): void + { + foreach ($this->templateVarsToSet as $key => $value) { + $templateMgr->assign($key, $value); + } + } + + private function setOrcidAccessData(string $orcidUri, array $results): void + { + // Save the access token + $orcidAccessExpiresOn = Carbon::now(); + // expires_in field from the response contains the lifetime in seconds of the token + // See https://members.orcid.org/api/get-oauthtoken + $orcidAccessExpiresOn->addSeconds($results['expires_in']); + $this->author->setOrcid($orcidUri); + // remove the access denied marker, because now the access was granted + $this->author->setData('orcidAccessDenied', null); + $this->author->setData('orcidAccessToken', $results['access_token']); + $this->author->setData('orcidAccessScope', $results['scope']); + $this->author->setData('orcidRefreshToken', $results['refresh_token']); + $this->author->setData('orcidAccessExpiresOn', $orcidAccessExpiresOn->toDateTimeString()); + + } + + private function addTemplateVar(string $key, mixed $value): void + { + $this->templateVarsToSet[$key] = $value; + } +} diff --git a/jobs/orcid/DepositOrcidSubmission.php b/jobs/orcid/DepositOrcidSubmission.php new file mode 100644 index 00000000000..590abac2eaf --- /dev/null +++ b/jobs/orcid/DepositOrcidSubmission.php @@ -0,0 +1,128 @@ +context) . ORCID_API_VERSION_URL . $this->authorOrcid . '/' . ORCID_WORK_URL; + $method = 'POST'; + + if ($putCode = $this->author->getData('orcidWorkPutCode')) { + // Submission has already been sent to ORCID. Use PUT to update meta data + $uri .= '/' . $putCode; + $method = 'PUT'; + $orcidWork['put-code'] = $putCode; + } else { + // Remove put-code from body because the work has not yet been sent + unset($this->orcidWork['put-code']); + } + + $headers = [ + 'Content-type: application/vnd.orcid+json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $this->author->getData('orcidAccessToken') + ]; + + OrcidManager::logInfo("{$method} {$uri}"); + OrcidManager::logInfo('Header: ' . var_export($headers, true)); + + $httpClient = Application::get()->getHttpClient(); + try { + $response = $httpClient->request( + $method, + $uri, + [ + 'headers' => $headers, + 'json' => $this->orcidWork, + ] + ); + } catch (ClientException $exception) { + $reason = $exception->getResponse()->getBody(); + OrcidManager::logError("Publication fail: {$reason}"); + + $this->fail($exception); + } + $httpStatus = $response->getStatusCode(); + OrcidManager::logInfo("Response status: {$httpStatus}"); + $responseHeaders = $response->getHeaders(); + + switch ($httpStatus) { + case 200: + // Work updated + OrcidManager::logInfo("Work updated in profile, putCode: {$putCode}"); + // TODO: See what to do with request success variable. Won't be handled by job in anyway by default + $requestSuccess = true; + break; + case 201: + $location = $responseHeaders['Location'][0]; + // Extract the ORCID work put code for updates/deletion. + $putCode = intval(basename(parse_url($location, PHP_URL_PATH))); + OrcidManager::logInfo("Work added to profile, putCode: {$putCode}"); + $this->author->setData('orcidWorkPutCode', $putCode); + Repo::author()->dao->update($this->author); + $requestSuccess = true; + break; + case 401: + // invalid access token, token was revoked + $error = json_decode($response->getBody(), true); + if ($error['error'] === 'invalid_token') { + OrcidManager::logError($error['error_description'] . ', deleting orcidAccessToken from author'); + OrcidManager::removeOrcidAccessToken($this->author); + } + $requestSuccess = false; + break; + case 403: + OrcidManager::logError('Work update forbidden: ' . $response->getBody()); + $requestSuccess = false; + break; + case 404: + // a work has been deleted from a ORCID record. putCode is no longer valid. + if ($method === 'PUT') { + OrcidManager::logError('Work deleted from ORCID record, deleting putCode form author'); + $this->author->setData('orcidWorkPutCode', null); + Repo::author()->dao->update($this->author); + $requestSuccess = false; + } else { + OrcidManager::logError("Unexpected status {$httpStatus} response, body: " . $response->getBody()); + $requestSuccess = false; + } + break; + case 409: + OrcidManager::logError('Work already added to profile, response body: ' . $response->getBody()); + $requestSuccess = false; + break; + default: + OrcidManager::logError("Unexpected status {$httpStatus} response, body: " . $response->getBody()); + $requestSuccess = false; + } + } +} diff --git a/jobs/orcid/PublishReviewerWorkToOrcid.php b/jobs/orcid/PublishReviewerWorkToOrcid.php new file mode 100644 index 00000000000..7913cc17638 --- /dev/null +++ b/jobs/orcid/PublishReviewerWorkToOrcid.php @@ -0,0 +1,106 @@ +context)) { + return; + } + + if (!OrcidManager::getCity($this->context) || !OrcidManager::getCountry($this->context)) { + return; + } + + $reviewer = Repo::user()->get($this->reviewAssignment->getData('reviewerId')); + + if ($reviewer->getOrcid() && $reviewer->getData('orcidAccessToken')) { + $orcidAccessExpiresOn = Carbon::parse($reviewer->getData('orcidAccessExpiresOn')); + if ($orcidAccessExpiresOn->isFuture()) { + # Extract only the ORCID from the stored ORCID uri + $orcid = basename(parse_url($reviewer->getOrcid(), PHP_URL_PATH)); + + $orcidReview = new OrcidReview($this->submission, $this->reviewAssignment, $this->context); + + $uri = OrcidManager::getApiPath($this->context) . ORCID_API_VERSION_URL . $orcid . '/' . ORCID_REVIEW_URL; + $method = 'POST'; + if ($putCode = $reviewer->getData('orcidReviewPutCode')) { + $uri .= '/' . $putCode; + $method = 'PUT'; + $orcidReview['put-code'] = $putCode; + } + $headers = [ + 'Content-Type' => ' application/vnd.orcid+json; qs=4', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $reviewer->getData('orcidAccessToken') + ]; + $httpClient = Application::get()->getHttpClient(); + + try { + $response = $httpClient->request( + $method, + $uri, + [ + 'headers' => $headers, + 'json' => $orcidReview->toArray(), + ] + ); + } catch (ClientException $exception) { + $reason = $exception->getResponse()->getBody(); + OrcidManager::logInfo("Publication fail: {$reason}"); + + $this->fail($exception); + } + $httpStatus = $response->getStatusCode(); + OrcidManager::logInfo("Response status: {$httpStatus}"); + $responseHeaders = $response->getHeaders(); + switch ($httpStatus) { + case 200: + OrcidManager::logInfo("Review updated in profile, putCode: {$putCode}"); + break; + case 201: + $location = $responseHeaders['Location'][0]; + // Extract the ORCID work put code for updates/deletion. + $putCode = basename(parse_url($location, PHP_URL_PATH)); + $reviewer->setData('orcidReviewPutCode', $putCode); + Repo::user()->edit($reviewer, ['orcidReviewPutCode']); + OrcidManager::logInfo("Review added to profile, putCode: {$putCode}"); + break; + default: + OrcidManager::logError("Unexpected status {$httpStatus} response, body: {$responseHeaders}"); + } + } + } + } +} diff --git a/pages/article/ArticleHandler.php b/pages/article/ArticleHandler.php index 18de5d32962..7a64fa39320 100644 --- a/pages/article/ArticleHandler.php +++ b/pages/article/ArticleHandler.php @@ -23,6 +23,7 @@ use APP\handler\Handler; use APP\issue\IssueAction; use APP\observers\events\UsageEvent; +use APP\orcid\OrcidManager; use APP\payment\ojs\OJSCompletedPaymentDAO; use APP\payment\ojs\OJSPaymentManager; use APP\security\authorization\OjsJournalMustPublishPolicy; @@ -307,6 +308,7 @@ public function view($args, $request) 'copyrightYear' => $publication->getData('copyrightYear'), 'pubIdPlugins' => PluginRegistry::loadCategory('pubIds', true), 'keywords' => $publication->getData('keywords'), + 'orcidIcon' => OrcidManager::getIcon(), ]); // Fetch and assign the galley to the template diff --git a/pages/orcid/OrcidHandler.php b/pages/orcid/OrcidHandler.php new file mode 100644 index 00000000000..a382d3da4f3 --- /dev/null +++ b/pages/orcid/OrcidHandler.php @@ -0,0 +1,148 @@ +addPolicy(new PKPSiteAccessPolicy( + $request, + ['verify', 'authorizeOrcid', 'about'], + PKPSiteAccessPolicy::SITE_ACCESS_ALL_ROLES + )); + + $op = $request->getRequestedOp(); + $targetOp = $request->getUserVar('targetOp'); + if ($op === 'authorize' && in_array($targetOp, ['profile', 'submit'])) { + // ... but user must be logged in for authorize with profile or submit + $this->addPolicy(new UserRequiredPolicy($request)); + } + + if (!Application::isInstalled()) { + SessionManager::disable(); + } + + $this->setEnforceRestrictedSite(false); + return parent::authorize($request, $args, $roleAssignments); + } + + public function verify(array $args, Request $request): void + { + // If the application is set to sandbox mode, it will not reach out to external services + if (Config::getVar('general', 'sandbox', false)) { + error_log('Application is set to sandbox mode and will not interact with the ORCID service'); + return; + } + + $templateMgr = TemplateManager::getManager($request); + + // Initialise template parameters + $templateMgr->assign([ + 'currentUrl' => $request->url(null, 'index'), + 'verifySuccess' => false, + 'authFailure' => false, + 'notPublished' => false, + 'sendSubmission' => false, + 'sendSubmissionSuccess' => false, + 'denied' => false, + ]); + + // Get the author + $author = $this->getAuthorToVerify($request); + + if ($author === null) { + $this->handleNoAuthorWithToken($templateMgr); + } elseif ($request->getUserVar('error') === 'access_denied') { + // Handle access denied + $this->handleUserDeniedAccess($author, $templateMgr, $request->getUserVar('error_description')); + } + + (new VerifyAuthorWithOrcid($author, $request))->execute()->updateTemplateMgrVars($templateMgr); + + $templateMgr->display(self::VERIFY_TEMPLATE_PATH); + } + + public function authorizeOrcid(array $args, Request $request): void + { + // If the application is set to sandbox mode, it will not reach out to external services + if (Config::getVar('general', 'sandbox', false)) { + error_log('Application is set to sandbox mode and will not interact with the ORCID service'); + return; + } + + (new AuthorizeUserData($request))->execute(); + } + + public function about(array $args, Request $request): void + { + $context = $request->getContext(); + $templateMgr = TemplateManager::getManager($request); + + $templateMgr->assign('orcidIcon', OrcidManager::getIcon()); + $templateMgr->assign('isMemberApi', OrcidManager::isMemberApiEnabled($context)); + $templateMgr->display(self::ABOUT_TEMPLATE_PATH); + + } + + private function getAuthorToVerify(Request $request): ?Author + { + $publicationId = $request->getUserVar('state'); + $authors = Repo::author() + ->getCollector() + ->filterByPublicationIds([$publicationId]) + ->getMany(); + + $authorToVerify = null; + // Find the author entry, for which the ORCID verification was requested + if ($request->getUserVar('token')) { + foreach ($authors as $author) { + if ($author->getData('orcidEmailToken') == $request->getUserVar('token')) { + $authorToVerify = $author; + } + } + } + + return $authorToVerify; + } + + private function handleNoAuthorWithToken(TemplateManager $templateMgr): void + { + OrcidManager::logError('OrcidHandler::verify = No author found with supplied token'); + $templateMgr->assign('verifySuccess', false); + } + + private function handleUserDeniedAccess(Author $author, TemplateManager $templateMgr, string $errorDescription): void + { + // User denied access + // Store the date time the author denied ORCID access to remember this + $author->setData('orcidAccessDenied', Core::getCurrentDate()); + // remove all previously stored ORCID access token + $author->setData('orcidAccessToken', null); + $author->setData('orcidAccessScope', null); + $author->setData('orcidRefreshToken', null); + $author->setData('orcidAccessExpiresOn', null); + $author->setData('orcidEmailToken', null); + Repo::author()->dao->update($author); + OrcidManager::logError('OrcidHandler::verify - ORCID access denied. Error description: ' . $errorDescription); + $templateMgr->assign('denied', true); + } +} diff --git a/pages/orcid/index.php b/pages/orcid/index.php new file mode 100644 index 00000000000..ff967609518 --- /dev/null +++ b/pages/orcid/index.php @@ -0,0 +1,8 @@ + + + diff --git a/schemas/context.json b/schemas/context.json index ebc60e29851..9f68bfb61f7 100644 --- a/schemas/context.json +++ b/schemas/context.json @@ -377,6 +377,64 @@ "type": "boolean", "default": false, "description": "Whether each publication version should receive a unique DOI" + }, + "orcidEnabled": { + "type": "boolean", + "default": false, + "description": "Whether ORCID functionality is enabled", + "validation": [ + "nullable" + ] + }, + "orcidApiType": { + "type": "string", + "validation": [ + "nullable", + "in:publicProduction,publicSandbox,memberProduction,memberSandbox" + ] + }, + "orcidClientId": { + "type": "string", + "default": "", + "validation": [ + "nullable" + ] + }, + "orcidClientSecret": { + "type": "string", + "default": "", + "validation": [ + "nullable" + ] + }, + "orcidSendMailToAuthorsOnPublication":{ + "type": "boolean", + "default": false + }, + "orcidLogLevel": { + "type": "string", + "default": "ERROR", + "validation": [ + "in:ERROR,ALL" + ] + }, + "orcidIsSandBox": { + "type": "boolean", + "default": false + }, + "orcidCountry": { + "type": "string", + "default": "", + "validation": [ + "nullable" + ] + }, + "orcidCity": { + "type": "string", + "default": "", + "validation": [ + "nullable" + ] } } } diff --git a/templates/frontend/pages/orcidAbout.tpl b/templates/frontend/pages/orcidAbout.tpl new file mode 100644 index 00000000000..4bd58daa65b --- /dev/null +++ b/templates/frontend/pages/orcidAbout.tpl @@ -0,0 +1,35 @@ +{** + * templates/orcidVerify.tpl + * + * Copyright (c) 2014-2020 Simon Fraser University + * Copyright (c) 2000-2020 John Willinsky + * Copyright (c) 2018-2019 University Library Heidelberg + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * Page template to display from the OrcidProfileHandler to show ORCID verification success or failure. + *} +{include file="frontend/components/header.tpl"} + +
+ {include file="frontend/components/breadcrumbs.tpl" currentTitleKey="orcidProfile.about.title"} +

+ {translate key="orcidProfile.about.title"} +

+
+ {translate key="orcidProfile.about.orcidExplanation"} +
+

{translate key="orcidProfile.about.howAndWhy.title"}

+ {if $isMemberApi} +
+ {translate key="orcidProfile.about.howAndWhyMemberAPI"} +
+ {else} + {translate key="orcidProfile.about.howAndWhyPublicAPI"} + {/if} +

{translate key="orcidProfile.about.display.title"}

+
+ {translate key="orcidProfile.about.display"} +
+
+ +{include file="frontend/components/footer.tpl"} diff --git a/templates/frontend/pages/orcidVerify.tpl b/templates/frontend/pages/orcidVerify.tpl new file mode 100644 index 00000000000..771e6ee6edd --- /dev/null +++ b/templates/frontend/pages/orcidVerify.tpl @@ -0,0 +1,61 @@ +{** + * templates/orcidVerify.tpl + * + * Copyright (c) 2014-2020 Simon Fraser University + * Copyright (c) 2000-2020 John Willinsky + * Copyright (c) 2018-2019 University Library Heidelberg + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * Page template to display from the OrcidProfileHandler to show ORCID verification success or failure. + *} +{include file="frontend/components/header.tpl"} + +
+ {include file="frontend/components/breadcrumbs.tpl" currentTitleKey="orcidProfile.verify.title"} +

+ {translate key="orcidProfile.verify.title"} +

+
+ {if $verifySuccess} +

+ {$orcidIcon}{$orcid|escape} +

+
+ {translate key="orcidProfile.verify.success"} +
+ {if $sendSubmission} + {if $sendSubmissionSuccess} +
+ {translate key="orcidProfile.verify.sendSubmissionToOrcid.success"} +
+ {else} +
+ {translate key="orcidProfile.verify.sendSubmissionToOrcid.failure"} +
+ {/if} + {elseif $submissionNotPublished} + {translate key="orcidProfile.verify.sendSubmissionToOrcid.notpublished"} + {/if} + {else} +
+ {if $orcidAPIError} + {$orcidAPIError} + {/if} + {if $invalidClient} + {translate key="orcidProfile.invalidClient"} + {elseif $duplicateOrcid} + {translate key="orcidProfile.verify.duplicateOrcid"} + {elseif $denied} + {translate key="orcidProfile.authDenied"} + {elseif $authFailure} + {translate key="orcidProfile.authFailure"} + {else} + {translate key="orcidProfile.verify.failure"} + {/if} +
+ {translate key="orcidProfile.failure.contact"} + {/if} +
+
+ +{include file="frontend/components/footer.tpl"}