diff --git a/.gitignore b/.gitignore index 61ead86..e43b0f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/vendor +.DS_Store diff --git a/IThenticate.inc.php b/IThenticate.inc.php new file mode 100644 index 0000000..85062d1 --- /dev/null +++ b/IThenticate.inc.php @@ -0,0 +1,835 @@ + 'author_', + 'submitter' => 'user_', + ]; + + /** + * Create a new instance + * + * @param string $apiUrl + * @param string $apiKey + * @param string $integrationName + * @param string $integrationVersion + * @param string|null eulaVersion + */ + public function __construct($apiUrl, $apiKey, $integrationName, $integrationVersion, $eulaVersion = null) { + $this->apiUrl = rtrim(trim($apiUrl ?? ''), '/\\'); + $this->apiKey = $apiKey; + $this->integrationName = $integrationName; + $this->integrationVersion = $integrationVersion; + $this->eulaVersion = $eulaVersion; + } + + /** + * Will deactivate the exception suppression on api request and throw exception + * + * @return self + */ + public function withoutSuppressingApiRequestException() { + $this->suppressApiRequestException = false; + + return $this; + } + + /** + * Get the json details of all enable features or get certiain feature details + * To get certain or nested feature details, pass the feature params in dot(.) notation + * For Example + * - to get specific feature as `similarity`, call as getEnabledFeature('similarity') + * - to get nested feature as `viewer_modes` in `similarity`, call as getEnabledFeature('similarity.viewer_modes') + * @see https://developers.turnitin.com/docs/tca#get-features-enabled + * + * @param mixed $feature The specific or nested feature details to get + * @return string|array|null + * + * @throws \Exception + */ + public function getEnabledFeature($feature = null) { + + static $result; + + if (!isset($result) && !$this->validateAccess($result)) { + if ($this->suppressApiRequestException) { + return []; + } + + throw new \Exception('unable to validate access details'); + } + + if (!$feature) { + return json_decode($result, true); + } + + $self = $this; + return data_get( + json_decode($result, true), + $feature, + function () use ($self, $feature) { + if ($self->suppressApiRequestException) { + return null; + } + + throw new \Exception("Feature details {$feature} does not exist"); + } + ); + } + + /** + * Validate the service access by retrieving the enabled feature + * @see https://developers.turnitin.com/docs/tca#get-features-enabled + * @see https://developers.turnitin.com/turnitin-core-api/best-practice/exposing-tca-settings + * + * @param mixed $result This may contains the returned enabled feature details from + * request validation api end point if validated successfully. + * @return bool + */ + public function validateAccess(&$result = null) { + + $response = $this->makeApiRequest('GET', $this->getApiPath('features-enabled'), [ + 'headers' => $this->getRequiredHeaders(), + 'verify' => false, + 'exceptions' => false, + 'http_errors' => false, + ]); + + if ($response && $response->getStatusCode() === 200) { + $result = $response->getBody()->getContents(); + return true; + } + + return false; + } + + /** + * Confirm the EULA on the user's behalf for given version + * @see https://developers.turnitin.com/docs/tca#accept-eula-version + * + * @param User $user + * @param Context $context + * + * @return bool + */ + public function confirmEula($user, $context) { + + $response = $this->makeApiRequest( + 'POST', + $this->getApiPath("eula/{$this->getApplicableEulaVersion()}/accept"), + [ + 'headers' => array_merge($this->getRequiredHeaders(), [ + 'Content-Type' => 'application/json' + ]), + 'json' => [ + 'user_id' => $this->getGeneratedId('submitter', $user->getId()), + 'accepted_timestamp' => \Carbon\Carbon::now()->toIso8601String(), + 'language' => $this->getApplicableLocale($context->getPrimaryLocale()), + ], + 'verify' => false, + 'exceptions' => false, + ] + ); + + return $response ? $response->getStatusCode() === 200 : false; + } + + /** + * Create a new submission at service's end + * @see https://developers.turnitin.com/docs/tca#create-a-submission + * + * @param Site $site The core site of submission system + * @param Submission $submission The article submission to check for plagiarism + * @param User $user The user who is making submitting the submission + * @param Author $author The author/owner of the submission + * @param string $authorPermission Submission author/owner permission set + * @param string $submitterPermission Submission submitter permission set + * + * @return string|null if succeed, it will return the created submission UUID from + * service's end and at failure, will return null + * + * @throws \Exception + */ + public function createSubmission($site, $submission, $user, $author, $authorPermission, $submitterPermission) { + + if (!$this->validatePermission($authorPermission, static::SUBMISSION_PERMISSION_SET)) { + throw new \Exception("in valid owner permission {$authorPermission} given"); + } + + if (!$this->validatePermission($submitterPermission, static::SUBMISSION_PERMISSION_SET)) { + throw new \Exception("in valid submitter permission {$submitterPermission} given"); + } + + $publication = $submission->getCurrentPublication(); /** @var Publication $publication */ + $author ??= $publication->getPrimaryAuthor(); + + $response = $this->makeApiRequest( + 'POST', + $this->getApiPath("submissions"), + [ + 'headers' => array_merge($this->getRequiredHeaders(), [ + 'Content-Type' => 'application/json' + ]), + 'json' => [ + 'owner' => $this->getGeneratedId('owner', $author->getId()), + 'title' => $publication->getLocalizedTitle($publication->getData('locale')), + 'submitter' => $this->getGeneratedId('submitter', $user->getId()), + 'owner_default_permission_set' => $authorPermission, + 'submitter_default_permission_set' => $submitterPermission, + 'metadata' => [ + 'owners' => [ + [ + 'id' => $this->getGeneratedId('owner', $author->getId()), + 'given_name' => $author->getGivenName($publication->getData('locale')), + 'family_name' => $author->getFamilyName($publication->getData('locale')), + 'email' => $author->getEmail(), + ] + ], + 'submitter' => [ + 'id' => $this->getGeneratedId('submitter', $user->getId()), + 'given_name' => $user->getGivenName($site->getPrimaryLocale()), + 'family_name' => $user->getFamilyName($site->getPrimaryLocale()), + 'email' => $user->getEmail(), + ], + 'original_submitted_time' => \Carbon\Carbon::now()->toIso8601String(), + ], + + ], + 'verify' => false, + 'exceptions' => false, + ] + ); + + if ($response && $response->getStatusCode() === 201) { + $result = json_decode($response->getBody()->getContents()); + return $result->id; + } else { + error_log((string)$response->getBody()->getContents()); + } + + return null; + } + + /** + * Upload single submission file to the service's end + * @see https://developers.turnitin.com/docs/tca#upload-submission-file-contents + * + * @param string $submissionTacId The submission UUID return back from service + * @param string $fileName + * @param mixed fileContent + * + * @return bool + */ + public function uploadFile($submissionTacId, $fileName, $fileContent) { + + $response = $this->makeApiRequest( + 'PUT', + $this->getApiPath("submissions/{$submissionTacId}/original"), + [ + 'headers' => array_merge($this->getRequiredHeaders(), [ + 'Content-Type' => 'binary/octet-stream', + 'Content-Disposition' => urlencode('inline; filename="'.$fileName.'"'), + ]), + 'body' => $fileContent, + 'verify' => false, + 'exceptions' => false, + ] + ); + + return $response ? $response->getStatusCode() === 202 : false; + } + + /** + * Get the submission details + * @see https://developers.turnitin.com/docs/tca#get-submission-info + * + * @param string $submissionTacId The submission UUID return back from service + * @return string|null On successful retrieval of submission details it will return + * details JSON data and on failure, will return null. + */ + public function getSubmissionInfo($submissionUuid) { + + $response = $this->makeApiRequest( + 'GET', + $this->getApiPath("submissions/{$submissionUuid}"), + [ + 'headers' => $this->getRequiredHeaders(), + 'verify' => false, + 'exceptions' => false, + ] + ); + + if ($response->getStatusCode() === 200) { + return $response->getBody()->getContents(); + } + + return null; + } + + /** + * Schedule the similarity report generation process + * @see https://developers.turnitin.com/docs/tca#generate-similarity-report + * + * @param string $submissionUuid The submission UUID return back from service + * @param array $settings The specific few settings + * + * @return bool + */ + public function scheduleSimilarityReportGenerationProcess($submissionUuid, $settings = []) { + + $response = $this->makeApiRequest( + 'PUT', + $this->getApiPath("submissions/{$submissionUuid}/similarity"), + [ + 'headers' => array_merge($this->getRequiredHeaders(), [ + 'Content-Type' => 'application/json' + ]), + 'json' => [ + // section `indexing_settings` settings + 'indexing_settings' => [ + 'add_to_index' => $settings['addToIndex'] ?? true, + ], + + // section `generation_settings` settings + 'generation_settings' => [ + 'search_repositories' => [ + 'INTERNET', + 'SUBMITTED_WORK', + 'PUBLICATION', + 'CROSSREF', + 'CROSSREF_POSTED_CONTENT' + ], + 'auto_exclude_self_matching_scope' => $settings['autoExcludeSelfMatchingScope'] ?? 'ALL', + 'priority' => $settings['priority'] ?? 'HIGH', + ], + + // section `view_settings` settings + 'view_settings' => [ + 'exclude_quotes' => $settings['excludeQuotes'] ?? false, + 'exclude_bibliography' => $settings['excludeBibliography'] ?? false, + 'exclude_citations' => $settings['excludeCitations'] ?? false, + 'exclude_abstract' => $settings['excludeAbstract'] ?? false, + 'exclude_methods' => $settings['excludeMethods'] ?? false, + 'exclude_custom_sections' => $settings['excludeCustomSections'] ?? false, + 'exclude_preprints' => $settings['excludePreprints'] ?? false, + 'exclude_small_matches' => (int) $settings['excludeSmallMatches'] >= self::EXCLUDE_SAMLL_MATCHES_MIN + ? (int) $settings['excludeSmallMatches'] + : self::EXCLUDE_SAMLL_MATCHES_MIN, + 'exclude_internet' => $settings['excludeInternet'] ?? false, + 'exclude_publications' => $settings['excludePublications'] ?? false, + 'exclude_crossref' => $settings['excludeCrossref'] ?? false, + 'exclude_crossref_posted_content' => $settings['excludeCrossrefPostedContent'] ?? false, + 'exclude_submitted_works' => $settings['excludeSubmittedWorks'] ?? false, + ], + ], + 'exceptions' => false, + ] + ); + + return $response ? $response->getStatusCode() === 202 : false; + } + + /** + * Get the similarity result info + * @see https://developers.turnitin.com/docs/tca#get-similarity-report-info + * + * @param string $submissionUuid The submission UUID return back from service + * @return string|null + */ + public function getSimilarityResult($submissionUuid) { + + $response = $this->makeApiRequest( + 'GET', + $this->getApiPath("submissions/{$submissionUuid}/similarity"), + [ + 'headers' => $this->getRequiredHeaders(), + 'exceptions' => false, + ] + ); + + return $response && $response->getStatusCode() === 200 + ? $response->getBody()->getContents() + : null; + } + + /** + * Create the viewer launch url + * @see https://developers.turnitin.com/docs/tca#create-viewer-launch-url + * + * @param string $submissionUuid The submission UUID return back from service + * @param User $user The viewing user + * @param string $locale The preferred locale + * @param string $viewerPermission The viewing user permission + * @param bool $allowUpdateInViewer Should allow to update in the viewer and save it which will + * cause the update of similarity score + * + * @return string|null + */ + public function createViewerLaunchUrl($submissionUuid, $user, $locale, $viewerPermission, $allowUpdateInViewer) { + + $response = $this->makeApiRequest( + 'POST', + $this->getApiPath("submissions/{$submissionUuid}/viewer-url"), + [ + 'headers' => array_merge($this->getRequiredHeaders(), [ + 'Content-Type' => 'application/json' + ]), + 'json' => [ + 'viewer_user_id' => $this->getGeneratedId('submitter', $user->getId()), + 'locale' => $locale, + 'viewer_default_permission_set' => $viewerPermission, + 'similarity' => [ + 'view_settings' => [ + 'save_changes' => $allowUpdateInViewer + ], + ], + ], + 'exceptions' => false, + ] + ); + + if ($response && $response->getStatusCode() === 200) { + $result = json_decode($response->getBody()->getContents()); + return $result->viewer_url; + } + + return null; + } + + /** + * Verify if user has already confirmed the given EULA version + * @see https://developers.turnitin.com/docs/tca#get-eula-acceptance-info + * + * @param Author|User $user + * @param string $version + * + * @return bool + */ + public function verifyUserEulaAcceptance($user, $version) { + + $response = $this->makeApiRequest( + 'GET', + $this->getApiPath("eula/{$version}/accept/" . $this->getGeneratedId('submitter' ,$user->getId())), + [ + 'headers' => $this->getRequiredHeaders(), + 'exceptions' => false, + ] + ); + + return $response ? $response->getStatusCode() === 200 : false; + } + + /** + * Validate/Retrieve the given EULA version + * @see https://developers.turnitin.com/docs/tca#get-eula-version-info + * + * @param string $version + * @return bool + */ + public function validateEulaVersion($version) { + + $response = $this->makeApiRequest('GET', $this->getApiPath("eula/{$version}"), [ + 'headers' => $this->getRequiredHeaders(), + 'exceptions' => false, + ]); + + if ($response->getStatusCode() === 200) { + $this->eulaVersionDetails = json_decode($response->getBody()->getContents(), true); + + if (!$this->eulaVersion) { + $this->eulaVersion = $this->eulaVersionDetails['version']; + } + + return true; + } + + return false; + } + + /** + * Register webhook end point + * @see https://developers.turnitin.com/docs/tca#create-webhook + * + * NOTE : with same webhook url, it will return response with status code 409(HTTP_CONFLICT) + * So it's important to verify one before create a new one + * + * @param string $signingSecret + * @param string $url + * @param array $events + * + * @return string|null The UUID of register webhook if succeed or null if failed + */ + public function registerWebhook($signingSecret, $url, $events = self::DEFAULT_WEBHOOK_EVENTS) { + + $response = $this->makeApiRequest('POST', $this->getApiPath('webhooks'), [ + 'headers' => array_merge($this->getRequiredHeaders(), [ + 'Content-Type' => 'application/json', + ]), + 'json' => [ + 'signing_secret' => base64_encode($signingSecret), + 'url' => $url, + 'event_types' => $events, + 'allow_insecure' => true, + ], + 'verify' => false, + 'exceptions' => false, + ]); + + if ($response && $response->getStatusCode() === 201) { + $result = json_decode($response->getBody()->getContents()); + return $result->id; + } + + return null; + } + + /** + * Delete webhook end point + * @see https://developers.turnitin.com/docs/tca#delete-webhook + * + * @param string $webhookId + * @return bool + */ + public function deleteWebhook($webhookId) { + + $response = $this->makeApiRequest('DELETE', $this->getApiPath("webhooks/{$webhookId}"), [ + 'headers' => $this->getRequiredHeaders(), + 'verify' => false, + 'exceptions' => false, + ]); + + return $response && $response->getStatusCode() === 204; + } + + /** + * Get the stored EULA details + * + * @return array|null + */ + public function getEulaDetails() { + return $this->eulaVersionDetails; + } + + /** + * Get the applicable EULA version + * + * @return string + * @throws \Exception + */ + public function getApplicableEulaVersion() { + if (!$this->eulaVersion) { + throw new \Exception('No EULA version set yet'); + } + + return $this->eulaVersion; + } + + /** + * Set the applicable EULA version + * + * @param string $version + * @return self + */ + public function setApplicableEulaVersion($version) { + $this->eulaVersion = $version; + + return $this; + } + + /** + * Make the api request + * + * @param string $method HTTP method. + * @param string|\Psr\Http\Message\UriInterface $uri URI object or string. + * @param array $options Request options to apply. See \GuzzleHttp\RequestOptions. + * + * @return \Psr\Http\Message\ResponseInterface|null + * + * @throws \Throwable + */ + public function makeApiRequest($method, $url, $options = []) { + + $response = null; + + try { + $response = Application::get()->getHttpClient()->request($method, $url, $options); + } catch (\Throwable $exception) { + error_log( + sprintf( + 'iThenticate API request to %s for %s method failed with options %s', + $url, + $method, + print_r($options, true) + ) + ); + + if ($this->suppressApiRequestException) { + error_log($exception->__toString()); + } else { + throw $exception; + } + } + + return $response; + } + + /** + * Get the applicable EULA Url + * + * @param string|array|null $locale + * @return string + * + * @throws \Exception + */ + public function getApplicableEulaUrl($locales = null) { + if (!$this->eulaVersion) { + throw new \Exception('No EULA version set yet'); + } + + $applicableEulaLanguage = $this->getApplicableLocale($locales ?? static::DEFAULT_EULA_LANGUAGE); + + $eulaUrl = $this->eulaVersionDetails['url']; + + return str_replace( + strtolower(static::DEFAULT_EULA_LANGUAGE), + strtolower($applicableEulaLanguage), + $eulaUrl + ); + } + + /** + * Convert given submission/context locale to service compatible and acceptable locale format + * @see https://developers.turnitin.com/docs/tca#eula + * + * @param string|array $locales + * @param string|null $eulaVersion + * + * @return string + */ + public function getApplicableLocale($locales, $eulaVersion = null) { + if (!$this->getEulaDetails() && !$this->validateEulaVersion($eulaVersion ?? $this->eulaVersion)) { + return static::DEFAULT_EULA_LANGUAGE; + } + + if (is_string($locales)) { + return $this->getCorrespondingLocaleAvailable($locales) ?? static::DEFAULT_EULA_LANGUAGE; + } + + foreach ($locales as $locale) { + $correspondingLocale = $this->getCorrespondingLocaleAvailable($locale); + if ($correspondingLocale) { + return $correspondingLocale; + } + } + + return static::DEFAULT_EULA_LANGUAGE; + } + + /** + * Get the corresponding available locale or return null + * + * @param string $locales + * @return string|null + */ + protected function getCorrespondingLocaleAvailable($locale) { + $eulaLangs = $this->eulaVersionDetails['available_languages']; + $locale = str_replace("_", "-", substr($locale, 0, 5)); + + return in_array($locale, $eulaLangs) ? $locale : null; + } + + /** + * Get the required headers that need to be sent with every request at service's end + * @see https://developers.turnitin.com/docs/tca#required-headers + * + * @return array + */ + protected function getRequiredHeaders(){ + return [ + 'X-Turnitin-Integration-Name' => $this->integrationName, + 'X-Turnitin-Integration-Version' => $this->integrationVersion, + 'Authorization' => 'Bearer ' . $this->apiKey, + ]; + } + + /** + * Generate and return the final API end point to make request + * + * @return \GuzzleHttp\Psr7\Uri + */ + protected function getApiPath($apiPathSegment) { + $apiRequestUrl = str_replace('API_URL', $this->apiUrl, $this->apiBasePath) . $apiPathSegment; + return new \GuzzleHttp\Psr7\Uri($apiRequestUrl); + } + + /** + * Generate and return unique entity id by concatenating the prefix to given id + * + * @param string $entity The entity name (e.g. owner/submitter etc). + * @param mixed $id Entity id associated with requesting system. + * @param bool $silent Silently return the passed `$id` if no matching entity mapping + * not found. Default to `false` and when set to `true`, will not throw + * exception. + * + * @return mixed + */ + protected function getGeneratedId($entity, $id, $silent = false) { + if (!in_array($entity, array_keys(static::ENTITY_ID_PREFIXES))) { + if ($silent) { + return $id; + } + + throw new Exception( + sprintf( + 'Invalid entity %s given, must be among [%s]', + $entity, + implode(', ', array_keys(static::ENTITY_ID_PREFIXES)) + ) + ); + } + + return static::ENTITY_ID_PREFIXES[$entity] . $id; + } + + /** + * Validate the existence of a permission against a given permission set + * + * @param string $permission The specific permission to check for existence + * @param array $permissionSet The permission list to check against + * + * @return bool True/False if the permission exists in the given permission set + */ + protected function validatePermission($permission, $permissionSet) { + return in_array($permission, $permissionSet); + } +} diff --git a/PlagiarismPlugin.inc.php b/PlagiarismPlugin.inc.php index a1616ba..791035b 100644 --- a/PlagiarismPlugin.inc.php +++ b/PlagiarismPlugin.inc.php @@ -3,29 +3,126 @@ /** * @file PlagiarismPlugin.inc.php * - * Copyright (c) 2003-2021 Simon Fraser University - * Copyright (c) 2003-2021 John Willinsky + * Copyright (c) 2024 Simon Fraser University + * Copyright (c) 2024 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file LICENSE. * * @brief Plagiarism plugin */ import('lib.pkp.classes.plugins.GenericPlugin'); +import('lib.pkp.classes.db.DAORegistry'); class PlagiarismPlugin extends GenericPlugin { + + /** + * Specify a default integration name for iThenticate service + */ + public const PLUGIN_INTEGRATION_NAME = 'Plagiarism plugin for OJS/OMP/OPS'; + + /** + * The default permission of submission primary author's to pass to the iThenticate service + */ + public const SUBMISSION_AUTOR_ITHENTICATE_DEFAULT_PERMISSION = 'USER'; + + /** + * Number of seconds EULA details for a context should be cached before refreshing it + */ + public const EULA_CACHE_LIFETIME = 60 * 60 * 24; + + /** + * Mapping of similarity settings with value type + * + * @var array + */ + public $similaritySettings = [ + 'addToIndex' => 'bool', + 'excludeQuotes' => 'bool', + 'excludeBibliography' => 'bool', + 'excludeCitations' => 'bool', + 'excludeAbstract' => 'bool', + 'excludeMethods' => 'bool', + 'excludeSmallMatches' => 'int', + 'allowViewerUpdate' => 'bool', + ]; + + /** + * List of archive mime type that will not be uploaded to iThenticate service + * + * @var array + */ + public $uploadRestrictedArchiveMimeTypes = [ + 'application/gzip', + 'application/zip', + 'application/x-tar', + ]; + + /** + * List of valid url components + * + * @var array + */ + protected $validRouteComponentHandlers = [ + 'plugins.generic.plagiarism.controllers.PlagiarismWebhookHandler', + 'plugins.generic.plagiarism.controllers.PlagiarismEulaAcceptanceHandler', + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + ]; + + /** + * Determine if running application is OPS or not + * + * @return bool + */ + public static function isOPS() { + return strtolower(Application::get()->getName()) === 'ops'; + } + /** * @copydoc Plugin::register() */ public function register($category, $path, $mainContextId = null) { $success = parent::register($category, $path, $mainContextId); + $this->addLocaleData(); - if ($success && $this->getEnabled()) { - HookRegistry::register('submissionsubmitstep4form::execute', array($this, 'callback')); + // if plugin hasn't registered, not allow loading plugin + if (!$success) { + return false; + } + + // Plugin has been registered but not enabled + // will allow to load plugin but no plugin feature will be executed + if (!$this->getEnabled($mainContextId)) { + return $success; } + + HookRegistry::register('submissionsubmitstep4form::display', [$this, 'confirmEulaAcceptance']); + HookRegistry::register('submissionsubmitstep4form::execute', [$this, 'submitForPlagiarismCheck']); + + HookRegistry::register('Schema::get::' . SCHEMA_SUBMISSION, [$this, 'addPlagiarismCheckDataToSubmissionSchema']); + HookRegistry::register('Schema::get::' . SCHEMA_SUBMISSION_FILE, [$this, 'addPlagiarismCheckDataToSubmissionFileSchema']); + HookRegistry::register('Schema::get::' . SCHEMA_CONTEXT, [$this, 'addIthenticateConfigSettingsToContextSchema']); + HookRegistry::register('SubmissionFile::edit', [$this, 'updateIthenticateRevisionHistory']); + + HookRegistry::register('userdao::getAdditionalFieldNames', [$this, 'handleAdditionalEulaConfirmationFieldNames']); + + HookRegistry::register('LoadComponentHandler', [$this, 'handleRouteComponent']); + + HookRegistry::register('editorsubmissiondetailsfilesgridhandler::initfeatures', [$this, 'addActionsToSubmissionFileGrid']); + HookRegistry::register('editorreviewfilesgridhandler::initfeatures', [$this, 'addActionsToSubmissionFileGrid']); + return $success; } + /** + * Running in test mode + * + * @return bool + */ + public static function isRunningInTestMode() { + return Config::getVar('ithenticate', 'test_mode', false); + } + /** * @copydoc Plugin::getDisplayName() */ @@ -43,215 +140,1083 @@ public function getDescription() { /** * @copydoc LazyLoadPlugin::getCanEnable() */ - function getCanEnable($contextId = null) { + public function getCanEnable($contextId = null) { return !Config::getVar('ithenticate', 'ithenticate'); } /** * @copydoc LazyLoadPlugin::getCanDisable() */ - function getCanDisable($contextId = null) { + public function getCanDisable($contextId = null) { return !Config::getVar('ithenticate', 'ithenticate'); } /** * @copydoc LazyLoadPlugin::getEnabled() */ - function getEnabled($contextId = null) { + public function getEnabled($contextId = null) { + + // This check is required as plugin can be forced enable by setting `ithenticate` to `On` + // in the config file which cuase the hooks to run but unavailable + // in the installation mode by setting `installed` to `Off` + if (!Config::getVar('general', 'installed')) { + return false; + } + + // This allow to force enable the plugin into the system if `ithenticate` set to `On` but the plugin + // itself still disable as in `plugin_setings` table, the `enabled` value not set or set to `0` + // for more details, see https://github.com/pkp/plagiarism/issues/49 + if (Config::getVar('ithenticate', 'ithenticate') && !parent::getEnabled($contextId)) { + $this->setEnabled(true); + } + return parent::getEnabled($contextId) || Config::getVar('ithenticate', 'ithenticate'); } /** - * Fetch credentials from config.inc.php, if available - * @return array username and password, or null(s) - **/ - function getForcedCredentials() { - $request = Application::get()->getRequest(); - $context = $request->getContext(); - $contextPath = $context->getPath(); - $username = Config::getVar('ithenticate', 'username[' . $contextPath . ']', - Config::getVar('ithenticate', 'username')); - $password = Config::getVar('ithenticate', 'password[' . $contextPath . ']', - Config::getVar('ithenticate', 'password')); - return [$username, $password]; + * Add properties for this type of public identifier to the submission entity's list for + * storage in the database. + * + * @param string $hookName `Schema::get::submission` + * @param array $params + * + * @return bool + */ + public function addPlagiarismCheckDataToSubmissionSchema($hookName, $params) { + $schema =& $params[0]; + + $schema->properties->ithenticateEulaVersion = (object) [ + 'type' => 'string', + 'description' => 'The iThenticate EULA version which has been agreed at submission checklist', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateEulaUrl = (object) [ + 'type' => 'string', + 'description' => 'The iThenticate EULA url which has been agreen at submission checklist', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateSubmissionCompletedAt = (object) [ + 'type' => 'string', + 'description' => 'The timestamp at which this submission successfully completed uploading all files at iThenticate service end', + 'writeOnly' => true, + 'validation' => [ + 'date:Y-m-d H:i:s', + 'nullable', + ], + ]; + + return false; } /** - * Send the editor an error message - * @param $submissionid int - * @param $message string - * @return void - **/ - public function sendErrorMessage($submissionid, $message) { - $request = Application::get()->getRequest(); - $context = $request->getContext(); - import('classes.notification.NotificationManager'); - $notificationManager = new NotificationManager(); - $roleDao = DAORegistry::getDAO('RoleDAO'); /* @var $roleDao RoleDAO */ - // Get the managers. - $managers = $roleDao->getUsersByRoleId(ROLE_ID_MANAGER, $context->getId()); - while ($manager = $managers->next()) { - $notificationManager->createTrivialNotification($manager->getId(), NOTIFICATION_TYPE_ERROR, array('contents' => __('plugins.generic.plagiarism.errorMessage', array('submissionId' => $submissionid, 'errorMessage' => $message)))); + * Add properties for this type of public identifier to the submission file entity's list for + * storage in the database. + * + * @param string $hookName `Schema::get::submissionFile` + * @param array $params + * + * @return bool + */ + public function addPlagiarismCheckDataToSubmissionFileSchema($hookName, $params) { + $schema =& $params[0]; + + $schema->properties->ithenticateFileId = (object) [ + 'type' => 'integer', + 'description' => 'The file id from the files table', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateId = (object) [ + 'type' => 'string', + 'description' => 'The iThenticate submission id for submission file', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateSimilarityScheduled = (object) [ + 'type' => 'boolean', + 'description' => 'The status which identify if the iThenticate similarity process has been scheduled for this submission file', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateSimilarityResult = (object) [ + 'type' => 'string', + 'description' => 'The similarity check result for this submission file in json format', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateSubmissionAcceptedAt = (object) [ + 'type' => 'string', + 'description' => 'The timestamp at which this submission file successfully accepted at iThenticate service end', + 'writeOnly' => true, + 'validation' => [ + 'date:Y-m-d H:i:s', + 'nullable', + ], + ]; + + $schema->properties->ithenticateRevisionHistory = (object) [ + 'type' => 'string', + 'description' => 'The similarity check action history on the previous revisions of this submission file', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + return false; + } + + /** + * Add properties for this type of public identifier to the context entity's list for + * storage in the database. + * + * @param string $hookName `Schema::get::context` + * @param array $params + * + * @return bool + */ + public function addIthenticateConfigSettingsToContextSchema($hookName, $params) { + $schema =& $params[0]; + + $schema->properties->ithenticateWebhookSigningSecret = (object) [ + 'type' => 'string', + 'description' => 'The iThenticate service webook registration signing secret', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + $schema->properties->ithenticateWebhookId = (object) [ + 'type' => 'string', + 'description' => 'The iThenticate service webook id that return back after successful webhook registration', + 'writeOnly' => true, + 'validation' => ['nullable'], + ]; + + return false; + } + + /** + * Add plagiarism action history for revision files. + * Only contains action history for files that has been sent for plagiarism check. + * + * @param string $hookName `SubmissionFile::edit` + * @param array $params + * + * @return bool + */ + public function updateIthenticateRevisionHistory($hookName, $params) { + $submissionFile = & $params[0]; /** @var SubmissionFile $submissionFile */ + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); /** @var SubmissionFileDAO $submissionFileDao */ + $currentSubmissionFile = $submissionFileDao->getById($submissionFile->getId()); /** @var SubmissionFile $currentSubmissionFile */ + + // Do not track for plagiarism revision history until marked for tracking + if (is_null($currentSubmissionFile->getData('ithenticateFileId'))) { + return false; + } + + // If file has not changed, no change in plagiarism revision history + if ($currentSubmissionFile->getData('fileId') === $submissionFile->getData('fileId')) { + return false; + } + + // new file revision added, so add/update itnenticate revision hisotry + $revisionHistory = json_decode($currentSubmissionFile->getData('ithenticateRevisionHistory') ?? '{}', true); + $submissionFile->setData('ithenticateFileId', $submissionFile->getData('fileId')); + + // If the previous file not sent schedule for plagiarism check + // no need to store it's plagiarism revision history + if (is_null($currentSubmissionFile->getData('ithenticateId'))) { + return false; } - error_log('iThenticate submission '.$submissionid.' failed: '.$message); + + array_push($revisionHistory, [ + 'ithenticateFileId' => $currentSubmissionFile->getData('ithenticateFileId'), + 'ithenticateId' => $currentSubmissionFile->getData('ithenticateId'), + 'ithenticateSimilarityResult' => $currentSubmissionFile->getData('ithenticateSimilarityResult'), + 'ithenticateSimilarityScheduled' => $currentSubmissionFile->getData('ithenticateSimilarityScheduled'), + 'ithenticateSubmissionAcceptedAt' => $currentSubmissionFile->getData('ithenticateSubmissionAcceptedAt'), + ]); + + $submissionFile->setData('ithenticateRevisionHistory', json_encode($revisionHistory)); + $submissionFile->setData('ithenticateId', null); + $submissionFile->setData('ithenticateSimilarityResult', null); + $submissionFile->setData('ithenticateSimilarityScheduled', 0); + $submissionFile->setData('ithenticateSubmissionAcceptedAt', null); + + return false; + } + + /** + * Add additional fields for users to stamp EULA details + * + * @param string $hookName `userdao::getAdditionalFieldNames` + * @param array $params + * + * @return bool + */ + public function handleAdditionalEulaConfirmationFieldNames($hookName, $params) { + + $fields =& $params[1]; + + $fields[] = 'ithenticateEulaVersion'; + $fields[] = 'ithenticateEulaConfirmedAt'; + + return false; } /** - * Send submission files to iThenticate. - * @param $hookName string - * @param $args array + * Handle the plugin specific route component requests + * + * @param string $hookName `LoadComponentHandler` + * @param array $params + * + * @return bool */ - public function callback($hookName, $args) { + public function handleRouteComponent($hookName, $params) { + $component =& $params[0]; + + if (static::isOPS() && $component === 'grid.articleGalleys.ArticleGalleyGridHandler') { + $this->import('controllers.PlagiarismArticleGalleyGridHandler'); + PlagiarismArticleGalleyGridHandler::setPlugin($this); + $params[0] = "plugins.generic.plagiarism.controllers.PlagiarismArticleGalleyGridHandler"; + return true; + } + + if (!in_array($component, $this->validRouteComponentHandlers)) { + return false; + } + + import($component); + $componentName = last(explode('.', $component)); + $componentName::setPlugin($this); + + return true; + } + + /** + * Check at the final stage of submission if the submitting user has already confirmed + * or accepted the EULA version associated with submission + * + * @param string $hookName `submissionsubmitstep4form::display` + * @param array $params + * + * @return bool + */ + public function confirmEulaAcceptance($hookName, $params) { $request = Application::get()->getRequest(); $context = $request->getContext(); - $contextPath = $context->getPath(); - $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /* @var $submissionDao SubmissionDAO */ - $submission = $submissionDao->getById($request->getUserVar('submissionId')); - $publication = $submission->getCurrentPublication(); - require_once(dirname(__FILE__) . '/vendor/autoload.php'); + // plugin can not function if the iThenticate service access not available at global/context level + if (!$this->isServiceAccessAvailable($context)) { + error_log("ithenticate service access not set for context id : " . ($context ? $context->getId() : 'undefined')); + return false; + } - // try to get credentials for current context otherwise use default config - $contextId = $context->getId(); - list($username, $password) = $this->getForcedCredentials(); - if (empty($username) || empty($password)) { - $username = $this->getSetting($contextId, 'ithenticateUser'); - $password = $this->getSetting($contextId, 'ithenticatePass'); + // if the auto upload to ithenticate disable + // not going to do the EULA confirmation at submission time + if ($this->hasAutoSubmissionDisabled()) { + return false; } + + $user = $request->getUser(); + $form = & $params[0]; /** @var SubmissionSubmitStep4Form $form */ + $submission = $form->submission; /** @var Submission $submission */ - $ithenticate = null; - try { - $ithenticate = new \bsobbe\ithenticate\Ithenticate($username, $password); - } catch (Exception $e) { - $this->sendErrorMessage($submission->getId(), $e->getMessage()); + // EULA confirmation is not required, so no need for the checking of EULA acceptance + if ($this->getContextEulaDetails($context, 'require_eula') == false) { return false; } - // Make sure there's a group list for this context, creating if necessary. - $groupList = $ithenticate->fetchGroupList(); - $contextName = $context->getLocalizedName($context->getPrimaryLocale()); - if (!($groupId = array_search($contextName, $groupList))) { - // No folder group found for the context; create one. - $groupId = $ithenticate->createGroup($contextName); - if (!$groupId) { - $this->sendErrorMessage($submission->getId(), 'Could not create folder group for context ' . $contextName . ' on iThenticate.'); - return false; - } + + // If submission has EULA stamped and user has EULA stamped and both are same version + // so there is no need to confirm EULA again + if ($submission->getData('ithenticateEulaVersion') && + $submission->getData('ithenticateEulaVersion') == $user->getData('ithenticateEulaVersion')) { + + return false; } - // Create a folder for this submission. - if (!($folderId = $ithenticate->createFolder( - 'Submission_' . $submission->getId(), - 'Submission_' . $submission->getId() . ': ' . $publication->getLocalizedTitle($publication->getData('locale')), - $groupId, - true, - true - ))) { - $this->sendErrorMessage($submission->getId(), 'Could not create folder for submission ID ' . $submission->getId() . ' on iThenticate.'); + $actionUrl = $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismEulaAcceptanceHandler', + 'handle', + null, + ); + + // As submitting user has not confrimed/accepted the EULA, + // we will override the submission's final stage confirmation view + // with a EULA confirmation view + $form->_template = $this->getTemplateResource('confirmEula.tpl'); + + import("plugins.generic.plagiarism.IThenticate"); + $eulaVersionDetails = $this->getContextEulaDetails($context, [ + $submission->getData('locale'), + $request->getSite()->getPrimaryLocale(), + IThenticate::DEFAULT_EULA_LANGUAGE + ]); + + $templateManager = TemplateManager::getManager(); + $templateManager->assign([ + 'submissionId' => $submission->getId(), + 'actionUrl' => $actionUrl, + 'eulaAcceptanceMessage' => __('plugins.generic.plagiarism.submission.eula.acceptance.message', [ + 'localizedEulaUrl' => $eulaVersionDetails['url'], + ]), + 'cancelWarningMessage' => __('submission.submit.cancelSubmission'), + 'cancelRedirect' => $request->getDispatcher()->url( + $request, + ROUTE_PAGE, + $context->getData('urlPath'), + 'submissions' + ) + ]); + + return false; + } + + /** + * Complete the submission process at iThenticate service's end + * The steps follows as: + * - Check if proper service credentials(API Url and Key) are available + * - Register webhook for context if not already registered + * - Check for EULA confirmation requirement + * - Check if EULA is stamped to submission + * - if not stamped, not allowed to submit at iThenticate + * - Check if EULA is stamped to submitting user + * - if not stamped, not allowed to submit at iThenticate + * - Traversing the submission files + * - Create new submission at ithenticate's end for each submission file + * - Upload the file for newly created submission uuid return back from ithenticate + * - Stamp the retuning iThenticate submission id with submission file + * - Return bool to indicate the status of process completion + * + * @param string $hookName `submissionsubmitstep4form::execute` + * @param array $args + * + * @return bool + */ + public function submitForPlagiarismCheck($hookName, $args) { + + $request = Application::get()->getRequest(); + $context = $request->getContext(); + + // plugin can not function if the iThenticate service access not available at global/context level + if (!$this->isServiceAccessAvailable($context)) { + error_log("ithenticate service access not set for context id : " . ($context ? $context->getId() : 'undefined')); return false; } + // if the auto upload to ithenticate disable + // not going to upload files to iThenticate at submission time + if ($this->hasAutoSubmissionDisabled()) { + return false; + } + + $form =& $args[0]; /** @var SubmissionSubmitStep4Form $form */ + $submission = $form->submission; /** @var Submission $submission */ + $user = $request->getUser(); + + $ithenticate = $this->initIthenticate(...$this->getServiceAccess($context)); /** @var IThenticate $ithenticate */ + + // If no webhook previously registered for this Context, register it + if (!$context->getData('ithenticateWebhookId')) { + $this->registerIthenticateWebhook($ithenticate, $context); + } + + // Only set applicable EULA if EULA required + if ($this->getContextEulaDetails($context, 'require_eula') == true) { + $ithenticate->setApplicableEulaVersion($submission->getData('ithenticateEulaVersion')); + } + + // Check EULA stamped to submission or submitter only if it is required + if ($this->getContextEulaDetails($context, 'require_eula') !== false) { + // not going to sent it for plagiarism check if EULA not stamped to submission or submitter + if (!$submission->getData('ithenticateEulaVersion') || !$user->getData('ithenticateEulaVersion')) { + $this->sendErrorMessage(__('plugins.generic.plagiarism.stamped.eula.missing'), $submission->getId()); + return false; + } + } + + /** @var DAOResultIterator $submissionFiles */ $submissionFiles = Services::get('submissionFile')->getMany([ 'submissionIds' => [$submission->getId()], ]); - $authors = $publication->getData('authors'); - $author = array_shift($authors); - foreach ($submissionFiles as $submissionFile) { - $file = Services::get('file')->get($submissionFile->getData('fileId')); - if (!$ithenticate->submitDocument( - $submissionFile->getLocalizedData('name'), - $author->getLocalizedGivenName(), - $author->getLocalizedFamilyName(), - $submissionFile->getLocalizedData('name'), - Services::get('file')->fs->read($file->path), - $folderId - )) { - $this->sendErrorMessage($submission->getId(), 'Could not submit "' . $submissionFile->getData('path') . '" to iThenticate.'); + try { + foreach($submissionFiles as $submissionFile) { /** @var SubmissionFile $submissionFile */ + if (!$this->createNewSubmission($request, $user, $submission, $submissionFile, $ithenticate)) { + return false; + } } + } catch (\Throwable $exception) { + error_log('submit for plagiarism check failed with excaption ' . $exception->__toString()); + $this->sendErrorMessage(__('plugins.generic.plagiarism.ithenticate.upload.complete.failed'), $submission->getId()); + return false; } + $submission->setData('ithenticateSubmissionCompletedAt', Core::getCurrentDate()); + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submissionDao->updateObject($submission); + return false; } /** - * @copydoc Plugin::getActions() - */ - function getActions($request, $verb) { - $router = $request->getRouter(); - import('lib.pkp.classes.linkAction.request.AjaxModal'); - return array_merge( - $this->getEnabled() ? array( - new LinkAction( - 'settings', - new AjaxModal( - $router->url($request, null, null, 'manage', null, array('verb' => 'settings', 'plugin' => $this->getName(), 'category' => 'generic')), - $this->getDisplayName() - ), - __('manager.plugins.settings'), - null - ), - ) : array(), - parent::getActions($request, $verb) - ); - } - - /** - * @copydoc Plugin::manage() - */ - function manage($args, $request) { - switch ($request->getUserVar('verb')) { - case 'settings': - $context = $request->getContext(); - - AppLocale::requireComponents(LOCALE_COMPONENT_APP_COMMON, LOCALE_COMPONENT_PKP_MANAGER); - $templateMgr = TemplateManager::getManager($request); - $templateMgr->registerPlugin('function', 'plugin_url', array($this, 'smartyPluginUrl')); - - $this->import('PlagiarismSettingsForm'); - $form = new PlagiarismSettingsForm($this, $context->getId()); - - if ($request->getUserVar('save')) { - $form->readInputData(); - if ($form->validate()) { - $form->execute(); - return new JSONMessage(true); - } - } else { - $form->initData(); - } - return new JSONMessage(true, $form->fetch($request)); - } - return parent::manage($args, $request); - } -} + * Add ithenticate related data and actions to submission file grid view + * + * @param string $hookName `editorsubmissiondetailsfilesgridhandler::initfeatures` or `editorreviewfilesgridhandler::initfeatures` + * @param array $params + * + * @return bool + */ + public function addActionsToSubmissionFileGrid($hookName, $params) { + $request = Application::get()->getRequest(); + $context = $request->getContext(); -/** - * Low-budget mock class for \bsobbe\ithenticate\Ithenticate -- Replace the - * constructor above with this class name to log API usage instead of - * interacting with the iThenticate service. - */ -class TestIthenticate { - public function __construct($username, $password) { - error_log("Constructing iThenticate: $username $password"); + // plugin can not function if the iThenticate service access not available at global/context level + if (!$this->isServiceAccessAvailable($context)) { + error_log("ithenticate service access not set for context id : " . ($context ? $context->getId() : 'undefined')); + return false; + } + + $user = $request->getUser(); + if (!$user->hasRole([ROLE_ID_MANAGER, ROLE_ID_SUB_EDITOR, ROLE_ID_REVIEWER], $context->getId())) { + return false; + } + + /** @var EditorSubmissionDetailsFilesGridHandler|EditorReviewFilesGridHandler $submissionDetailsFilesGridHandler */ + $submissionDetailsFilesGridHandler = & $params[0]; + + $this->import('grids.SimilarityActionGridColumn'); + $submissionDetailsFilesGridHandler->addColumn(new SimilarityActionGridColumn($this)); + + $this->import('grids.RearrangeColumnsFeature'); + $features =& $params[3]; /** @var array $features */ + $features[] = new RearrangeColumnsFeature($submissionDetailsFilesGridHandler); + + return false; } - public function fetchGroupList() { - error_log('Fetching iThenticate group list'); - return array(); + /** + * Stamp the iThenticate EULA with the submission + * + * @param Context $context + * @param Submission $submission + * + * @return bool + */ + public function stampEulaToSubmission($context, $submission) { + + $request = Application::get()->getRequest(); + + $eulaDetails = $this->getContextEulaDetails($context, [ + $submission->getData('locale'), + $request->getSite()->getPrimaryLocale(), + IThenticate::DEFAULT_EULA_LANGUAGE + ]); + + $submission->setData('ithenticateEulaVersion', $eulaDetails['version']); + $submission->setData('ithenticateEulaUrl', $eulaDetails['url']); + + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submissionDao->updateObject($submission); + + return true; } - public function createGroup($group_name) { - error_log("Creating group named \"$group_name\""); - return 1; + /** + * Stamp the iThenticate EULA to the submitting user + * + * @param Context $context + * @param Submission $submission + * @param User|null $user + * + * @return bool + */ + public function stampEulaToSubmittingUser($context, $submission, $user = null) { + $request = Application::get()->getRequest(); + $user ??= $request->getUser(); + + $submissionEulaVersion = $submission->getData('ithenticateEulaVersion'); + + if (is_null($submissionEulaVersion)) { + $eulaDetails = $this->getContextEulaDetails($context, [ + $submission->getData('locale'), + $request->getSite()->getPrimaryLocale(), + IThenticate::DEFAULT_EULA_LANGUAGE + ]); + + $submissionEulaVersion = $eulaDetails['version']; + } + + // If submission EULA version has already been stamped to user + // no need to do the confirmation and stamping again + if ($user->getData('ithenticateEulaVersion') === $submissionEulaVersion) { + return false; + } + + $ithenticate = $this->initIthenticate(...$this->getServiceAccess($context)); /** @var IThenticate $ithenticate */ + $ithenticate->setApplicableEulaVersion($submissionEulaVersion); + + // Check if user has ever already accepted this EULA version and if so, stamp it to user + // Or, try to confirm the EULA for user and upon succeeding, stamp it to user + if ($ithenticate->verifyUserEulaAcceptance($user, $submissionEulaVersion) || + $ithenticate->confirmEula($user, $context)) { + $this->stampEulaVersionToUser($user, $submissionEulaVersion); + return true; + } + + return false; } - public function createFolder($folder_name, $folder_description, $group_id, $exclude_quotes) { - error_log("Creating folder:\n\t$folder_name\n\t$folder_description\n\t$group_id\n\t$exclude_quotes"); + /** + * Create a new submission at iThenticate service's end + * + * @param Request $request + * @param User $user + * @param Submission $submission + * @param SubmissionFile $submissionFile + * @param IThenticate|TestIThenticate $ithenticate + * + * @return bool + */ + public function createNewSubmission($request, $user, $submission, $submissionFile, $ithenticate) { + $context = $request->getContext(); + $publication = $submission->getCurrentPublication(); + $author = $publication->getPrimaryAuthor(); + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); /** @var SubmissionFileDAO $submissionFileDao */ + + $submissionUuid = $ithenticate->createSubmission( + $request->getSite(), + $submission, + $user, + $author, + static::SUBMISSION_AUTOR_ITHENTICATE_DEFAULT_PERMISSION, + $this->getSubmitterPermission($context, $user) + ); + + if (!$submissionUuid) { + $this->sendErrorMessage( + __('plugins.generic.plagiarism.ithenticate.submission.create.failed', [ + 'submissionFileId' => $submissionFile->getId(), + ]), + $submission->getId() + ); + return false; + } + + $pkpFileService = Services::get('file'); /** @var \PKP\Services\PKPFileService $pkpFileService */ + $file = $pkpFileService->get($submissionFile->getData('fileId')); + + if (in_array($file->mimetype, $this->uploadRestrictedArchiveMimeTypes)) { + return true; + } + + $submissionFileName = $submissionFile->getData("name", $publication->getData("locale")) + ?? collect([$context->getPrimaryLocale()]) + ->merge($context->getData("supportedSubmissionLocales") ?? []) + ->merge([$request->getSite()->getPrimaryLocale()]) + ->unique() + ->map(fn ($locale) => $submissionFile->getData("name", $locale)) + ->filter() + ->first(); + + $uploadStatus = $ithenticate->uploadFile( + $submissionUuid, + $submissionFileName, + $pkpFileService->fs->read($file->path), + ); + + // Upload submission files for successfully created submission at iThenticate's end + if (!$uploadStatus) { + $this->sendErrorMessage( + __('plugins.generic.plagiarism.ithenticate.file.upload.failed', [ + 'submissionFileId' => $submissionFile->getId(), + ]), + $submission->getId() + ); + return false; + } + + $submissionFile->setData('ithenticateId', $submissionUuid); + $submissionFile->setData('ithenticateFileId', $submissionFile->getData('fileId')); + $submissionFile->setData('ithenticateSimilarityScheduled', 0); + $submissionFileDao->updateObject($submissionFile); + return true; } - public function submitDocument($essay_title, $author_firstname, $author_lastname, $filename, $document_content, $folder_number) { - error_log("Submitting document:\n\t$essay_title\n\t$author_firstname\n\t$author_lastname\n\t$filename\n\t" . strlen($document_content) . " bytes of content\n\t$folder_number"); - return true; + /** + * Register the webhook for this context + * + * @param IThenticate|TestIThenticate $ithenticate + * @param Context|null $context + * + * @return bool + */ + public function registerIthenticateWebhook($ithenticate, $context = null) { + + $request = Application::get()->getRequest(); + $context ??= $request->getContext(); + + $signingSecret = \Illuminate\Support\Str::random(12); + $webhookUrl = Application::get()->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismWebhookHandler', + 'handle' + ); + + if ($webhookId = $ithenticate->registerWebhook($signingSecret, $webhookUrl)) { + $contextService = Services::get('context'); /** @var \PKP\Services\PKPContextService $contextService */ + $context = $contextService->edit($context, [ + 'ithenticateWebhookSigningSecret' => $signingSecret, + 'ithenticateWebhookId' => $webhookId + ], $request); + + return true; + } + + error_log("unable to complete the iThenticate webhook registration for context id {$context->getId()}"); + + return false; + } + + /** + * Get the cached EULA details form Context + * The eula details structure is in the following format + * [ + * 'require_eula' => null/true/false, // null => not possible to retrived, + * // true => EULA confirmation required, + * // false => EULA confirmation not required + * 'en_US' => [ + * 'version' => '', + * 'url' => '', + * ], + * ... + * ] + * + * Based on the `key` param defined, it will return in following format + * - if null, will return the whole details in above structure + * - if array, will try to find the first matching `key` index value and return that + * - if array and not found any match or if string, will return value based on last + * array index or string value and considering the default value along with it + * + * @param Context $context + * @param string|array|null $keys + * @param mixed $default + * + * @return mixed + */ + public function getContextEulaDetails($context, $keys = null, $default = null) { + /** @var \FileCache $cache */ + $cache = CacheManager::getManager() + ->getCache( + 'ithenticate_eula', + $context->getId(), + [$this, 'retrieveEulaDetails'] + ); + + // if running on ithenticate test mode, set the cache life time to 60 seconds + $cacheLifetime = static::isRunningInTestMode() ? 60 : static::EULA_CACHE_LIFETIME; + if (time() - $cache->getCacheTime() > $cacheLifetime) { + $cache->flush(); + } + + $eulaDetails = $cache->get($context->getId()); + + if (!$keys) { + return $eulaDetails; + } + + if (is_array($keys)) { + foreach ($keys as $key) { + $value = data_get($eulaDetails, $key); + if ($value) { + return $value; + } + } + } + + return data_get( + $eulaDetails, + last(\Illuminate\Support\Arr::wrap($keys)), + $default + ); + } + + /** + * Retrieved and generate the localized EULA details and EULA confirmation requirement + * for given context and cache it in following format + * [ + * 'require_eula' => null/true/false, // null => not possible to retrived, + * // true => EULA confirmation required, + * // false => EULA confirmation not required + * 'en_US' => [ + * 'version' => '', + * 'url' => '', + * ], + * ... + * ] + * + * @param GenericCache $cache + * @param mixed $cacheId + * + * @return array + */ + public function retrieveEulaDetails($cache, $cacheId) { + $context = Application::get()->getRequest()->getContext(); + $ithenticate = $this->initIthenticate(...$this->getServiceAccess($context)); /** @var IThenticate $ithenticate */ + $eulaDetails = []; + + $eulaDetails['require_eula'] = $ithenticate->getEnabledFeature('tenant.require_eula'); + + // If `require_eula` is set to `true` that is EULA confirmation is required + // and default EULA version is verified + // we will map and store locale key to eula details (version and url) in following structure + // 'en_US' => [ + // 'version' => '', + // 'url' => '', + // ], + // ... + if ($eulaDetails['require_eula'] == true && + $ithenticate->validateEulaVersion($ithenticate::DEFAULT_EULA_VERSION)) { + + foreach($context->getSupportedSubmissionLocaleNames() as $localeKey => $localeName) { + $eulaDetails[$localeKey] = [ + 'version' => $ithenticate->getApplicableEulaVersion(), + 'url' => $ithenticate->getApplicableEulaUrl($localeKey), + ]; + } + + // Also store the default iThenticate language version details + if (!isset($eulaDetails[$ithenticate::DEFAULT_EULA_LANGUAGE])) { + $eulaDetails[$ithenticate::DEFAULT_EULA_LANGUAGE] = [ + 'version' => $ithenticate->getApplicableEulaVersion(), + 'url' => $ithenticate->getApplicableEulaUrl($ithenticate::DEFAULT_EULA_LANGUAGE), + ]; + } + } + + $cache->setEntireCache([$cacheId => $eulaDetails]); + + return $eulaDetails; + } + + /** + * Create and return an instance of service class responsible to handle the + * communication with iThenticate service. + * + * If the test mode is enable, it will return an instance of mock class + * `TestIThenticate` instead of actual commucation responsible class. + * + * @param string $apiUrl + * @param string $apiKey + * + * @return IThenticate|TestIThenticate + */ + public function initIthenticate($apiUrl, $apiKey) { + + if (static::isRunningInTestMode()) { + import('plugins.generic.plagiarism.TestIThenticate'); + return new TestIThenticate( + $apiUrl, + $apiKey, + static::PLUGIN_INTEGRATION_NAME, + $this->getCurrentVersion()->getData('current') + ); + } + + import('plugins.generic.plagiarism.IThenticate'); + + return new IThenticate( + $apiUrl, + $apiKey, + static::PLUGIN_INTEGRATION_NAME, + $this->getCurrentVersion()->getData('current') + ); + } + + /** + * Stamp the EULA version and confirmation datetime for submitting user + * + * @param User $user + * @param string $version + * + * @return void + */ + public function stampEulaVersionToUser($user, $version) { + $userDao = DAORegistry::getDAO('UserDAO'); /** @var UserDAO $userDao */ + + $user->setData('ithenticateEulaVersion', $version); + $user->setData('ithenticateEulaConfirmedAt', Core::getCurrentDate()); + + $userDao->updateObject($user); + } + + /** + * @copydoc Plugin::getActions() + */ + public function getActions($request, $verb) { + $router = $request->getRouter(); + import('lib.pkp.classes.linkAction.request.AjaxModal'); + + return array_merge( + $this->getEnabled() + ? [ + new LinkAction( + 'settings', + new AjaxModal( + $router->url( + $request, + null, + null, + 'manage', + null, + [ + 'verb' => 'settings', + 'plugin' => $this->getName(), + 'category' => 'generic' + ] + ), + $this->getDisplayName() + ), + __('manager.plugins.settings'), + null + ), + ] : [], + parent::getActions($request, $verb) + ); + } + + /** + * @copydoc Plugin::manage() + */ + public function manage($args, $request) { + switch ($request->getUserVar('verb')) { + case 'settings': + $context = $request->getContext(); /** @var Context $context */ + + AppLocale::requireComponents(LOCALE_COMPONENT_APP_COMMON, LOCALE_COMPONENT_PKP_MANAGER); + $templateMgr = TemplateManager::getManager($request); /** @var TemplateManager $templateMgr */ + $templateMgr->registerPlugin('function', 'plugin_url', [$this, 'smartyPluginUrl']); + + $this->import('PlagiarismSettingsForm'); + $form = new PlagiarismSettingsForm($this, $context); + + if ($request->getUserVar('save')) { + $form->readInputData(); + if ($form->validate()) { + $form->execute(); + return new JSONMessage(true); + } + } else { + $form->initData(); + } + return new JSONMessage(true, $form->fetch($request)); + } + + return parent::manage($args, $request); + } + + /** + * Get the ithenticate service access as array in format [API_URL, API_KEY] + * Will try to get credentials for current context otherwise use default config + * + * @param Context|null $context + * @return array The service access creds in format as [API_URL, API_KEY] + */ + public function getServiceAccess($context = null) { + + if ($this->hasForcedCredentials($context)) { + list($apiUrl, $apiKey) = $this->getForcedCredentials($context); + return [$apiUrl, $apiKey]; + } + + if ($context && $context instanceof Context) { + return [ + $this->getSetting($context->getId(), 'ithenticateApiUrl'), + $this->getSetting($context->getId(), 'ithenticateApiKey') + ]; + } + + return ['', '']; + } + + /** + * Fetch credentials from config.inc.php, if available + * + * @param Context|null $context + * @return array api url and api key, or null(s) + */ + public function getForcedCredentials($context = null) { + $contextPath = $context ? $context->getPath() : 'index'; + + $apiUrl = $this->getForcedConfigSetting($contextPath, 'api_url'); + $apiKey = $this->getForcedConfigSetting($contextPath, 'api_key'); + + return [$apiUrl, $apiKey]; + } + + /** + * Check and determine if plagiarism checking service creds has been set forced in config.inc.php + * + * @param Context|null $context + * @return bool + */ + public function hasForcedCredentials($context = null) { + list($apiUrl, $apiKey) = $this->getForcedCredentials($context); + return !empty($apiUrl) && !empty($apiKey); + } + + /** + * Get the configuration settings(all or specific) for ithenticate similarity report generation process + * + * @param Context $context + * @param string|null $settingName + * + * @return array|string|null + */ + public function getSimilarityConfigSettings($context, $settingName = null) { + $contextPath = $context->getPath(); + $similarityConfigSettings = []; + + foreach(array_keys($this->similaritySettings) as $settingOption) { + $similarityConfigSettings[$settingOption] = $this->getForcedConfigSetting($contextPath, $settingOption) + ?? $this->getSetting($context->getId(), $settingOption); + } + + return $settingName + ? ($similarityConfigSettings[$settingName] ?? null) + : $similarityConfigSettings; + } + + /** + * Check if auto upload of submission file has been disable globally or context level + * + * @return bool + */ + public function hasAutoSubmissionDisabled() { + $context = Application::get()->getRequest()->getContext(); /** @var Context $context */ + $contextPath = $context ? $context->getPath() : 'index'; + + return (bool)( + $this->getForcedConfigSetting($contextPath, 'disableAutoSubmission') + ?? $this->getSetting($context->getId(), 'disableAutoSubmission') + ); + } + + /** + * Get the submission submitter's appropriate permission based on role in the submission context + * + * @param Context $context + * @param User $user + * + * @return string + */ + public function getSubmitterPermission($context, $user) { + + if ($user->hasRole([ROLE_ID_SITE_ADMIN, ROLE_ID_MANAGER], $context->getId())) { + return 'ADMINISTRATOR'; + } + + if ($user->hasRole([ROLE_ID_SUB_EDITOR], $context->getId())) { + return 'EDITOR'; + } + + if ($user->hasRole([ROLE_ID_AUTHOR], $context->getId())) { + return 'USER'; + } + + return 'UNDEFINED'; + } + + /** + * Send the editor an error message + * + * @param string $message The error/exception message to set as notification and log in error file + * @param int|null $submissionid The submission id for which error/exception has generated + * + * @return void + */ + public function sendErrorMessage($message, $submissionId = null) { + + $request = Application::get()->getRequest(); /** @var Request $request */ + $context = $request->getContext(); /** @var Context $context */ + $message = $submissionId + ? __( + 'plugins.generic.plagiarism.errorMessage', [ + 'submissionId' => $submissionId, + 'errorMessage' => $message + ] + ) : __( + 'plugins.generic.plagiarism.general.errorMessage', [ + 'errorMessage' => $message + ] + ); + + import('classes.notification.NotificationManager'); + $notificationManager = new NotificationManager(); + $roleDao = DAORegistry::getDAO('RoleDAO'); /** @var RoleDAO $roleDao */ + + // Get the managers. + $managers = $roleDao->getUsersByRoleId(ROLE_ID_MANAGER, $context->getId()); /** @var DAOResultFactory $managers */ + while ($manager = $managers->next()) { + $notificationManager->createTrivialNotification( + $manager->getId(), + NOTIFICATION_TYPE_ERROR, + ['contents' => $message] + ); + } + + error_log("iThenticate submission {$submissionId} failed: {$message}"); + } + + /** + * Get the iThenticate logo URL + * + * @return string + */ + public function getIThenticateLogoUrl() { + return Application::get()->getRequest()->getBaseUrl() + . '/' + . $this->getPluginPath() + . '/' + . 'assets/logo/ithenticate-badge-rec-positive.png'; + } + + /** + * Get the forced config at global or context level if defined + * + * @param string $contextPath + * @param string $configKeyName + * + * @return mixed + */ + protected function getForcedConfigSetting($contextPath, $configKeyName) { + return Config::getVar( + 'ithenticate', + "{$configKeyName}[{$contextPath}]", + Config::getVar('ithenticate', $configKeyName) + ); + } + + /** + * Check is ithenticate service access details(API URL & KEY) available at global level or + * for the given context + * + * @param Context|null $context + * @return bool + */ + protected function isServiceAccessAvailable($context = null) { + return !collect($this->getServiceAccess($context))->filter()->isEmpty(); } } diff --git a/PlagiarismSettingsForm.inc.php b/PlagiarismSettingsForm.inc.php index 2284d98..f58b2c9 100644 --- a/PlagiarismSettingsForm.inc.php +++ b/PlagiarismSettingsForm.inc.php @@ -1,28 +1,79 @@ _contextId = $contextId; + public function __construct($plugin, $context) { $this->_plugin = $plugin; + $this->_context = $context; + + $request = Application::get()->getRequest(); parent::__construct($plugin->getTemplateResource('settingsForm.tpl')); - - $this->addCheck(new FormValidator($this, 'ithenticateUser', 'required', 'plugins.generic.plagiarism.manager.settings.usernameRequired')); - $this->addCheck(new FormValidator($this, 'ithenticatePass', 'required', 'plugins.generic.plagiarism.manager.settings.passwordRequired')); + + if (!empty(array_filter([$request->getUserVar('ithenticateApiUrl'), $request->getUserVar('ithenticateApiKey')]))) { + $this->addCheck(new FormValidator($this, 'ithenticateApiUrl', 'required', 'plugins.generic.plagiarism.manager.settings.apiUrlRequired')); + $this->addCheck(new FormValidator($this, 'ithenticateApiKey', 'required', 'plugins.generic.plagiarism.manager.settings.apiKeyRequired')); + $this->addCheck(new FormValidatorUrl($this, 'ithenticateApiUrl', 'required', 'plugins.generic.plagiarism.manager.settings.apiUrlInvalid')); + $this->addCheck( + new FormValidatorIthenticateAccess( + $this, + '', + 'required', + 'plugins.generic.plagiarism.manager.settings.serviceAccessInvalid', + $this->_plugin->initIthenticate( + $request->getUserVar('ithenticateApiUrl'), + $request->getUserVar('ithenticateApiKey') + ) + ) + ); + } + + $this->addCheck( + new FormValidatorCustom( + $this, + 'excludeSmallMatches', + 'required', + 'plugins.generic.plagiarism.similarityCheck.settings.field.excludeSmallMatches.validation.min', + function($excludeSmallMatches) { + return (int) $excludeSmallMatches >= IThenticate::EXCLUDE_SAMLL_MATCHES_MIN; + } + ) + ); $this->addCheck(new FormValidatorPost($this)); $this->addCheck(new FormValidatorCSRF($this)); @@ -31,26 +82,41 @@ function __construct($plugin, $contextId) { /** * Initialize form data. */ - function initData() { - list($username, $password) = $this->_plugin->getForcedCredentials(); - $this->_data = array( - 'ithenticateUser' => $this->_plugin->getSetting($this->_contextId, 'ithenticateUser'), - 'ithenticatePass' => $this->_plugin->getSetting($this->_contextId, 'ithenticatePass'), - 'ithenticateForced' => !empty($username) && !empty($password) - ); + public function initData() { + $this->_data = [ + 'ithenticateForced' => $this->_plugin->hasForcedCredentials($this->_context), + 'ithenticateApiUrl' => $this->_plugin->getSetting($this->_context->getId(), 'ithenticateApiUrl'), + 'ithenticateApiKey' => $this->_plugin->getSetting($this->_context->getId(), 'ithenticateApiKey'), + 'disableAutoSubmission' => $this->_plugin->getSetting($this->_context->getId(), 'disableAutoSubmission'), + ]; + + foreach(array_keys($this->_plugin->similaritySettings) as $settingOption) { + $this->_data[$settingOption] = $this->_plugin->getSetting($this->_context->getId(), $settingOption); + } + + // set the default value `8` for `excludeSmallMatches` as per iThenticate guide + if ((int) $this->_data['excludeSmallMatches'] < IThenticate::EXCLUDE_SAMLL_MATCHES_MIN) { + $this->_data['excludeSmallMatches'] = IThenticate::EXCLUDE_SAMLL_MATCHES_MIN; + } } /** * Assign form data to user-submitted data. */ - function readInputData() { - $this->readUserVars(array('ithenticateUser', 'ithenticatePass')); + public function readInputData() { + $this->readUserVars( + array_merge([ + 'ithenticateApiUrl', + 'ithenticateApiKey', + 'disableAutoSubmission', + ], array_keys($this->_plugin->similaritySettings)) + ); } /** * @copydoc Form::fetch() */ - function fetch($request, $template = null, $display = false) { + public function fetch($request, $template = null, $display = false) { $templateMgr = TemplateManager::getManager($request); $templateMgr->assign('pluginName', $this->_plugin->getName()); return parent::fetch($request, $template, $display); @@ -59,9 +125,46 @@ function fetch($request, $template = null, $display = false) { /** * @copydoc Form::execute() */ - function execute(...$functionArgs) { - $this->_plugin->updateSetting($this->_contextId, 'ithenticateUser', trim($this->getData('ithenticateUser'), "\"\';"), 'string'); - $this->_plugin->updateSetting($this->_contextId, 'ithenticatePass', trim($this->getData('ithenticatePass'), "\"\';"), 'string'); + public function execute(...$functionArgs) { + + $ithenticateApiUrl = trim($this->getData('ithenticateApiUrl'), "\"\';"); + $ithenticateApiKey = trim($this->getData('ithenticateApiKey'), "\"\';"); + + // if proper api url and api key provided and if there is no forced credentails defined in + // `config.inc.php` at global or for this context + if (!empty(array_filter([$ithenticateApiUrl, $ithenticateApiKey])) && + !$this->_plugin->hasForcedCredentials($this->_context)) { + + // access updated or new access entered, need to update webhook registration + if ($this->_plugin->getSetting($this->_context->getId(), 'ithenticateApiUrl') !== $ithenticateApiUrl || + $this->_plugin->getSetting($this->_context->getId(), 'ithenticateApiKey') !== $ithenticateApiKey) { + + $ithenticate = $this->_plugin->initIthenticate($ithenticateApiUrl, $ithenticateApiKey); + + // If there is a already registered webhook for this context, need to delete it first + // before creating a new one as webhook URL remains same which will return 409(HTTP_CONFLICT) + if ($this->_context->getData('ithenticateWebhookId')) { + $ithenticate->deleteWebhook($this->_context->getData('ithenticateWebhookId')); + } + + $this->_plugin->registerIthenticateWebhook($ithenticate); + } + + $this->_plugin->updateSetting($this->_context->getId(), 'ithenticateApiUrl', $ithenticateApiUrl, 'string'); + $this->_plugin->updateSetting($this->_context->getId(), 'ithenticateApiKey', $ithenticateApiKey, 'string'); + } + + $this->_plugin->updateSetting($this->_context->getId(), 'disableAutoSubmission', $this->getData('disableAutoSubmission'), 'bool'); + + foreach($this->_plugin->similaritySettings as $settingName => $settingValueType) { + $this->_plugin->updateSetting( + $this->_context->getId(), + $settingName, + $this->getData($settingName), + $settingValueType + ); + } + parent::execute(...$functionArgs); } } diff --git a/README.md b/README.md index 10adbd7..76e58bf 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,45 @@ For OJS/OMP/OPS 3.x ## Overview -This plugin permits automatic submission of uploaded manuscripts to the [iThenticate service](http://www.ithenticate.com/) for plagiarism checking. -1. You need an account of ithenticate.com (costs involved) +This plugin permits automatic or manual submission of uploaded manuscripts to the [iThenticate service](http://www.ithenticate.com/) for plagiarism checking. + +1. You need an account on ithenticate.com (costs involved) * paid via Crossref Similarity Check * or, paid directly to iThenticate -2. Install the plugin via the Plugin Gallery in the Dashboard -3. Configure the plugin (see below) - * Enable the plugin via config.inc.php or in a specific journal/press/preprint context - * Configure the plugin with the username and password you get from ithenticate.com - * ![Example Settings configuration](ithenticate-settings.png) -4. The author logs in and makes a submission - * The submission files will be sent to iThenticate in Step 4 of the submission process -5. The Editor logs in to ithenticate.com to see the submission - * The submission will be found in a folder named by the Submission ID, under a Group named by the journal/press/preprint context - * Click to see the report - * ![Example report review](ithenticate-report.png) - -Watch [the demo](https://www.ithenticate.com/demo) to know more about the features of iThenticate. +2. Configure the API credentials following this [guide](https://help.turnitin.com/ithenticate/administrator/api-custom.htm) +3. Install the plugin via the Plugin Gallery +4. Configure the plugin (see below) + * Enable the iThenticate service from `config.inc.php` by setting `ithenticate` to `On`. + * Enable the plugin from Plugin Gallery by clicking on the checkbox. + * ![Example Enabling Plugin](images/enable-plugin.png) + * Configure the plugin with the **API URL** and **API KEY** you get from ithenticate.com at plugin's setting page or in the config file. + * ![Example Settings configuration](images/ithenticate-settings.png) +5. The author logs in and makes a submission + * At Step 4 of the submission process, submitting user must confirm iThenticate's End User License Agreement to complete the submission. + * The submission files will be sent to iThenticate in Step 4 of the submission process. +6. The Editor logs in to OJS/OMP/OPS installation and opens the Submission workflow page. + * In the submission files grid view, the Editor can see the similarity scores if the similarity process has completed. + * The Editor can also launch iThenticate's similarity viewer. +7. If auto upload to iThenticate is disabled, the Editor can send each submission file to iThenticate from the workflow stage. + +## Similarity Check Settings + +There are several iThenticate similarity check settings that can be configured via the plugin. +1. Similarity Check Options + * `addToIndex` -- Submissions will be indexed in the accounts repository and will be available for comparison in Similarity Reports by other users within your organization + * `excludeQuotes` -- Text in quotes of the submission will not count as similar content + * `excludeBibliography` -- Text in a bibliography section of the submission will not count as similar content + * `excludeAbstract` -- Text in the abstract section of the submission will not count as similar content + * `excludeMethods` -- Text in the method section of the submission will not count as similar content + * `excludeCitations` -- The citations of the submission will be excluded from similarity check + * `excludeSmallMatches` -- Similarity matches that match less than the specified amount of words will not count as similar content. Minimum value is 8. + * `allowViewerUpdate` -- Changes made in reports will be saved for the next time the report is viewed + * ![Available Similarity Check Options](images/similarity-check-settings.png) +2. Each of this settings can also be configured at global or Journal/Press/Server level from the `config.inc.php` file. ## Configuration -You may set the credentials in config.inc.php, or you may set the credentials per-journal in the plugin settings. If credentials are present in config.inc.php, they will override those entered in the plugin settings form. +You may set the credentials in config.inc.php, or you may set the credentials per journal/press/server in the plugin settings. If credentials are present in config.inc.php, they will override those entered in the plugin settings form. The config.inc.php settings format is: @@ -35,20 +53,34 @@ The config.inc.php settings format is: [ithenticate] -; Enable iThenticate to submit manuscripts after submit step 4 -;ithenticate = On +; Enable/Disable iThenticate service to upload submission files for plagiarism checking. +; Unsetting this will turn off the plugin globally. +ithenticate = On + +; Global iThenticate API URL +; api_url = "https://some-ithenticate-account.com" -; Credentials can be set by context : specify journal path -; The username to access the API (usually an email address) -;username[MyJournal_path] = "user@email.com" -; The password to access the API -;password[MyJournal_path] = "password" +; Global iThenticate API key +; api_key = "YOUR_API_KEY" -; default credentials -; The username to access the API (usually an email address) -;username = "user@email.com" +; If desired, credentials can also be set by context by specifying each Journal/Server/Press path. +; api_url[Journal_or_Server_or_Press_path] = "https://some-ithenticate-account.com" +; api_key[Journal_or_Server_or_Press_path] = "YOUR_API_KEY" -; The password to access the API -;password = "password" +; To update webhook after changing the API URL or/both KEY defined in the config file, +; run the command `php plugins/generic/plagiarism/tools/registerWebhooks.php`. + +; To globally disable auto upload of submission files to iThenticate service, uncomment following line. +; disableAutoSubmission = On +; It is possible to disable auto upload at specific Journal/Server/Press level rather than globally +; disableAutoSubmission[Journal_or_Server_or_Press_path] = On + +; Other settings can be configured here; see README.md for all options. ``` +> NOTE : Changing the api credentails (`api_url` and/or `api_key`) in the `config.inc.php` file will not update the webhook settings automatically and will require action from the submission workflow section to complete plagiarism similarity score generation process. However it is possible to use the command line tool to update it from CLI via command `php plugins/generic/plagiarism/tools/registerWebhooks.php` to update webhook for forced credentails. + +## Restrictions +1. The submitting user must confirm the iThenticate End User License Agreement to send files to iThenticate service for plagiarism checking. +2. zip/tar/gzip files will not be uploaded to iThenticate. +3. Uploading files larger than 100 MB will cause failure as per iThenticate limits. diff --git a/TestIThenticate.inc.php b/TestIThenticate.inc.php new file mode 100644 index 0000000..889084d --- /dev/null +++ b/TestIThenticate.inc.php @@ -0,0 +1,427 @@ + "v1beta", + "valid_from" => "2018-04-30T17:00:00Z", + "valid_until" => null, + "url" => "https://static.turnitin.com/eula/v1beta/en-us/eula.html", + "available_languages" => [ + "sv-SE", + "zh-CN", + "ja-JP", + "ko-KR", + "es-MX", + "nl-NL", + "ru-RU", + "zh-TW", + "ar-SA", + "pt-BR", + "de-DE", + "el-GR", + "nb-NO", + "cs-CZ", + "da-DK", + "tr-TR", + "pl-PL", + "fi-FI", + "it-IT", + "vi-VN", + "fr-FR", + "en-US", + "ro-RO", + ], + ]; + + /** + * @copydoc IThenticate::$suppressApiRequestException + */ + protected $suppressApiRequestException = true; + + /** + * @copydoc IThenticate::DEFAULT_EULA_VERSION + */ + public const DEFAULT_EULA_VERSION = 'latest'; + + /** + * @copydoc IThenticate::DEFAULT_EULA_LANGUAGE + */ + public const DEFAULT_EULA_LANGUAGE = 'en-US'; + + /** + * @copydoc IThenticate::DEFAULT_WEBHOOK_EVENTS + */ + public const DEFAULT_WEBHOOK_EVENTS = [ + 'SUBMISSION_COMPLETE', + 'SIMILARITY_COMPLETE', + 'SIMILARITY_UPDATED', + 'PDF_STATUS', + 'GROUP_ATTACHMENT_COMPLETE', + ]; + + /** + * @copydoc IThenticate::SUBMISSION_PERMISSION_SET + */ + public const SUBMISSION_PERMISSION_SET = [ + 'ADMINISTRATOR', + 'APPLICANT', + 'EDITOR', + 'INSTRUCTOR', + 'LEARNER', + 'UNDEFINED', + 'USER', + ]; + + /** + * The minimum value of similarity report's view_setting's `exclude_small_matches` option + * @see https://developers.turnitin.com/docs/tca#generate-similarity-report + * + * @var int + */ + public const EXCLUDE_SAMLL_MATCHES_MIN = 8; + + /** + * @copydoc IThenticate::__construct() + */ + public function __construct($apiUrl, $apiKey, $integrationName, $integrationVersion, $eulaVersion = null) { + + // These following 2 conditions are to facilitate the mock the EULA requirement + if ($eulaVersion) { + $this->eulaVersion = $eulaVersion; + } + + if (!$eulaVersion) { + $this->eulaVersion = Config::getVar('ithenticate', 'test_mode_eula', true) ? 'v1beta' : null; + } + + error_log("Constructing iThenticate with API URL : {$apiUrl}, API Key : {$apiKey}, Integration Name : {$integrationName}, Integration Version : {$integrationVersion} and EUlA Version : {$this->eulaVersion}"); + } + + /** + * @copydoc IThenticate::withoutSuppressingApiRequestException() + */ + public function withoutSuppressingApiRequestException() { + $this->suppressApiRequestException = false; + error_log('deactivating api request exception suppression'); + return $this; + } + + /** + * @copydoc IThenticate::getEnabledFeature() + */ + public function getEnabledFeature($feature = null) { + + static $result; + + $result = '{ + "similarity": { + "viewer_modes": { + "match_overview": true, + "all_sources": true + }, + "generation_settings": { + "search_repositories": [ + "INTERNET", + "PUBLICATION", + "CROSSREF", + "CROSSREF_POSTED_CONTENT", + "SUBMITTED_WORK" + ], + "submission_auto_excludes": true + }, + "view_settings": { + "exclude_bibliography": true, + "exclude_quotes": true, + "exclude_abstract": true, + "exclude_methods": true, + "exclude_small_matches": true, + "exclude_internet": true, + "exclude_publications": true, + "exclude_crossref": true, + "exclude_crossref_posted_content": true, + "exclude_submitted_works": true, + "exclude_citations": true, + "exclude_preprints": true + } + }, + "tenant": { + "require_eula": '.($this->eulaVersion ? "true" : "false").' + }, + "product_name": "Turnitin Originality", + "access_options": [ + "NATIVE", + "CORE_API", + "DRAFT_COACH" + ] + }'; + + + if (!$feature) { + error_log("iThenticate enabled feature details {$result}"); + return json_decode($result, true); + } + + $self = $this; + $featureStatus = data_get( + json_decode($result, true), + $feature, + function () use ($self, $feature) { + if ($self->suppressApiRequestException) { + return null; + } + + throw new \Exception("Feature details {$feature} does not exist"); + } + ); + + error_log("iThenticate specific enable feature details {$featureStatus}"); + return $featureStatus; + } + + /** + * @copydoc IThenticate::validateAccess() + */ + public function validateAccess(&$result = null) { + error_log("Confirming the service access validation for given access details"); + return true; + } + + /** + * @copydoc IThenticate::confirmEula() + */ + public function confirmEula($user, $context) { + error_log("Confirming EULA for user {$user->getId()} with language ".$this->getApplicableLocale($context->getPrimaryLocale())." for version {$this->getApplicableEulaVersion()}"); + return true; + } + + /** + * @copydoc IThenticate::createSubmission() + */ + public function createSubmission($site, $submission, $user, $author, $authorPermission, $submitterPermission) { + + if (!$this->validatePermission($authorPermission, static::SUBMISSION_PERMISSION_SET)) { + throw new \Exception("in valid owner permission {$authorPermission} given"); + } + + if (!$this->validatePermission($submitterPermission, static::SUBMISSION_PERMISSION_SET)) { + throw new \Exception("in valid submitter permission {$submitterPermission} given"); + } + + error_log("Creating a new submission with id {$submission->getId()} by submitter {$user->getId()} for owner {$author->getId()} with owner permission as {$authorPermission} and submitter permission as {$submitterPermission}"); + + return \Illuminate\Support\Str::uuid()->__toString(); + } + + /** + * @copydoc IThenticate::uploadFile() + */ + public function uploadFile($submissionTacId, $fileName, $fileContent) { + error_log("Uploading submission file named {$fileName} for submission UUID {$submissionTacId}"); + return true; + } + + /** + * @copydoc IThenticate::getSubmissionInfo() + */ + public function getSubmissionInfo($submissionUuid) { + + return '{ + "id": "'.$submissionUuid.'", + "owner": "a9c14691-9523-4f44-b5fc-4a673c5d4a35", + "title": "History 101 Final Esssay", + "status": "COMPLETE", + "content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "page_count": 3, + "word_count": 145, + "character_count": 760, + "created_time": "2023-08-30T22:13:41Z", + "capabilities" : [ + "INDEX", + "VIEWER", + "SIMILARITY" + ] + }'; + } + + /** + * @copydoc IThenticate::scheduleSimilarityReportGenerationProcess() + */ + public function scheduleSimilarityReportGenerationProcess($submissionUuid, $settings = []) { + error_log( + sprintf( + 'Scheduled similarity report generation process for submission UUID %s with similarity config %s', + $submissionUuid, + print_r($settings, true) + ) + ); + return true; + } + + /** + * @copydoc IThenticate::getSimilarityResult() + */ + public function getSimilarityResult($submissionUuid) { + error_log("Similarity report result retrived for iThenticate submission id : {$submissionUuid}"); + return '{ + "submission_id": "'.$submissionUuid.'", + "overall_match_percentage": 15, + "internet_match_percentage": 12, + "publication_match_percentage": 10, + "submitted_works_match_percentage": 0, + "status": "COMPLETE", + "time_requested": "2017-11-06T19:14:31.828Z", + "time_generated": "2017-11-06T19:14:45.993Z", + "top_source_largest_matched_word_count": 193, + "top_matches": [] + }'; + } + + /** + * @copydoc IThenticate::createViewerLaunchUrl() + */ + public function createViewerLaunchUrl($submissionUuid, $user, $locale, $viewerPermission, $allowUpdateInViewer) { + error_log("Similarity report viewer launch url generated for iThenticate submission id : {$submissionUuid} with locale : {$locale}, viewer permission : {$viewerPermission} and update viewer permission : {$allowUpdateInViewer}"); + return Application::get()->getRequest()->getBaseUrl(); + } + + /** + * @copydoc IThenticate::verifyUserEulaAcceptance() + */ + public function verifyUserEulaAcceptance($user, $version) { + error_log("Verifying if user with id {$user->getId()} has already confirmed the given EULA version {$version}"); + return true; + } + + /** + * @copydoc IThenticate::validateEulaVersion() + */ + public function validateEulaVersion($version) { + error_log("Validating/Retrieving the given EULA version {$version}"); + return true; + } + + /** + * @copydoc IThenticate::registerWebhook() + */ + public function registerWebhook($signingSecret, $url, $events = self::DEFAULT_WEBHOOK_EVENTS) { + error_log( + sprintf( + "Register webhook end point with singing secret : %s, url : %s and events : [%s]", + $signingSecret, + $url, + implode(', ',$events) + ) + ); + return \Illuminate\Support\Str::uuid()->__toString(); + } + + /** + * @copydoc IThenticate::deleteWebhook() + */ + public function deleteWebhook($webhookId) { + error_log("ithenticate webhook with id : {$webhookId} removed"); + return true; + } + + /** + * @copydoc IThenticate::getEulaDetails() + */ + public function getEulaDetails() { + return $this->eulaVersionDetails; + } + + /** + * @copydoc IThenticate::getApplicableEulaVersion() + */ + public function getApplicableEulaVersion() { + return $this->eulaVersion; + } + + /** + * @copydoc IThenticate::setApplicableEulaVersion() + */ + public function setApplicableEulaVersion($version) { + $this->eulaVersion = $version; + + return $this; + } + + /** + * @copydoc IThenticate::getApplicableEulaUrl() + */ + public function getApplicableEulaUrl($locales = null) { + if (!$this->eulaVersion) { + throw new \Exception('No EULA version set yet'); + } + + $applicableEulaLanguage = $this->getApplicableLocale($locales ?? static::DEFAULT_EULA_LANGUAGE); + + $eulaUrl = $this->eulaVersionDetails['url']; + + return str_replace( + strtolower(static::DEFAULT_EULA_LANGUAGE), + strtolower($applicableEulaLanguage), + $eulaUrl + ); + } + + /** + * @copydoc IThenticate::getApplicableLocale() + */ + public function getApplicableLocale($locales, $eulaVersion = null) { + if (!$this->getEulaDetails() && !$this->validateEulaVersion($eulaVersion ?? $this->eulaVersion)) { + return static::DEFAULT_EULA_LANGUAGE; + } + + if (is_string($locales)) { + return $this->getCorrespondingLocaleAvailable($locales) ?? static::DEFAULT_EULA_LANGUAGE; + } + + foreach ($locales as $locale) { + $correspondingLocale = $this->getCorrespondingLocaleAvailable($locale); + if ($correspondingLocale) { + return $correspondingLocale; + } + } + + return static::DEFAULT_EULA_LANGUAGE; + } + + /** + * @copydoc IThenticate::isCorrespondingLocaleAvailable() + */ + protected function getCorrespondingLocaleAvailable($locale) { + $eulaLangs = $this->eulaVersionDetails['available_languages']; + $locale = str_replace("_", "-", substr($locale, 0, 5)); + + return in_array($locale, $eulaLangs) ? $locale : null; + } + + /** + * @copydoc IThenticate::validatePermission() + */ + protected function validatePermission($permission, $permissionSet) { + return in_array($permission, $permissionSet); + } +} diff --git a/assets/logo/ithenticate-badge-rec-positive.png b/assets/logo/ithenticate-badge-rec-positive.png new file mode 100644 index 0000000..027f6cc Binary files /dev/null and b/assets/logo/ithenticate-badge-rec-positive.png differ diff --git a/classes/form/validation/FormValidatorIthenticateAccess.inc.php b/classes/form/validation/FormValidatorIthenticateAccess.inc.php new file mode 100644 index 0000000..9a37a45 --- /dev/null +++ b/classes/form/validation/FormValidatorIthenticateAccess.inc.php @@ -0,0 +1,35 @@ +ithenticate = $ithenticate; + } + + /** + * @copydoc Validator::isValid() + */ + public function isValid($value) { + return $this->ithenticate->validateAccess(); + } +} diff --git a/composer.json b/composer.json deleted file mode 100644 index 37f1a3a..0000000 --- a/composer.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "require": { - "bsobbe/ithenticate": "dev-dependabot/composer/phpxmlrpc/phpxmlrpc-4.9.2", - "phpxmlrpc/phpxmlrpc": "^4.9", - "cweagans/composer-patches": "^1.7" - }, - "extra": { - "patches": { - "phpxmlrpc/phpxmlrpc": { - "Allow other datetime possibility": "lib/phpxmlrpc-datetime.diff" - } - } - }, - "config": { - "allow-plugins": { - "cweagans/composer-patches": true - } - } -} diff --git a/composer.lock b/composer.lock deleted file mode 100644 index e4d8331..0000000 --- a/composer.lock +++ /dev/null @@ -1,177 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "1841d3e30deba0c3223aa19c5a18c7b8", - "packages": [ - { - "name": "bsobbe/ithenticate", - "version": "dev-dependabot/composer/phpxmlrpc/phpxmlrpc-4.9.2", - "source": { - "type": "git", - "url": "https://github.com/bsobbe/iThenticate.git", - "reference": "1266a0435143bc7a627be3a7dda98b690ba6bb47" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/bsobbe/iThenticate/zipball/1266a0435143bc7a627be3a7dda98b690ba6bb47", - "reference": "1266a0435143bc7a627be3a7dda98b690ba6bb47", - "shasum": "" - }, - "require": { - "phpxmlrpc/phpxmlrpc": "4.9.2" - }, - "type": "php-library", - "autoload": { - "psr-4": { - "bsobbe\\ithenticate\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Sobhan Bagheri (A.K.A sobbe)", - "email": "b.sobhanbagheri@gmail.com" - } - ], - "description": "A library to use Ithenticate API easier and faster, to check and prevent plagiarism", - "keywords": [ - "api", - "crossref", - "ithenticate", - "library", - "php", - "plagiarism", - "plagiarism-prevention" - ], - "support": { - "issues": "https://github.com/bsobbe/iThenticate/issues", - "source": "https://github.com/bsobbe/iThenticate/tree/dependabot/composer/phpxmlrpc/phpxmlrpc-4.9.2" - }, - "time": "2023-01-11T23:51:36+00:00" - }, - { - "name": "cweagans/composer-patches", - "version": "1.7.3", - "source": { - "type": "git", - "url": "https://github.com/cweagans/composer-patches.git", - "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/e190d4466fe2b103a55467dfa83fc2fecfcaf2db", - "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.3.0" - }, - "require-dev": { - "composer/composer": "~1.0 || ~2.0", - "phpunit/phpunit": "~4.6" - }, - "type": "composer-plugin", - "extra": { - "class": "cweagans\\Composer\\Patches" - }, - "autoload": { - "psr-4": { - "cweagans\\Composer\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Cameron Eagans", - "email": "me@cweagans.net" - } - ], - "description": "Provides a way to patch Composer packages.", - "support": { - "issues": "https://github.com/cweagans/composer-patches/issues", - "source": "https://github.com/cweagans/composer-patches/tree/1.7.3" - }, - "time": "2022-12-20T22:53:13+00:00" - }, - { - "name": "phpxmlrpc/phpxmlrpc", - "version": "4.9.2", - "source": { - "type": "git", - "url": "https://github.com/gggeek/phpxmlrpc.git", - "reference": "805d727670b7a7c08c9f129327482f13a4c1ea11" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/gggeek/phpxmlrpc/zipball/805d727670b7a7c08c9f129327482f13a4c1ea11", - "reference": "805d727670b7a7c08c9f129327482f13a4c1ea11", - "shasum": "" - }, - "require": { - "ext-xml": "*", - "php": "^5.3.0 || ^7.0 || ^8.0" - }, - "conflict": { - "phpxmlrpc/extras": "<= 0.6.3" - }, - "require-dev": { - "ext-curl": "*", - "ext-dom": "*", - "ext-mbstring": "*", - "phpunit/phpunit": "^4.8 || ^5.0 || ^8.5.14", - "phpunit/phpunit-selenium": "*", - "yoast/phpunit-polyfills": "*" - }, - "suggest": { - "ext-curl": "Needed for HTTPS and HTTP 1.1 support, NTLM Auth etc...", - "ext-mbstring": "Needed to allow reception of requests/responses in character sets other than ASCII,LATIN-1,UTF-8", - "ext-zlib": "Needed for sending compressed requests and receiving compressed responses, if cURL is not available", - "phpxmlrpc/extras": "Adds more featured Server classes and other useful bits", - "phpxmlrpc/jsonrpc": "Adds support for the JSON-RPC protocol" - }, - "type": "library", - "autoload": { - "psr-4": { - "PhpXmlRpc\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "A php library for building xmlrpc clients and servers", - "homepage": "https://gggeek.github.io/phpxmlrpc/", - "keywords": [ - "webservices", - "xml-rpc", - "xmlrpc" - ], - "support": { - "issues": "https://github.com/gggeek/phpxmlrpc/issues", - "source": "https://github.com/gggeek/phpxmlrpc/tree/4.9.2" - }, - "time": "2022-12-18T21:47:48+00:00" - } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": { - "bsobbe/ithenticate": 20 - }, - "prefer-stable": false, - "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.3.0" -} diff --git a/controllers/PlagiarismArticleGalleyGridHandler.inc.php b/controllers/PlagiarismArticleGalleyGridHandler.inc.php new file mode 100644 index 0000000..d3eb3f0 --- /dev/null +++ b/controllers/PlagiarismArticleGalleyGridHandler.inc.php @@ -0,0 +1,55 @@ +import('grids.SimilarityActionGridColumn'); + $this->addColumn(new SimilarityActionGridColumn(static::$_plugin)); + } +} diff --git a/controllers/PlagiarismComponentHandler.inc.php b/controllers/PlagiarismComponentHandler.inc.php new file mode 100644 index 0000000..6559a49 --- /dev/null +++ b/controllers/PlagiarismComponentHandler.inc.php @@ -0,0 +1,57 @@ +addPolicy(new UserRequiredPolicy($request)); + $this->addPolicy(new UserRolesRequiredPolicy($request)); + + return parent::authorize($request, $args, $roleAssignments); + } +} diff --git a/controllers/PlagiarismEulaAcceptanceHandler.inc.php b/controllers/PlagiarismEulaAcceptanceHandler.inc.php new file mode 100644 index 0000000..2c33028 --- /dev/null +++ b/controllers/PlagiarismEulaAcceptanceHandler.inc.php @@ -0,0 +1,102 @@ +addRoleAssignment( + [ROLE_ID_AUTHOR, ROLE_ID_SITE_ADMIN, ROLE_ID_MANAGER, ROLE_ID_SUB_EDITOR], + ['handle'] + ); + } + + /** + * @copydoc PKPHandler::authorize() + */ + public function authorize($request, &$args, $roleAssignments) { + $this->addPolicy(new SubmissionAccessPolicy($request, $args, $roleAssignments, 'submissionId')); + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Handle the presentation of iThenticate EULA right before the submission final stage + * + * @param array $args + * @param Request $request + * + * @return void + */ + public function handle($args, $request) { + $request = Application::get()->getRequest(); + $context = $request->getContext(); + $user = $request->getUser(); + $submission = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION); /** @var Submission $submission */ + $confirmSubmissionEula = $request->getUserVar('confirmSubmissionEula') ?? false; + + if (!$confirmSubmissionEula) { + SessionManager::getManager()->getUserSession()->setSessionVar('confirmSubmissionEulaError', true); + return $this->redirectToUrl( + $request, + $context, + ['submissionId' => $submission->getId()] + ); + } + + SessionManager::getManager()->getUserSession()->unsetSessionVar('confirmSubmissionEulaError'); + + static::$_plugin->stampEulaToSubmission($context, $submission); + static::$_plugin->stampEulaToSubmittingUser($context, $submission, $user); + + return $this->redirectToUrl( + $request, + $context, + ['submissionId' => $submission->getId()] + ); + } + + /** + * Generate and get the redirection url + * + * @param Request $request + * @param Context $context + * @param array $args + * + * @return string + */ + protected function redirectToUrl($request, $context, $args) { + + return $request->redirectUrl( + $request->getDispatcher()->url( + $request, + ROUTE_PAGE, + $context->getData('urlPath'), + 'submission', + 'wizard', + 4, + $args + ) + ); + } + +} diff --git a/controllers/PlagiarismIthenticateActionHandler.inc.php b/controllers/PlagiarismIthenticateActionHandler.inc.php new file mode 100644 index 0000000..82d61ff --- /dev/null +++ b/controllers/PlagiarismIthenticateActionHandler.inc.php @@ -0,0 +1,479 @@ +addRoleAssignment( + [ + ROLE_ID_MANAGER, + ROLE_ID_SUB_EDITOR, + ROLE_ID_ASSISTANT, + ROLE_ID_SITE_ADMIN + ], + [ + 'launchViewer', + 'scheduleSimilarityReport', + 'refreshSimilarityResult', + 'submitSubmission', + 'acceptEulaAndExecuteIntendedAction', + 'confirmEula', + ] + ); + } + + /** + * @copydoc PlagiarismComponentHandler::authorize() + */ + public function authorize($request, &$args, $roleAssignments) { + $this->markRoleAssignmentsChecked(); + + import('lib.pkp.classes.security.authorization.SubmissionFileAccessPolicy'); + $this->addPolicy(new SubmissionFileAccessPolicy($request, $args, $roleAssignments, SUBMISSION_FILE_ACCESS_READ, (int) $args['submissionFileId'])); + + return parent::authorize($request, $args, $roleAssignments); + } + + /** + * Launch the iThenticate similarity report viewer + * + * @param array $args + * @param Request $request + */ + public function launchViewer($args, $request) { + $context = $request->getContext(); + $user = $request->getUser(); + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); /** @var SubmissionFile $submissionFile */ + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submission = $submissionDao->getById($submissionFile->getData('submissionId')); + $siteDao = DAORegistry::getDAO("SiteDAO"); /** @var SiteDAO $siteDao */ + $site = $siteDao->getSite(); + + /** @var IThenticate $ithenticate */ + $ithenticate = static::$_plugin->initIthenticate( + ...static::$_plugin->getServiceAccess($context) + ); + + // If EULA is required and submission has EULA stamped, we set the applicable EULA + // Otherwise get the current EULA from default one and set the applicable + // Basically we need to retrieve the available langs details from EULA details + static::$_plugin->getContextEulaDetails($context, 'require_eula') == true && + $submission->getData('ithenticateEulaVersion') + ? $ithenticate->setApplicableEulaVersion($submission->getData('ithenticateEulaVersion')) + : $ithenticate->validateEulaVersion($ithenticate::DEFAULT_EULA_VERSION); + + $locale = $ithenticate + ->getApplicableLocale( + collect([$submission->getData("locale")]) + ->merge(Arr::wrap($user->getData("locales"))) + ->merge([$context->getPrimaryLocale(), $site->getPrimaryLocale()]) + ->unique() + ->filter() + ->toArray() + ); + + $viewerUrl = $ithenticate->createViewerLaunchUrl( + $submissionFile->getData('ithenticateId'), + $user, + $locale, + static::$_plugin->getSubmitterPermission($context, $user), + (bool)static::$_plugin->getSimilarityConfigSettings($context, 'allowViewerUpdate') + ); + + if (!$viewerUrl) { + return $request->redirect( + null, + 'user', + 'authorizationDenied', + null, + ['message' => 'plugins.generic.plagiarism.action.launchViewer.error'] + ); + } + + return $request->redirectUrl($viewerUrl); + } + + /** + * Schedule the similarity report generate process at iThenticate services's end + * + * @param array $args + * @param Request $request + */ + public function scheduleSimilarityReport($args, $request) { + + $context = $request->getContext(); + + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); /** @var SubmissionFile $submissionFile */ + + /** @var IThenticate $ithenticate */ + $ithenticate = static::$_plugin->initIthenticate( + ...static::$_plugin->getServiceAccess($context) + ); + + // If no confirmation of submission file completed the processing at iThenticate service'e end + // we first need to check it's processing status to see has set to `COMPLETED` + // see more at https://developers.turnitin.com/turnitin-core-api/best-practice/retry-polling + if (!$submissionFile->getData('ithenticateSubmissionAcceptedAt')) { + $submissionInfo = $ithenticate->getSubmissionInfo($submissionFile->getData('ithenticateId')); + + // submission info not available to schedule report generation process + if (!$submissionInfo) { + $this->generateUserNotification( + $request, + NOTIFICATION_TYPE_ERROR, + __('plugins.generic.plagiarism.webhook.similarity.schedule.error', [ + 'submissionFileId' => $submissionFile->getId(), + 'error' => __('plugins.generic.plagiarism.submission.status.unavailable'), + ]) + ); + return $this->triggerDataChangedEvent($submissionFile); + } + + $submissionInfo = json_decode($submissionInfo); + + // submission has not completed yet to schedule report generation process + if ($submissionInfo->status !== 'COMPLETE') { + $similaritySchedulingError = ''; + + switch($submissionInfo->status) { + case 'CREATED' : + $similaritySchedulingError = __('plugins.generic.plagiarism.submission.status.CREATED'); + break; + case 'PROCESSING' : + $similaritySchedulingError = __('plugins.generic.plagiarism.submission.status.PROCESSING'); + break; + case 'ERROR' : + $similaritySchedulingError = property_exists($submissionInfo, 'error_code') + ? __("plugins.generic.plagiarism.ithenticate.submission.error.{$submissionInfo->error_code}") + : __('plugins.generic.plagiarism.submission.status.ERROR'); + break; + } + + $this->generateUserNotification( + $request, + NOTIFICATION_TYPE_ERROR, + __('plugins.generic.plagiarism.webhook.similarity.schedule.error', [ + 'submissionFileId' => $submissionFile->getId(), + 'error' => $similaritySchedulingError, + ]) + ); + + return $this->triggerDataChangedEvent($submissionFile); + } + + $submissionFile->setData('ithenticateSubmissionAcceptedAt', Core::getCurrentDate()); + $submissionFileDao->updateObject($submissionFile); + } + + $scheduleSimilarityReport = $ithenticate->scheduleSimilarityReportGenerationProcess( + $submissionFile->getData('ithenticateId'), + static::$_plugin->getSimilarityConfigSettings($context) + ); + + if (!$scheduleSimilarityReport) { + $message = __('plugins.generic.plagiarism.webhook.similarity.schedule.failure', [ + 'submissionFileId' => $submissionFile->getId(), + ]); + $this->generateUserNotification($request, NOTIFICATION_TYPE_ERROR, $message); + return $this->triggerDataChangedEvent($submissionFile); + } + + $submissionFile->setData('ithenticateSimilarityScheduled', 1); + $submissionFileDao->updateObject($submissionFile); + + $this->generateUserNotification( + $request, + NOTIFICATION_TYPE_SUCCESS, + __('plugins.generic.plagiarism.action.scheduleSimilarityReport.success') + ); + + return $this->triggerDataChangedEvent($submissionFile); + } + + /** + * Refresh the submission's similarity score result + * + * @param array $args + * @param Request $request + */ + public function refreshSimilarityResult($args, $request) { + $context = $request->getContext(); + + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); /** @var SubmissionFile $submissionFile */ + + /** @var IThenticate $ithenticate */ + $ithenticate = static::$_plugin->initIthenticate( + ...static::$_plugin->getServiceAccess($context) + ); + + $similarityScoreResult = $ithenticate->getSimilarityResult( + $submissionFile->getData('ithenticateId') + ); + + if (!$similarityScoreResult) { + $message = __('plugins.generic.plagiarism.action.refreshSimilarityResult.error', [ + 'submissionFileId' => $submissionFile->getId(), + ]); + $this->generateUserNotification($request, NOTIFICATION_TYPE_ERROR, $message); + return $this->triggerDataChangedEvent($submissionFile); + } + + $similarityScoreResult = json_decode($similarityScoreResult); + + if ($similarityScoreResult->status !== 'COMPLETE') { + $message = __('plugins.generic.plagiarism.action.refreshSimilarityResult.warning', [ + 'submissionFileId' => $submissionFile->getId(), + ]); + $this->generateUserNotification($request, NOTIFICATION_TYPE_WARNING, $message); + return $this->triggerDataChangedEvent($submissionFile); + } + + $submissionFile->setData('ithenticateSimilarityResult', json_encode($similarityScoreResult)); + $submissionFileDao->updateObject($submissionFile); + + $this->generateUserNotification( + $request, + NOTIFICATION_TYPE_SUCCESS, + __('plugins.generic.plagiarism.action.refreshSimilarityResult.success') + ); + + return $this->triggerDataChangedEvent($submissionFile); + } + + /** + * Upload the submission file and create a new submission at iThenticate service's end + * + * @param array $args + * @param Request $request + */ + public function submitSubmission($args, $request) { + $context = $request->getContext(); + $user = $request->getUser(); + + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); /** @var SubmissionFile $submissionFile */ + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submission = $submissionDao->getById($submissionFile->getData('submissionId')); /** @var Submission $submission*/ + + /** @var IThenticate $ithenticate */ + $ithenticate = static::$_plugin->initIthenticate( + ...static::$_plugin->getServiceAccess($context) + ); + + // If no webhook previously registered for this Context, register it + if (!$context->getData('ithenticateWebhookId')) { + static::$_plugin->registerIthenticateWebhook($ithenticate, $context); + } + + // As the submission has been already and should be stamped with an EULA at the + // confirmation stage, need to set it + if ($submission->getData('ithenticateEulaVersion')) { + $ithenticate->setApplicableEulaVersion($submission->getData('ithenticateEulaVersion')); + } + + if (!static::$_plugin->createNewSubmission($request, $user, $submission, $submissionFile, $ithenticate)) { + $this->generateUserNotification( + $request, + NOTIFICATION_TYPE_ERROR, + __('plugins.generic.plagiarism.action.submitSubmission.error') + ); + return $this->triggerDataChangedEvent($submissionFile); + } + + $this->generateUserNotification( + $request, + NOTIFICATION_TYPE_SUCCESS, + __('plugins.generic.plagiarism.action.submitSubmission.success') + ); + + return $this->triggerDataChangedEvent($submissionFile); + } + + /** + * Accept the EULA, stamp it to proper entity (Submission/User or both) and upload + * submission file + * + * @param array $args + * @param Request $request + */ + public function acceptEulaAndExecuteIntendedAction($args, $request) { + $context = $request->getContext(); + $user = $request->getUser(); + + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); /** @var SubmissionFile $submissionFile */ + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submission = $submissionDao->getById($submissionFile->getData('submissionId')); + + $confirmSubmissionEula = $args['confirmSubmissionEula'] ?? false; + + if (!$confirmSubmissionEula) { + + $templateManager = $this->getEulaConfirmationTemplate( + $request, + $args, + $context, + $submission, + $submissionFile + ); + + SessionManager::getManager()->getUserSession()->setSessionVar('confirmSubmissionEulaError', true); + + return new JSONMessage( + true, + $templateManager->fetch(static::$_plugin->getTemplateResource('confirmEula.tpl')) + ); + } + + if (!$submission->getData('ithenticateEulaVersion')) { + static::$_plugin->stampEulaToSubmission($context, $submission); + } + + if (!$user->getData('ithenticateEulaVersion')) { + static::$_plugin->stampEulaToSubmittingUser($context, $submission, $user); + } + + return $this->submitSubmission($args, $request); + } + + /** + * Show the EULA confirmation modal before the uploading submission file to iThenticate + * + * @param array $args + * @param Request $request + */ + public function confirmEula($args, $request) { + $context = $request->getContext(); + + $submissionFile = $this->getAuthorizedContextObject(ASSOC_TYPE_SUBMISSION_FILE); /** @var SubmissionFile $submissionFile */ + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submission = $submissionDao->getById($submissionFile->getData('submissionId')); + + $templateManager = $this->getEulaConfirmationTemplate( + $request, + $args, + $context, + $submission, + $submissionFile + ); + + return new JSONMessage( + true, + $templateManager->fetch(static::$_plugin->getTemplateResource('confirmEula.tpl')) + ); + } + + /** + * Get the template manager to handle the EULA confirmation as the before action of + * intended action. + * + * @param Request $request + * @param array $args + * @param Context $context + * @param Submission $submission + * @param SubmissionFile $submissionFile + * + * @return TemplateManager + */ + protected function getEulaConfirmationTemplate($request, $args, $context, $submission, $submissionFile) { + + $eulaVersionDetails = $submission->getData('ithenticateEulaVersion') + ? [ + 'version' => $submission->getData('ithenticateEulaVersion'), + 'url' => $submission->getData('ithenticateEulaUrl') + ] : static::$_plugin->getContextEulaDetails($context, [ + $submission->getData('locale'), + $request->getSite()->getPrimaryLocale(), + IThenticate::DEFAULT_EULA_LANGUAGE + ]); + + $actionUrl = $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + 'acceptEulaAndExecuteIntendedAction', + null, + [ + 'version' => $eulaVersionDetails['version'], + 'submissionFileId' => $submissionFile->getId(), + 'stageId' => $request->getUserVar('stageId'), + ] + ); + + $templateManager = TemplateManager::getManager(); + $templateManager->assign([ + 'submissionId' => $submission->getId(), + 'actionUrl' => $actionUrl, + 'eulaAcceptanceMessage' => __('plugins.generic.plagiarism.submission.eula.acceptance.message', [ + 'localizedEulaUrl' => $eulaVersionDetails['url'], + ]), + ]); + + return $templateManager; + } + + /** + * Generate the user friendly notification upon a response received for an action + * + * @param Request $request + * @param int $notificationType + * @param string $notificationContent + * + * @return void + */ + protected function generateUserNotification($request, $notificationType, $notificationContent) { + $notificationMgr = new NotificationManager(); + $notificationMgr->createTrivialNotification( + $request->getUser()->getId(), + $notificationType, + ['contents' => $notificationContent] + ); + } + + /** + * Trigger the data change event to refresh the grid view + * + * @param SubmissionFile $submissionFile + * @return JSONMessage + */ + protected function triggerDataChangedEvent($submissionFile) { + if (static::$_plugin::isOPS()) { + $articleGalleyDao = DAORegistry::getDAO('ArticleGalleyDAO'); /** @var ArticleGalleyDAO $articleGalleyDao */ + $daoResultFactory = $articleGalleyDao->getByFileId($submissionFile->getId()); /** @var DAOResultFactory $daoResultFactory */ + $articleGalley = $daoResultFactory->next(); /** @var ArticleGalley $articleGalley */ + + if ($articleGalley) { + return DAO::getDataChangedEvent($articleGalley->getId()); + } + } + + return DAO::getDataChangedEvent($submissionFile->getId()); + } + +} diff --git a/controllers/PlagiarismWebhookHandler.inc.php b/controllers/PlagiarismWebhookHandler.inc.php new file mode 100644 index 0000000..7817a2a --- /dev/null +++ b/controllers/PlagiarismWebhookHandler.inc.php @@ -0,0 +1,238 @@ +getRequest(); + $context = $request->getContext(); + $headers = collect(array_change_key_case(getallheaders(), CASE_LOWER)); + $payload = file_get_contents('php://input'); + + if (!$context->getData('ithenticateWebhookId') || !$context->getData('ithenticateWebhookSigningSecret')) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.configuration.missing', [ + 'contextId' => $context->getId(), + ])); + return; + } + + if (!$headers->has(['x-turnitin-eventtype', 'x-turnitin-signature'])) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.headers.missing')); + return; + } + + if (!in_array($headers->get('x-turnitin-eventtype'), IThenticate::DEFAULT_WEBHOOK_EVENTS)) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.event.invalid', [ + 'event' => $headers->get('x-turnitin-eventtype'), + ])); + return; + } + + if ($headers->get('x-turnitin-signature') !== hash_hmac("sha256", $payload, $context->getData('ithenticateWebhookSigningSecret'))) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.signature.invalid')); + return; + } + + switch($headers->get('x-turnitin-eventtype')) { + case 'SUBMISSION_COMPLETE' : + $this->handleSubmissionCompleteEvent($context, $payload, $headers->get('x-turnitin-eventtype')); + break; + case 'SIMILARITY_COMPLETE' : + case 'SIMILARITY_UPDATED' : + $this->storeSimilarityScore($context, $payload, $headers->get('x-turnitin-eventtype')); + break; + default: + error_log("Handling the iThenticate webhook event {$headers->get('x-turnitin-eventtype')} is not implemented yet"); + } + } + + /** + * Initiate the iThenticate similarity report generation process for given + * iThenticate submission id at receiving webhook event `SUBMISSION_COMPLETE` + * + * @param Context $context The current context for which the webhook request has initiated + * @param string $payload The incoming request payload through webhook + * @param string $event The incoming webhook request event + * + * @return void + */ + protected function handleSubmissionCompleteEvent($context, $payload, $event) { + $payload = json_decode($payload); + + $ithenticateSubmission = $this->getIthenticateSubmission($payload->id); + + if (!$ithenticateSubmission) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.submissionId.invalid', [ + 'submissionUuid' => $payload->id, + 'event' => $event, + ])); + return; + } + + /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); + $submissionFile = $submissionFileDao->getById($ithenticateSubmission->submission_file_id); + + if (!$this->verifySubmissionFileAssociationWithContext($context, $submissionFile)) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.submissionFileAssociationWithContext.invalid', [ + 'submissionFileId' => $submissionFile->getId(), + 'contextId' => $context->getId(), + ])); + return; + } + + if ($payload->status !== 'COMPLETE') { + // If the status not `COMPLETE`, then it's `ERROR` + static::$_plugin->sendErrorMessage( + __('plugins.generic.plagiarism.webhook.similarity.schedule.error', [ + 'submissionFileId' => $submissionFile->getId(), + 'error' => __("plugins.generic.plagiarism.ithenticate.submission.error.{$payload->error_code}"), + ]), + $submissionFile->getData('submissionId') + ); + return; + } + + $submissionFile->setData('ithenticateSubmissionAcceptedAt', Core::getCurrentDate()); + $submissionFileDao->updateObject($submissionFile); + $submissionFile = $submissionFileDao->getById($submissionFile->getId()); + + if ((int)$submissionFile->getData('ithenticateSimilarityScheduled')) { + static::$_plugin->sendErrorMessage( + __('plugins.generic.plagiarism.webhook.similarity.schedule.previously', [ + 'submissionFileId' => $submissionFile->getId(), + ]), + $submissionFile->getData('submissionId') + ); + return; + } + + list($apiUrl, $apiKey) = static::$_plugin->getServiceAccess($context); + $ithenticate = static::$_plugin->initIthenticate($apiUrl, $apiKey); + + $scheduleSimilarityReport = $ithenticate->scheduleSimilarityReportGenerationProcess( + $payload->id, + static::$_plugin->getSimilarityConfigSettings($context) + ); + + if (!$scheduleSimilarityReport) { + static::$_plugin->sendErrorMessage( + __('plugins.generic.plagiarism.webhook.similarity.schedule.failure', [ + 'submissionFileId' => $submissionFile->getId(), + ]), + $submissionFile->getData('submissionId') + ); + return; + } + + $submissionFile->setData('ithenticateSimilarityScheduled', 1); + $submissionFileDao->updateObject($submissionFile); + } + + /** + * Store or Update the result of similarity check for a submission file at receiving + * the webook event `SIMILARITY_COMPLETE` or `SIMILARITY_UPDATED` + * + * @param Context $context The current context for which the webhook request has initiated + * @param string $payload The incoming request payload through webhook + * @param string $event The incoming webhook request event + * + * @return void + */ + protected function storeSimilarityScore($context, $payload, $event) { + $payload = json_decode($payload); + + // we will not store similarity check result unless it has completed + if ($payload->status !== 'COMPLETE') { + return; + } + + $ithenticateSubmission = $this->getIthenticateSubmission($payload->submission_id); + + if (!$ithenticateSubmission) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.submissionId.invalid', [ + 'submissionUuid' => $payload->submission_id, + 'event' => $event, + ])); + return; + } + + /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); + $submissionFile = $submissionFileDao->getById($ithenticateSubmission->submission_file_id); + + if (!$this->verifySubmissionFileAssociationWithContext($context, $submissionFile)) { + static::$_plugin->sendErrorMessage(__('plugins.generic.plagiarism.webhook.submissionFileAssociationWithContext.invalid', [ + 'submissionFileId' => $submissionFile->getId(), + 'contextId' => $context->getId(), + ])); + return; + } + + $submissionFile->setData('ithenticateSimilarityResult', json_encode($payload)); + $submissionFileDao->updateObject($submissionFile); + } + + /** + * Verify if the given submission file is associated with current running/set context + * + * @param Context $context + * @param SubmissionFile $submissionFile + * + * @return bool + */ + protected function verifySubmissionFileAssociationWithContext($context, $submissionFile) { + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submission = $submissionDao->getById($submissionFile->getData('submissionId')); + + return (int) $submission->getData('contextId') === (int) $context->getId(); + } + + /** + * Get the row data as object from submission file settings table or null if none found + * + * @param string $id The given iThenticate submission id in UUID format + * @return object|null + */ + private function getIthenticateSubmission($id) { + + /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); + + return Capsule::table($submissionFileDao->settingsTableName) + ->where('setting_name', 'ithenticateId') + ->where('setting_value', $id) + ->first(); + } +} diff --git a/exclusions.txt b/exclusions.txt deleted file mode 100644 index 805d965..0000000 --- a/exclusions.txt +++ /dev/null @@ -1,10 +0,0 @@ -plagiarism/exclusions.txt -plagiarism/vendor/phpxmlrpc/phpxmlrpc/demo -plagiarism/vendor/phpxmlrpc/phpxmlrpc/extras -plagiarism/vendor/phpxmlrpc/phpxmlrpc/tests -plagiarism/vendor/phpxmlrpc/phpxmlrpc/doc -plagiarism/vendor/phpxmlrpc/phpxmlrpc/pakefile.php -plagiarism/vendor/phpxmlrpc/phpxmlrpc/debugger -plagiarism/vendor/phpxmlrpc/phpxmlrpc/.gitignore -plagiarism/vendor/phpxmlrpc/phpxmlrpc/.travis.yml -plagiarism/vendor/bsobbe/ithenticate/.git diff --git a/grids/RearrangeColumnsFeature.inc.php b/grids/RearrangeColumnsFeature.inc.php new file mode 100644 index 0000000..b2e6318 --- /dev/null +++ b/grids/RearrangeColumnsFeature.inc.php @@ -0,0 +1,59 @@ +gridHandler = $gridHandler; + parent::__construct('rearrangeColumns'); + } + + /** + * @see GridFeature::getGridDataElements() + */ + public function getGridDataElements($args) { + if (!reset($this->gridHandler->_columns) instanceof SimilarityActionGridColumn) { + return; + } + + // The plagiarism report is the first column. Move it to the end. + $plagiarismColumn = array_shift($this->gridHandler->_columns); /** @var SimilarityActionGridColumn $plagiarismColumn */ + $plagiarismColumn->addFlag('firstColumn', false); + + // push the plagiarism score/action column as the second column + $afterKey = array_key_first($this->gridHandler->_columns); + $index = array_search($afterKey, array_keys( $this->gridHandler->_columns )); + $this->gridHandler->_columns = array_slice($this->gridHandler->_columns, 0, $index + 1) + + [SimilarityActionGridColumn::SIMILARITY_ACTION_GRID_COLUMN_ID => $plagiarismColumn] + + $this->gridHandler->_columns; + + // set the first file name column as first column + reset($this->gridHandler->_columns)->addFlag('firstColumn', true); + } +} diff --git a/grids/SimilarityActionGridColumn.inc.php b/grids/SimilarityActionGridColumn.inc.php new file mode 100644 index 0000000..c2756f9 --- /dev/null +++ b/grids/SimilarityActionGridColumn.inc.php @@ -0,0 +1,354 @@ +_plugin = $plugin; + + $cellProvider = new ColumnBasedGridCellProvider(); + + parent::__construct( + self::SIMILARITY_ACTION_GRID_COLUMN_ID, + 'plugins.generic.plagiarism.similarity.action.column.score.title', + null, + null, + $cellProvider, + ['width' => 30, 'alignment' => COLUMN_ALIGNMENT_LEFT, 'anyhtml' => true] + ); + } + + /** + * Method expected by ColumnBasedGridCellProvider to render a cell in this column. + * + * @copydoc ColumnBasedGridCellProvider::getTemplateVarsFromRowColumn() + */ + public function getTemplateVarsFromRow($row) { + $request = Application::get()->getRequest(); + + if ($this->_plugin::isOPS()) { // For OPS + $articleGalley = $row->getData(); /** @var ArticleGalley $articleGalley */ + + if (!$articleGalley->getData('submissionFileId')) { + return ['label' => '']; + } + + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFile = $submissionFileDao->getById($articleGalley->getData('submissionFileId')); /** @var SubmissionFile $submissionFile */ + + $articleGalleyDao = DAORegistry::getDAO('ArticleGalleyDAO'); /** @var ArticleGalleyDAO $articleGalleyDao */ + $articleGalley = $articleGalleyDao->getByFileId($submissionFile->getId()); /** @var ArticleGalley $articleGalley */ + } else { + $submissionFileData = $row->getData(); + $submissionFile = $submissionFileData['submissionFile']; /** @var SubmissionFile $submissionFile */ + } + + assert($submissionFile instanceof SubmissionFile); + + // Not going to allow plagiarism action on a zip file + if ($this->isSubmissionFileTypeRestricted($submissionFile)) { + return ['label' => __('plugins.generic.plagiarism.similarity.action.invalidFileType')]; + } + + // submission similarity score is available + if ($submissionFile->getData('ithenticateSimilarityScheduled') == true && + $submissionFile->getData('ithenticateSimilarityResult')) { + + $similarityResult = json_decode( + $submissionFile->getData('ithenticateSimilarityResult'), + ); + + $templateManager = TemplateManager::getManager(); + $templateManager->assign([ + 'logoUrl' => $this->_plugin->getIThenticateLogoUrl(), + 'score' => $similarityResult->overall_match_percentage, + 'viewerUrl' => $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $request->getContext()->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + 'launchViewer', + null, + [ + 'stageId' => $this->getStageId($request), + 'submissionId' => $submissionFile->getData('submissionId'), + 'submissionFileId' => $submissionFile->getId(), + ] + ) + ]); + + return [ + 'label' => $templateManager->fetch( + $this->_plugin->getTemplateResource('similarityScore.tpl') + ) + ]; + } + + return ['label' => '']; + } + + /** + * @copydoc GridColumn::getCellActions() + */ + public function getCellActions($request, $row, $position = GRID_ACTION_POSITION_DEFAULT) { + $cellActions = parent::getCellActions($request, $row, $position); + $request = Application::get()->getRequest(); + $context = $request->getContext(); + $user = $request->getUser(); + + // User can not perform any of following actions if not a Journal Manager or Editor + // - Upload file for plagiarism check if failed + // - Schedule similarity report generation if not scheduled already + // - Refresh the similarity report scores + // - Launch similarity report viewer + if (!$user->hasRole([ROLE_ID_MANAGER, ROLE_ID_SUB_EDITOR], $context->getId())) { + return $cellActions; + } + + if ($this->_plugin::isOPS()) { // For OPS + $articleGalley = $row->getData(); /** @var ArticleGalley $articleGalley */ + + if (!$articleGalley->getData('submissionFileId')) { + return $cellActions; + } + + $submissionFileDao = DAORegistry::getDAO('SubmissionFileDAO'); /** @var SubmissionFileDAO $submissionFileDao */ + $submissionFile = $submissionFileDao->getById($articleGalley->getData('submissionFileId')); /** @var SubmissionFile $submissionFile */ + } else { + $submissionFileData = $row->getData(); + $submissionFile = $submissionFileData['submissionFile']; /** @var SubmissionFile $submissionFile */ + } + + // Not going to allow plagiarism action on a zip file + if ($this->isSubmissionFileTypeRestricted($submissionFile)) { + return $cellActions; + } + + $submissionDao = DAORegistry::getDAO('SubmissionDAO'); /** @var SubmissionDAO $submissionDao */ + $submission = $submissionDao->getById($submissionFile->getData('submissionId')); + + // There was an error and submission not completed, + // Ask for confirmation and try to complete the submission process + if (!$submissionFile->getData('ithenticateId')) { + + // first check if curernt user has already EULA confirmed that is associated with submission + // If not confirmed, need to confirm EULA first before uploading submission to iThenticate + + if ($this->isEulaConfirmationRequired($context, $submission, $user)) { + + $cellActions[] = new LinkAction( + "plagiarism-eula-confirmation-{$submissionFile->getId()}", + new AjaxModal( + $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + 'confirmEula', + null, + [ + 'stageId' => $this->getStageId($request), + 'submissionId' => $submission->getId(), + 'submissionFileId' => $submissionFile->getId(), + ] + ), + __('plugins.generic.plagiarism.similarity.action.confirmEula.title') + ), + __('plugins.generic.plagiarism.similarity.action.submitforPlagiarismCheck.title') + ); + + return $cellActions; + } + + $cellActions[] = new LinkAction( + "plagiarism-submission-submit-{$submissionFile->getId()}", + new RemoteActionConfirmationModal( + $request->getSession(), + __('plugins.generic.plagiarism.similarity.action.submitforPlagiarismCheck.confirmation'), + __('plugins.generic.plagiarism.similarity.action.submitforPlagiarismCheck.title'), + $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + 'submitSubmission', + null, + [ + 'stageId' => $this->getStageId($request), + 'submissionId' => $submission->getId(), + 'submissionFileId' => $submissionFile->getId(), + ] + ) + ), + __('plugins.generic.plagiarism.similarity.action.submitforPlagiarismCheck.title') + ); + + return $cellActions; + } + + // Submission similarity report generation has not scheduled + if ($submissionFile->getData('ithenticateSimilarityScheduled') == false) { + $cellActions[] = new LinkAction( + "plagiarism-similarity-report-schedule-{$submissionFile->getId()}", + new RemoteActionConfirmationModal( + $request->getSession(), + __('plugins.generic.plagiarism.similarity.action.generateReport.confirmation'), + __('plugins.generic.plagiarism.similarity.action.generateReport.title'), + $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + 'scheduleSimilarityReport', + null, + [ + 'stageId' => $this->getStageId($request), + 'submissionId' => $submission->getId(), + 'submissionFileId' => $submissionFile->getId(), + ] + ) + ), + __('plugins.generic.plagiarism.similarity.action.generateReport.title'), + ); + + return $cellActions; + } + + // Generate the action for similarity score refresh + $similarityResultRefreshAction = new LinkAction( + "plagiarism-similarity-score-refresh-{$submissionFile->getId()}", + new RemoteActionConfirmationModal( + $request->getSession(), + __('plugins.generic.plagiarism.similarity.action.refreshReport.confirmation'), + __('plugins.generic.plagiarism.similarity.action.refreshReport.title'), + $request->getDispatcher()->url( + $request, + ROUTE_COMPONENT, + $context->getData('urlPath'), + 'plugins.generic.plagiarism.controllers.PlagiarismIthenticateActionHandler', + 'refreshSimilarityResult', + null, + [ + 'stageId' => $this->getStageId($request), + 'submissionId' => $submission->getId(), + 'submissionFileId' => $submissionFile->getId(), + ] + ) + ), + __('plugins.generic.plagiarism.similarity.action.refreshReport.title') + ); + + // If similarity score not available + // show as cell action and upon it's available, show it as part of row action + $submissionFile->getData('ithenticateSimilarityResult') + ? $row->addAction($similarityResultRefreshAction) + : array_push($cellActions, $similarityResultRefreshAction); + + return $cellActions; + } + + /** + * Check for the requrement of EULA confirmation + * + * @param Context $context + * @param Submission $submission + * @param User $user + * + * @return bool + */ + protected function isEulaConfirmationRequired($context, $submission, $user) { + + // Check if EULA confirmation required for this tenant + if ($this->_plugin->getContextEulaDetails($context, 'require_eula') == false) { + return false; + } + + // If no EULA is stamped with submission + // means submission never passed through iThenticate process + if (!$submission->getData('ithenticateEulaVersion')) { + return true; + } + + // If no EULA is stamped with submitting user + // means user has never previously interacted with iThenticate process + if (!$user->getData('ithenticateEulaVersion')) { + return true; + } + + // If user and submission EULA do not match + // means users previously agreed upon different EULA + if ($user->getData('ithenticateEulaVersion') !== $submission->getData('ithenticateEulaVersion')) { + return true; + } + + return false; + } + + /** + * Check if submission file type in valid for plagiarism action + * Restricted for ZIP file + * + * @param SubmissionFile $submissionFile + * @return bool + */ + protected function isSubmissionFileTypeRestricted($submissionFile) { + + $pkpFileService = Services::get('file'); /** @var \PKP\Services\PKPFileService $pkpFileService */ + $file = $pkpFileService->get($submissionFile->getData('fileId')); + + return in_array($file->mimetype, $this->_plugin->uploadRestrictedArchiveMimeTypes); + } + + /** + * Get the proper workflow stage id for iThenticate actions + * + * @param Request $request + * @return int + */ + protected function getStageId($request) { + + if ($this->_plugin::isOPS()) { + return WORKFLOW_STAGE_ID_PRODUCTION; + } + + return $request->getUserVar('stageId'); + } + +} diff --git a/images/enable-plugin.png b/images/enable-plugin.png new file mode 100644 index 0000000..f8ca1b1 Binary files /dev/null and b/images/enable-plugin.png differ diff --git a/images/ithenticate-settings.png b/images/ithenticate-settings.png new file mode 100644 index 0000000..eccc993 Binary files /dev/null and b/images/ithenticate-settings.png differ diff --git a/images/similarity-check-settings.png b/images/similarity-check-settings.png new file mode 100644 index 0000000..538720f Binary files /dev/null and b/images/similarity-check-settings.png differ diff --git a/index.php b/index.php index 024f479..d4a8022 100644 --- a/index.php +++ b/index.php @@ -3,8 +3,8 @@ /** * @file index.php * - * Copyright (c) 2003-2021 Simon Fraser University - * Copyright (c) 2003-2021 John Willinsky + * Copyright (c) 2024 Simon Fraser University + * Copyright (c) 2024 John Willinsky * Distributed under the GNU GPL v3. For full terms see the file LICENSE. * * @brief Wrapper for plagiarism checking plugin. diff --git a/ithenticate-report.png b/ithenticate-report.png deleted file mode 100644 index 1442919..0000000 Binary files a/ithenticate-report.png and /dev/null differ diff --git a/ithenticate-settings.png b/ithenticate-settings.png deleted file mode 100644 index 5ba7ddf..0000000 Binary files a/ithenticate-settings.png and /dev/null differ diff --git a/lib/phpxmlrpc-datetime.diff b/lib/phpxmlrpc-datetime.diff deleted file mode 100644 index aca430e..0000000 --- a/lib/phpxmlrpc-datetime.diff +++ /dev/null @@ -1,11 +0,0 @@ ---- src/Helper/XMLParser_patched.php Thu Feb 9 12:12:05 2023 -+++ src/Helper/XMLParser.php Wed Jan 11 11:15:43 2023 -@@ -431,7 +431,7 @@ - if ($name == 'STRING') { - $this->_xh['value'] = $this->_xh['ac']; - } elseif ($name == 'DATETIME.ISO8601') { -- if (!preg_match('/^[0-9]{8}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/', $this->_xh['ac'])) { -+ if (!preg_match('/^[0-9]{8}T[0-9]{2}:[0-9]{2}:[0-9]{2}$/', $this->_xh['ac']) && !preg_match('/^[0-9\-]{10}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/', $this->_xh['ac'])) { - Logger::instance()->errorLog('XML-RPC: ' . __METHOD__ . ': invalid value received in DATETIME: ' . $this->_xh['ac']); - } - $this->_xh['vt'] = Value::$xmlrpcDateTime; diff --git a/locale/en_US/locale.po b/locale/en_US/locale.po index edf9d1f..e724606 100644 --- a/locale/en_US/locale.po +++ b/locale/en_US/locale.po @@ -26,20 +26,222 @@ msgstr "" "Settings for the account used to upload submissions to iThenticate. Contact " "your iThenticate administrator for details." -msgid "plugins.generic.plagiarism.manager.settings.username" -msgstr "iThenticate Usename" +msgid "plugins.generic.plagiarism.manager.settings.apiUrl" +msgstr "iThenticate API URL" -msgid "plugins.generic.plagiarism.manager.settings.password" -msgstr "iThenticate Password" +msgid "plugins.generic.plagiarism.manager.settings.apiKey" +msgstr "iThenticate API key" -msgid "plugins.generic.plagiarism.manager.settings.usernameRequired" -msgstr "iThenticate Usename is required" +msgid "plugins.generic.plagiarism.manager.settings.apiUrlRequired" +msgstr "iThenticate API URL is required" -msgid "plugins.generic.plagiarism.manager.settings.passwordRequired" -msgstr "iThenticate Password is required" +msgid "plugins.generic.plagiarism.manager.settings.disableAutoSubmission" +msgstr "Disable auto upload of submission files to iThenticate at submission time" + +msgid "plugins.generic.plagiarism.manager.settings.disableAutoSubmission.description" +msgstr "If auto upload to iThenticate is disabled, submission files need to be sent manually from submission workflow" + +msgid "plugins.generic.plagiarism.manager.settings.apiUrlInvalid" +msgstr "" +"The specified API URL is not valid. Please double-check the URL and try again. " +"(Hint: Try adding http:// to the beginning of the URL.)" + +msgid "plugins.generic.plagiarism.manager.settings.apiKeyRequired" +msgstr "The iThenticate API key is required" + +msgid "plugins.generic.plagiarism.manager.settings.serviceAccessInvalid" +msgstr "" +"The specified API URL/key is invalid or a connection to iThenticate API service cannot be established." +"Please double-check the API URL/key and try again." msgid "plugins.generic.plagiarism.manager.settings.areForced" msgstr "iThenticate settings were found in config.inc.php and the settings here will not be used." msgid "plugins.generic.plagiarism.errorMessage" msgstr "Upload of submission {$submissionId} to iThenticate failed with error: {$errorMessage}" + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.UNSUPPORTED_FILETYPE" +msgstr "The uploaded filetype is not supported." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.PROCESSING_ERROR" +msgstr "An unspecified error occurred while processing the submissions." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.CANNOT_EXTRACT_TEXT" +msgstr "The submission does not contain text to generate a Similarity Report or the word count of the submission is 0." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.TOO_LITTLE_TEXT" +msgstr "The submission does not have enough text to generate a Similarity Report. A submission must contain at least 20 words." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.TOO_MUCH_TEXT" +msgstr "The submission has too much text to generate a Similarity Report. After extracted text is converted to UTF-8, the submission must contain less than 2MB of text." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.TOO_MANY_PAGES" +msgstr "The submission has too many pages to generate a Similarity Report. A submission cannot contain more than 800 pages." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.FILE_LOCKED" +msgstr "The uploaded file requires a password in order to be opened." + +msgid "plugins.generic.plagiarism.ithenticate.submission.error.CORRUPT_FILE" +msgstr "The uploaded file appears to be corrupt." + +msgid "plugins.generic.plagiarism.general.errorMessage" +msgstr "Request processing error: {$errorMessage}" + +msgid "plugins.generic.plagiarism.submission.checklist.eula" +msgstr "iThenticate EULA link" + +msgid "plugins.generic.plagiarism.submission.eula.acceptance.message" +msgstr "You must read and accept the iThenticate EULA before proceeding to finalise the submission." + +msgid "plugins.generic.plagiarism.submission.eula.acceptance.confirm" +msgstr "I have read the end user license agreement and accept it." + +msgid "plugins.generic.plagiarism.submission.eula.acceptance.error" +msgstr "The EULA must be confirmed before continuing." + +msgid "plugins.generic.plagiarism.submission.eula.accept.button.title" +msgstr "Confirm and Continue" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.title" +msgstr "Submission similarity check settings" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.warning.note" +msgstr "Note that if any of the following settings are set in the configuration file at the global or journal/server/press level, those settings will take precedence over this form." + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.addToIndex" +msgstr "Submissions will be indexed in the accounts repository and will be available for comparison in Similarity Reports by other users within your organization" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeQuotes" +msgstr "Text in quotes of the submission will not count as similar content" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeBibliography" +msgstr "Text in a bibliography section of the submission will not count as similar content" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeAbstract" +msgstr "Text in the abstract section of the submission will not count as similar content" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeMethods" +msgstr "Text in the method section of the submission will not count as similar content" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeCitations" +msgstr "The citations of the submission will be excluded from similarity check" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeSmallMatches.label" +msgstr "Exclude small match count" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeSmallMatches.description" +msgstr "Similarity matches that match less than the specified amount of words will not count as similar content" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.excludeSmallMatches.validation.min" +msgstr "Must be an integer(without any fraction) with minimum value 8" + +msgid "plugins.generic.plagiarism.similarityCheck.settings.field.allowViewerUpdate" +msgstr "Changes made in reports will be saved for the next time the report is viewed" + +msgid "plugins.generic.plagiarism.similarity.action.column.score.title" +msgstr "Plagiarism Score/Action" + +msgid "plugins.generic.plagiarism.similarity.action.launch.viewer.title" +msgstr "Launch Plagiarism Similarity Viewer" + +msgid "plugins.generic.plagiarism.similarity.match.title" +msgstr "Similarity Match" + +msgid "plugins.generic.plagiarism.similarity.score.column.overall_match_percentage" +msgstr "Overall Match Percentage" + +msgid "plugins.generic.plagiarism.similarity.action.generateReport.title" +msgstr "Schedule Plagiarism Report" + +msgid "plugins.generic.plagiarism.similarity.action.generateReport.confirmation" +msgstr "Are you sure you want to generate the Plagiarism report?" + +msgid "plugins.generic.plagiarism.similarity.action.refreshReport.title" +msgstr "Refresh Plagiarism Similarity Score" + +msgid "plugins.generic.plagiarism.similarity.action.refreshReport.confirmation" +msgstr "Are you sure you want to refresh the Plagiarism similarity score?" + +msgid "plugins.generic.plagiarism.similarity.action.submitforPlagiarismCheck.title" +msgstr "Conduct Plagiarism Check" + +msgid "plugins.generic.plagiarism.similarity.action.submitforPlagiarismCheck.confirmation" +msgstr "Are you sure you want to submit this file for plagiarism check?" + +msgid "plugins.generic.plagiarism.similarity.action.confirmEula.title" +msgstr "Plagiarism End User License Agreement Confirmation" + +msgid "plugins.generic.plagiarism.similarity.action.invalidFileType" +msgstr "Plagiarism checking not available" + +msgid "plugins.generic.plagiarism.action.scheduleSimilarityReport.success" +msgstr "Successfully scheduled the iThenticate similarity report generation process." + +msgid "plugins.generic.plagiarism.action.launchViewer.error" +msgstr "Report viewer not currently available, please try again later." + +msgid "plugins.generic.plagiarism.action.refreshSimilarityResult.error" +msgstr "Unable to refresh iThenticate similarity score for submission file id : {$submissionFileId}." + +msgid "plugins.generic.plagiarism.action.refreshSimilarityResult.warning" +msgstr "The iThenticate similarity report has not yet completed for submission file id : {$submissionFileId}." + +msgid "plugins.generic.plagiarism.action.refreshSimilarityResult.success" +msgstr "Successfully refreshed and updated the iThenticate similarity scores." + +msgid "plugins.generic.plagiarism.action.submitSubmission.error" +msgstr "Unable to upload the submission file to iThenticate. Note that submission file size must be at most 100 MB." + +msgid "plugins.generic.plagiarism.action.submitSubmission.success" +msgstr "Successfully uploaded the submission file to iThenticate." + +msgid "plugins.generic.plagiarism.webhook.configuration.missing" +msgstr "iThenticate webhook not configured for context id {$contextId} ." + +msgid "plugins.generic.plagiarism.webhook.headers.missing" +msgstr "Missing required iThenticate webhook headers" + +msgid "plugins.generic.plagiarism.webhook.event.invalid" +msgstr "Invalid iThenticate webhook event type {$event}" + +msgid "plugins.generic.plagiarism.webhook.signature.invalid" +msgstr "Invalid iThenticate webhook signature" + +msgid "plugins.generic.plagiarism.webhook.submissionId.invalid" +msgstr "Invalid iThenticate submission id {$submissionUuid} given for webhook event {$event}" + +msgid "plugins.generic.plagiarism.webhook.submissionFileAssociationWithContext.invalid" +msgstr "The given submission file id : {$submissionFileId} does not exists for context id : {$contextId}" + +msgid "plugins.generic.plagiarism.webhook.similarity.schedule.error" +msgstr "Unable to schedule the similarity report generation for file id {$submissionFileId} with error : {$error}" + +msgid "plugins.generic.plagiarism.webhook.similarity.schedule.previously" +msgstr "Similarity report generation process has already been scheduled for submission file id {$submissionFileId}" + +msgid "plugins.generic.plagiarism.webhook.similarity.schedule.failure" +msgstr "Failed to schedule the similarity report generation process for submission file id {$submissionFileId}" + +msgid "plugins.generic.plagiarism.stamped.eula.missing" +msgstr "The stamped EULA inforamtion is missing for the submission or submitting user." + +msgid "plugins.generic.plagiarism.ithenticate.upload.complete.failed" +msgstr "Unable to complete the uploading of all files at iThenticate service for plagiarism check." + +msgid "plugins.generic.plagiarism.ithenticate.submission.create.failed" +msgstr "Could not create the submission at iThenticate for submission file id : {$submissionFileId}" + +msgid "plugins.generic.plagiarism.ithenticate.file.upload.failed" +msgstr "Could not complete the file upload at iThenticate for submission file id : {$submissionFileId}" + +msgid "plugins.generic.plagiarism.submission.status.unavailable" +msgstr "Submission details is unavailable." + +msgid "plugins.generic.plagiarism.submission.status.CREATED" +msgstr "Submission has been created but no file has been uploaded" + +msgid "plugins.generic.plagiarism.submission.status.PROCESSING" +msgstr "File contents have been uploaded and the submission is still being processed" + +msgid "plugins.generic.plagiarism.submission.status.ERROR" +msgstr "An error occurred during submission processing." diff --git a/locale/fr_CA/locale.po b/locale/fr_CA/locale.po index 8587d28..55c47a6 100644 --- a/locale/fr_CA/locale.po +++ b/locale/fr_CA/locale.po @@ -19,3 +19,6 @@ msgstr "" msgid "plugins.generic.plagiarism.displayName" msgstr "Plugiciel de détection de plagiat iThenticate" + +msgid "plugins.generic.plagiarism.submission.checklist.eula" +msgstr "Plagiarism EULA link" diff --git a/templates/confirmEula.tpl b/templates/confirmEula.tpl new file mode 100644 index 0000000..449eced --- /dev/null +++ b/templates/confirmEula.tpl @@ -0,0 +1,92 @@ +{** + * plugins/generic/plagiarism/templates/confirmEula.tpl + * + * Copyright (c) 2024 Simon Fraser University + * Copyright (c) 2003-2024 John Willinsky + * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING. + * + * Intermediate stage before final submission stage to view EULA + *} + + +
+ {csrf} + + {include file="controllers/notification/inPlaceNotification.tpl" notificationId="submitStep4FormNotification"} + +

{$eulaAcceptanceMessage}

+ + {fbvFormArea id="EulaConfirmationSection"} + {fbvFormSection} + +
+ {fbvElement + type="checkbox" + name="confirmSubmissionEula" + id="confirmSubmissionEula" + label="plugins.generic.plagiarism.submission.eula.acceptance.confirm" + translate="true" + } + + {if SessionManager::getManager()->getUserSession()->getSessionVar('confirmSubmissionEulaError')} + + + + {/if} +
+ + {fbvFormButtons + id="eulaConfirmationAcceptance" + submitText="plugins.generic.plagiarism.submission.eula.accept.button.title" + } + {/fbvFormSection} + {/fbvFormArea} +
+ + diff --git a/templates/settingsForm.tpl b/templates/settingsForm.tpl index 5b212e2..05e11bb 100644 --- a/templates/settingsForm.tpl +++ b/templates/settingsForm.tpl @@ -5,22 +5,87 @@ {rdelim}); -
+ {csrf} {include file="controllers/notification/inPlaceNotification.tpl" notificationId="plagiarismSettingsFormNotification"} -
{translate key="plugins.generic.plagiarism.manager.settings.description"}
+
+ {translate key="plugins.generic.plagiarism.manager.settings.description"} +
+ {if $ithenticateForced} -
{translate key="plugins.generic.plagiarism.manager.settings.areForced"}
+
+ {translate key="plugins.generic.plagiarism.manager.settings.areForced"} +
{/if} - {fbvFormArea id="webFeedSettingsFormArea"} - {fbvElement type="text" id="ithenticateUser" value=$ithenticateUser label="plugins.generic.plagiarism.manager.settings.username"} - {fbvElement type="text" id="ithenticatePass" value=$ithenticatePass label="plugins.generic.plagiarism.manager.settings.password" password=true} - + {fbvFormArea id="ithenticateServiceAccessFormArea"} + {fbvElement + type="text" + id="ithenticateApiUrl" + value=$ithenticateApiUrl + label="plugins.generic.plagiarism.manager.settings.apiUrl" + } + + {fbvElement + type="text" + id="ithenticateApiKey" + value=$ithenticateApiKey + label="plugins.generic.plagiarism.manager.settings.apiKey" + } + +

+ {fbvFormSection description="plugins.generic.plagiarism.manager.settings.disableAutoSubmission.description" list=true} + {fbvElement + type="checkbox" + name="disableAutoSubmission" + id="disableAutoSubmission" + checked=$disableAutoSubmission + label="plugins.generic.plagiarism.manager.settings.disableAutoSubmission" + translate="true" + } + {/fbvFormSection} +

+ + {/fbvFormArea} + + {fbvFormArea id="ithenticateSimilarityReportSettings"} + {fbvFormSection title="plugins.generic.plagiarism.similarityCheck.settings.title" list=true} +
+ {translate key="plugins.generic.plagiarism.similarityCheck.settings.warning.note"} +
+ + {fbvElement type="checkbox" name="addToIndex" id="addToIndex" checked=$addToIndex label="plugins.generic.plagiarism.similarityCheck.settings.field.addToIndex" translate="true"} + {fbvElement type="checkbox" name="excludeQuotes" id="excludeQuotes" checked=$excludeQuotes label="plugins.generic.plagiarism.similarityCheck.settings.field.excludeQuotes" translate="true"} + {fbvElement type="checkbox" name="excludeBibliography" id="excludeBibliography" checked=$excludeBibliography label="plugins.generic.plagiarism.similarityCheck.settings.field.excludeBibliography" translate="true"} + {fbvElement type="checkbox" name="excludeCitations" id="excludeCitations" checked=$excludeCitations label="plugins.generic.plagiarism.similarityCheck.settings.field.excludeCitations" translate="true"} + {fbvElement type="checkbox" name="excludeAbstract" id="excludeAbstract" checked=$excludeAbstract label="plugins.generic.plagiarism.similarityCheck.settings.field.excludeAbstract" translate="true"} + {fbvElement type="checkbox" name="excludeMethods" id="excludeMethods" checked=$excludeMethods label="plugins.generic.plagiarism.similarityCheck.settings.field.excludeMethods" translate="true"} + {fbvFormSection description="plugins.generic.plagiarism.similarityCheck.settings.field.excludeSmallMatches.description"} + {fbvElement + type="text" + id="excludeSmallMatches" + value=$excludeSmallMatches + label="plugins.generic.plagiarism.similarityCheck.settings.field.excludeSmallMatches.label" + } + {/fbvFormSection} + {fbvElement type="checkbox" name="allowViewerUpdate" id="allowViewerUpdate" checked=$allowViewerUpdate label="plugins.generic.plagiarism.similarityCheck.settings.field.allowViewerUpdate" translate="true"} + {/fbvFormSection} {/fbvFormArea} {fbvFormButtons}

{translate key="common.requiredField"}

+ + diff --git a/templates/similarityScore.tpl b/templates/similarityScore.tpl new file mode 100644 index 0000000..f675e63 --- /dev/null +++ b/templates/similarityScore.tpl @@ -0,0 +1,32 @@ + + + {translate key= + + {$score|escape}% + + + diff --git a/tools/registerWebhooks.php b/tools/registerWebhooks.php new file mode 100644 index 0000000..056fb6c --- /dev/null +++ b/tools/registerWebhooks.php @@ -0,0 +1,111 @@ +argv[0])) { + $this->contextPath = $this->argv[0]; + } + } + + /** + * Print command usage information. + */ + public function usage() { + echo "Register Webhooks for iThenticate Account.\n\n" + . "Usage: {$this->scriptName} optional.context.path \n\n"; + } + + /** + * Execute the specified migration. + */ + public function execute() { + try { + + $plagiarismPlugin = new PlagiarismPlugin(); + + if ($this->contextPath) { + $contextDao = Application::getContextDAO(); /** @var ContextDAO $contextDao */ + $context = $contextDao->getByPath($this->contextPath); /** @var Context $context */ + if (!$context) { + throw new \Exception("No context found for given context path: {$this->contextPath}"); + } + + $this->updateWebhook($context, $plagiarismPlugin); + + return; + } + + // check if there is a global level config e.g. one config for all context + // - Run webhook update for all contexts + // If there is no global level config, then check if each context level config + // - Run webhook update only for those specific context + // if no global level or context level config defined, e.g. configs are managed via plugin setting from + // - nothing to do as plugin's setting will handle webhook update on config update + + $contextService = Services::get("context"); /** @var \APP\Services\ContextService $contextService */ + foreach($contextService->getMany() as $context) { /** @var Context $context */ + $this->updateWebhook($context, $plagiarismPlugin); + } + + } catch (\Throwable $exception) { + echo 'EXCEPTION: ' . $exception->getMessage() . "\n\n"; + exit(2); + } + } + + /** + * Update the webhook details for given context + * + * @param Context $context + * @param PlagiarismPlugin $plagiarismPlugin + * + * @return void + */ + protected function updateWebhook($context, $plagiarismPlugin) { + if (!$plagiarismPlugin->hasForcedCredentials($context)) { + echo "ERROR: No forced credentails defined for context path : {$context->getData('urlPath')}\n\n"; + return; + } + + /** @var IThenticate|TestIThenticate $ithenticate */ + $ithenticate = $plagiarismPlugin->initIthenticate( + ...$plagiarismPlugin->getForcedCredentials($context) + ); + + // If there is a already registered webhook for this context, need to delete it first + // before creating a new one as webhook URL remains same which will return 409(HTTP_CONFLICT) + $existingWebhookId = $context->getData('ithenticateWebhookId'); + if ($existingWebhookId) { + $ithenticate->deleteWebhook($existingWebhookId); + } + + $webhookUpdateStatus = $plagiarismPlugin->registerIthenticateWebhook($ithenticate, $context); + + echo $webhookUpdateStatus + ? "SUCCESS: updated the webhook for context path : {$context->getData('urlPath')}\n\n" + : "ERROR: unable to updated the webhook for context path : {$context->getData('urlPath')}\n\n"; + } +} + +$tool = new RegisterWebhooks(isset($argv) ? $argv : []); +$tool->execute(); diff --git a/version.xml b/version.xml index 4f47456..b5bc580 100644 --- a/version.xml +++ b/version.xml @@ -4,8 +4,8 @@