Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 129 additions & 4 deletions IThenticate.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ class IThenticate
*/
protected ?string $cachedEnabledFeatures = null;

/**
* Store the last API response details for debugging/inspection
* Contains: status_code, body, headers, reason
*/
protected ?array $lastResponseDetails = null;

/**
* The default EULA version placeholder to retrieve the current latest version
*
Expand Down Expand Up @@ -587,11 +593,46 @@ public function registerWebhook(
'exceptions' => false,
]);

if ($response && $response->getStatusCode() === 201) {
if (!$response) {
return null;
}

$responseStatusCode = $response->getStatusCode();

if ($responseStatusCode === 201) {
$result = json_decode($response->getBody()->getContents());
return $result->id;
}

// Handle 409 CONFLICT — a webhook with the same URL already exists.
// This happens when a previous registration succeeded at iThenticate but the
// webhook ID was not saved locally (e.g. DB save failed after API success).
// Recovery: find the orphaned webhook, delete it, and retry registration once.
if ($responseStatusCode === 409) {
$existingWebhookId = $this->findWebhookIdByUrl($url);

if ($existingWebhookId && $this->deleteWebhook($existingWebhookId)) {
$retryResponse = $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 ($retryResponse && $retryResponse->getStatusCode() === 201) {
$result = json_decode($retryResponse->getBody()->getContents());
return $result->id;
}
}
}

return null;
}

Expand Down Expand Up @@ -630,6 +671,43 @@ public function validateWebhook(string $webhookId, ?string &$result = null): boo
return false;
}

/**
* List all registered webhooks
* @see https://developers.turnitin.com/docs/tca#list-webhooks
*
* @return array List of webhook associative arrays, or empty array on failure
*/
public function listWebhooks(): array
{
$response = $this->makeApiRequest('GET', $this->getApiPath('webhooks'), [
'headers' => $this->getRequiredHeaders(),
'verify' => false,
'exceptions' => false,
]);

if ($response && $response->getStatusCode() === 200) {
return json_decode($response->getBody()->getContents(), true) ?? [];
}

return [];
}

/**
* Find a webhook ID by its URL from the list of registered webhooks
*
* @return string|null The webhook ID if found, or null
*/
public function findWebhookIdByUrl(string $url): ?string
{
foreach ($this->listWebhooks() as $webhook) {
if (($webhook['url'] ?? null) === $url) {
return $webhook['id'] ?? null;
}
}

return null;
}

/**
* Get the stored EULA details
*/
Expand Down Expand Up @@ -681,11 +759,37 @@ public function makeApiRequest(

try {
$response = Application::get()->getHttpClient()->request($method, $url, $options);

// Store response details on success
$body = $response->getBody();
$bodyContent = $body->getContents();

$this->lastResponseDetails = [
'status_code' => $response->getStatusCode(),
'body' => $bodyContent,
'headers' => $response->getHeaders(),
'reason' => $response->getReasonPhrase(),
];

// Rewind so existing code can still read the body
if ($body->isSeekable()) {
$body->rewind();
}

} catch (\Throwable $exception) {

$exceptionMessage = null;
if ($exception instanceof \GuzzleHttp\Exception\RequestException) {
$exceptionMessage = $exception->getResponse()->getBody()->getContents();
if ($exception instanceof \GuzzleHttp\Exception\RequestException && $exception->hasResponse()) {
$errorResponse = $exception->getResponse();
$exceptionMessage = $errorResponse->getBody()->getContents();

// Store response details on failure
$this->lastResponseDetails = [
'status_code' => $errorResponse->getStatusCode(),
'body' => $exceptionMessage,
'headers' => $errorResponse->getHeaders(),
'reason' => $errorResponse->getReasonPhrase(),
];
}

// Mask the sensitive Authorization Bearer token to hide API KEY before logging
Expand Down Expand Up @@ -754,6 +858,27 @@ public function getApplicableLocale(string|array $locales, ?string $eulaVersion
return static::DEFAULT_EULA_LANGUAGE;
}

/**
* Get the last API response details including status code, body, headers, and reason phrase
*
* @return array|null Array with keys: status_code, body, headers, reason.
* Returns null if no API call has been made yet.
*/
public function getLastResponseDetails(): ?array
{
return $this->lastResponseDetails;
}

/**
* Get only the last response body content
*
* @return string|null The response body content, or null if no API call has been made yet.
*/
public function getLastResponseBody(): ?string
{
return $this->lastResponseDetails['body'] ?? null;
}

/**
* Get the corresponding available locale or return null
*/
Expand Down
46 changes: 34 additions & 12 deletions PlagiarismPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -842,32 +842,54 @@ public function createNewSubmission(
}

/**
* Register the webhook for this context
* Get the webhook URL for a given context
*
* Format: BASE_URL/index.php/CONTEXT_PATH/$$$call$$$/plugins/generic/plagiarism/controllers/plagiarism-webhook/handle
*/
public function registerIthenticateWebhook(IThenticate|TestIThenticate $ithenticate, ?Context $context = null): bool
public function getWebhookUrl(?Context $context = null): string
{
$request = Application::get()->getRequest();
$context ??= $request->getContext();

$signingSecret = \Illuminate\Support\Str::random(12);

// Example webhook url : BASE_URL/index.php/CONTEXT_PATH/$$$call$$$/plugins/generic/plagiarism/controllers/plagiarism-webhook/handle
$webhookUrl = Application::get()->getDispatcher()->url(
return Application::get()->getDispatcher()->url(
$request,
Application::ROUTE_COMPONENT,
$context->getData('urlPath'),
'plugins.generic.plagiarism.controllers.PlagiarismWebhookHandler',
'handle'
);
}

/**
* Register the webhook for this context
*
*
* Example webhook format : BASE_URL/index.php/CONTEXT_PATH/$$$call$$$/plugins/generic/plagiarism/controllers/plagiarism-webhook/handle
*/
public function registerIthenticateWebhook(IThenticate|TestIThenticate $ithenticate, ?Context $context = null): bool
{
$request = Application::get()->getRequest();
$context ??= $request->getContext();

$signingSecret = \Illuminate\Support\Str::random(12);
$webhookUrl = $this->getWebhookUrl($context);

if ($webhookId = $ithenticate->registerWebhook($signingSecret, $webhookUrl)) {
$contextService = Services::get('context'); /** @var \PKP\Services\PKPContextService $contextService */
$context = $contextService->edit($context, [
'ithenticateWebhookSigningSecret' => $signingSecret,
'ithenticateWebhookId' => $webhookId
], $request);
try {
$contextService = Services::get('context'); /** @var \PKP\Services\PKPContextService $contextService */
$context = $contextService->edit($context, [
'ithenticateWebhookSigningSecret' => $signingSecret,
'ithenticateWebhookId' => $webhookId
], $request);

return true;
} catch (Throwable $e) {
// DB save failed after API registration succeeded — clean up orphaned webhook
error_log("Webhook registered at iThenticate (ID: {$webhookId}) but failed to save to DB for context {$context->getId()}: " . $e->getMessage());
$ithenticate->deleteWebhook($webhookId);

return true;
return false;
}
}

error_log("unable to complete the iThenticate webhook registration for context id {$context->getId()}");
Expand Down
8 changes: 6 additions & 2 deletions PlagiarismSettingsForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,14 @@ public function execute(...$functionArgs)
// 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'));
if (!$ithenticate->deleteWebhook($this->_context->getData('ithenticateWebhookId'))) {
error_log("Failed to delete existing iThenticate webhook {$this->_context->getData('ithenticateWebhookId')} for context {$this->_context->getId()}");
}
}

$this->_plugin->registerIthenticateWebhook($ithenticate);
if (!$this->_plugin->registerIthenticateWebhook($ithenticate, $this->_context)) {
error_log("Failed to register iThenticate webhook for context {$this->_context->getId()}");
}
}

$this->_plugin->updateSetting($this->_context->getId(), 'ithenticateApiUrl', $ithenticateApiUrl, 'string');
Expand Down
19 changes: 5 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,30 +90,21 @@ When you update(after initial configuration) iThenticate API credentials in `con

The webhook command-line tool helps you manage iThenticate webhooks for your contexts.

**Basic Usage:**

```bash
# Register webhooks for a specific journal/press/server
php plugins/generic/plagiarism/tools/webhook.php register --context=yourcontextpath
php plugins/generic/plagiarism/tools/webhook.php register --context=yourjournalpath

# Update existing webhooks (useful after changing API credentials), use --force to force update
php plugins/generic/plagiarism/tools/webhook.php update --context=yourcontextpath
# Update existing webhooks (useful after changing API credentials)
php plugins/generic/plagiarism/tools/webhook.php update --context=yourjournalpath

# Validate webhook configuration
php plugins/generic/plagiarism/tools/webhook.php validate --context=yourcontextpath
php plugins/generic/plagiarism/tools/webhook.php validate --context=yourjournalpath

# List all configured webhooks
php plugins/generic/plagiarism/tools/webhook.php list
```

**When to use:**
- After changing `api_url` or `api_key` in `config.inc.php`
- To verify webhook configuration is working correctly
- To troubleshoot similarity score delivery issues

**Finding your context path:**
- From `Administration --> Hosted Journals --> Settings Wizard `
- Or use the context ID number instead of the path
For detailed usage including all commands, flags, and advanced features (API-level operations, orphaned webhook recovery, duplicate cleanup, and more), see the [Webhook CLI Usage Guide](WEBHOOK_CLI_USAGE.md).

## Restrictions
1. The submitting user must confirm the iThenticate End User License Agreement to send files to iThenticate service for plagiarism checking.
Expand Down
39 changes: 39 additions & 0 deletions TestIthenticate.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ class TestIThenticate
*/
protected ?string $cachedEnabledFeatures = null;

/**
* @copydoc IThenticate::$lastResponseDetails
*/
protected ?array $lastResponseDetails = null;

/**
* @copydoc IThenticate::DEFAULT_EULA_VERSION
*/
Expand Down Expand Up @@ -423,6 +428,24 @@ public function validateWebhook(string $webhookId, ?string &$result = null): boo
return true;
}

/**
* @copydoc IThenticate::listWebhooks()
*/
public function listWebhooks(): array
{
error_log("Listing all registered webhooks");
return [];
}

/**
* @copydoc IThenticate::findWebhookIdByUrl()
*/
public function findWebhookIdByUrl(string $url): ?string
{
error_log("Finding webhook by URL: {$url}");
return null;
}

/**
* @copydoc IThenticate::getEulaDetails()
*/
Expand Down Expand Up @@ -492,6 +515,22 @@ public function getApplicableLocale(string|array $locales, ?string $eulaVersion
return static::DEFAULT_EULA_LANGUAGE;
}

/**
* @copydoc IThenticate::getLastResponseDetails()
*/
public function getLastResponseDetails(): ?array
{
return $this->lastResponseDetails;
}

/**
* @copydoc IThenticate::getLastResponseBody()
*/
public function getLastResponseBody(): ?string
{
return $this->lastResponseDetails['body'] ?? null;
}

/**
* @copydoc IThenticate::isCorrespondingLocaleAvailable()
*/
Expand Down
Loading