Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b517e7c
remove cloning poll from UploadJob, clone attachments synchronously (…
vsolovei-smartling Sep 2, 2026
64a716b
make UploadQueueManager::claim() a real compare-and-swap (WP-1015)
vsolovei-smartling Sep 2, 2026
e5bffed
let UploadJob opt out of the distributed lock (WP-1015)
vsolovei-smartling Sep 2, 2026
348217c
replace upload row's lock probe with a live-refreshing queue count (W…
vsolovei-smartling Sep 2, 2026
1aaac56
animate upload queue count on change (WP-1015)
vsolovei-smartling Sep 2, 2026
2e54b2a
switch upload cell to "Nothing to do" when the polled count hits zero…
vsolovei-smartling Sep 2, 2026
9a03f73
fix bulk uploads (WP-1015)
vsolovei-smartling Sep 2, 2026
49e9b29
cleanup (WP-1015)
vsolovei-smartling Sep 3, 2026
b127d1c
fix bulk-submit description precedence bug, remove dead clone-request…
vsolovei-smartling Sep 3, 2026
31741e4
fix unused constant, add tests, remove cloneContent hook (WP-1015)
vsolovei-smartling Sep 3, 2026
14ea14a
fix upload queue delete race, dead clone UI, poller resilience, ajax …
vsolovei-smartling Sep 3, 2026
e13f921
remove dead cloning poll, unify InstantTranslationController AJAX aut…
vsolovei-smartling Sep 3, 2026
ef06a23
remove dead cloning path from bulk submit page (WP-1015)
vsolovei-smartling Sep 3, 2026
677d4b2
add nonce check to bulk submit action (WP-1015)
vsolovei-smartling Sep 3, 2026
b3a992b
fix nonce bypass in bulk submit, extend nonce check to submissions/tr…
vsolovei-smartling Sep 3, 2026
ad623bd
dedupe nonce/ajax-auth checks, gate bulk submit nonce on POST, fix up…
vsolovei-smartling Sep 3, 2026
a8f7a1f
cleanup (WP-1015)
vsolovei-smartling Sep 3, 2026
c00b383
remove cloning coverage from RelationsTest
vsolovei-smartling Sep 4, 2026
73ba29e
remove more cloning coverage: dead functionality, same as RelationsTest
vsolovei-smartling Sep 4, 2026
f983eee
bump version, add readme (WP-1015)
vsolovei-smartling Sep 4, 2026
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "smartling/wordpress-connector",
"license": "GPL-2.0-or-later",
"version": "5.6.2",
"version": "5.7.0",
"description": "",
"type": "wordpress-plugin",
"repositories": [
Expand Down
11 changes: 11 additions & 0 deletions css/smartling-connector-admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,14 @@ span.circle {
.smartling-border-table .numeric {
text-align: right;
}

#smartling-upload-queue-count {
--smartling-queue-count-highlight: #2271b1;
color: inherit;
transition: color 900ms ease;
}

#smartling-upload-queue-count.smartling-queue-count-changed {
color: var(--smartling-queue-count-highlight);
transition: none;
}
6 changes: 0 additions & 6 deletions inc/Smartling/Base/ExportedAPI.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,6 @@ interface ExportedAPI
*/
public const ACTION_SMARTLING_SEND_FOR_TRANSLATION = 'smartling_send_for_translation';

/**
* Action that clones content of given SubmissionEntity without translation
* @param SubmissionEntity
*/
public const ACTION_SMARTLING_CLONE_CONTENT = 'smartling_clone_content';

/**
* Action that downloads translation for given SubmissionEntity
* @param SubmissionEntity
Expand Down
1 change: 0 additions & 1 deletion inc/Smartling/Base/SmartlingCore.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ public function __construct(
) {
parent::__construct();

$this->wpProxy->add_action(ExportedAPI::ACTION_SMARTLING_CLONE_CONTENT, [$this, 'cloneContent']);
$this->wpProxy->add_action(ExportedAPI::ACTION_SMARTLING_PREPARE_SUBMISSION_UPLOAD, [$this, 'prepareUpload']);
$this->wpProxy->add_action(ExportedAPI::ACTION_SMARTLING_SEND_FOR_TRANSLATION, [$this, 'sendForTranslation']);
$this->wpProxy->add_action(ExportedAPI::ACTION_SMARTLING_DOWNLOAD_TRANSLATION, [$this, 'downloadTranslationBySubmission',]);
Expand Down
21 changes: 16 additions & 5 deletions inc/Smartling/Base/SmartlingCoreUploadTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ public function sendForTranslation(UploadQueueItem $item): void

$configurationProfile = $this->getSettingsManager()->getSingleSettingsProfile($item->getSubmissions()[0]->getSourceBlogId());

// Mark attachment submission as "Cloned" if there is "Clone attachment"
// Clone attachment submission instead of uploading it, if "Clone attachment"
// option is enabled in configuration profile.
foreach ($item->getSubmissions() as $submission) {
if (1 === $configurationProfile->getCloneAttachment() && $submission->getContentType() === 'attachment') {
Expand All @@ -541,14 +541,26 @@ public function sendForTranslation(UploadQueueItem $item): void

$this->getLogger()->info(
sprintf(
'Attachment submissionId="%s" marked as cloned (sourceBlogId="%s", sourceId="%s", contentType="%s", batchUid="%s").',
'Cloning attachment submissionId="%s" (sourceBlogId="%s", sourceId="%s", contentType="%s", batchUid="%s").',
$submission->getId(),
$submission->getSourceBlogId(),
$submission->getSourceId(),
$submission->getContentType(),
$item->getBatchUid(),
)
);
try {
$this->cloneContent($submission);
} catch (\Throwable $e) {
$this->getSubmissionManager()->setErrorMessage(
$submission, vsprintf('Error occurred while cloning: %s', [$e->getMessage()])
);
$this->getLogger()->error(sprintf(
'Failed cloning attachment submissionId="%s": %s',
$submission->getId(),
$e->getMessage(),
));
}
$item = $item->removeSubmission($submission);
}
}
Expand Down Expand Up @@ -610,7 +622,7 @@ public function sendForTranslation(UploadQueueItem $item): void
}
}

public function prepareForUpload(string $contentType, int $sourceBlog, int $sourceEntity, int $targetBlog, JobEntityWithBatchUid $jobInfo, bool $clone): SubmissionEntity
public function prepareForUpload(string $contentType, int $sourceBlog, int $sourceEntity, int $targetBlog, JobEntityWithBatchUid $jobInfo): SubmissionEntity
{
$translationHelper = $this->getTranslationHelper();
$submission = $translationHelper
Expand All @@ -629,8 +641,7 @@ public function prepareForUpload(string $contentType, int $sourceBlog, int $sour
$submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW);
}

$isCloned = true === $clone ? 1 : 0;
$submission->setIsCloned($isCloned);
$submission->setIsCloned(0);
$submission->setJobInfo($jobInfo->getJobInformationEntity());

return $this->getSubmissionManager()->storeEntity($submission);
Expand Down
59 changes: 43 additions & 16 deletions inc/Smartling/DbAl/UploadQueueManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,7 @@ public function dequeue(int $blogId): ?UploadQueueItem
// Get queue items with the first submission having its source blog id = $blogId.
// It should be impossible to create a single queue item with submissions from multiple source blog ids,
// so only checking one is enough.
$staleClaimCondition = new ConditionBlock(ConditionBuilder::CONDITION_BLOCK_LEVEL_OPERATOR_OR);
$staleClaimCondition->addCondition(new Condition(
ConditionBuilder::CONDITION_IS_NULL,
'q.' . UploadQueueEntity::FIELD_CLAIMED,
[],
false,
));
$staleClaimCondition->addCondition(new Condition(
ConditionBuilder::CONDITION_SIGN_LESS,
'q.' . UploadQueueEntity::FIELD_CLAIMED,
$this->getStaleClaimThreshold(),
false,
));
$staleClaimCondition = $this->staleClaimCondition('q.');

$query = sprintf(<<<'SQL'
select q.%1$s, q.%2$s, q.%3$s, q.%9$s, q.%10$s from %7$s q left join %8$s s
Expand Down Expand Up @@ -171,6 +159,33 @@ private function getStaleClaimThreshold(): string
);
}

/**
* A row is eligible to be (re)claimed when nobody holds a claim on it, or the claim is
* old enough to have been abandoned by a crashed process.
*
* @param string $fieldPrefix Table alias prefix (e.g. 'q.') to use in a joined query.
* Left empty for an unqualified column reference.
*/
private function staleClaimCondition(string $fieldPrefix = ''): ConditionBlock
{
$escapeField = $fieldPrefix === '';
$block = new ConditionBlock(ConditionBuilder::CONDITION_BLOCK_LEVEL_OPERATOR_OR);
$block->addCondition(new Condition(
ConditionBuilder::CONDITION_IS_NULL,
$fieldPrefix . UploadQueueEntity::FIELD_CLAIMED,
[],
$escapeField,
));
$block->addCondition(new Condition(
ConditionBuilder::CONDITION_SIGN_LESS,
$fieldPrefix . UploadQueueEntity::FIELD_CLAIMED,
$this->getStaleClaimThreshold(),
$escapeField,
));

return $block;
}

public function complete(UploadQueueItem $item): void
{
if (!$this->delete($item->getId())) {
Expand All @@ -179,18 +194,27 @@ public function complete(UploadQueueItem $item): void
}

/**
* Claims a row by id, but only if it is still unclaimed (or stale) at the moment of the
* write. Matching by id alone would let two concurrent dequeue() calls that both selected
* the same unclaimed row both succeed in claiming it; re-checking the claim in the same
* UPDATE makes this a real compare-and-swap, since InnoDB serializes concurrent writers
* to the same row and re-evaluates the WHERE clause against the current data.
*
* @return bool Whether the row was actually claimed.
*/
private function claim(int $id, int $attempts): bool
{
$conditions = $this->idCondition($id);
$conditions->addConditionBlock($this->staleClaimCondition());

return $this->db->query(QueryBuilder::buildUpdateQuery(
$this->tableName,
[
UploadQueueEntity::FIELD_CLAIMED => DateTimeHelper::nowAsString(),
UploadQueueEntity::FIELD_ATTEMPTS => $attempts + 1,
],
$this->idCondition($id),
)) !== false;
$conditions,
)) > 0;
}

public function enqueue(IntegerIterator $submissionIds, string $batchUid): void
Expand Down Expand Up @@ -281,7 +305,10 @@ private function getSmartlingLocale(SubmissionEntity $submission): ?string
*/
private function delete(int $id): bool
{
return $this->db->query(QueryBuilder::buildDeleteQuery($this->tableName, $this->idCondition($id))) !== false;
// Affected-rows is checked with `> 0`, not `!== false`: a successful DELETE matching
// zero rows returns int(0), and `0 !== false` is true in PHP, which would report a
// no-op delete as success (see claim()'s equivalent check for the same pitfall).
return $this->db->query(QueryBuilder::buildDeleteQuery($this->tableName, $this->idCondition($id))) > 0;
}

private function idCondition(int $id): ConditionBlock
Expand Down
10 changes: 10 additions & 0 deletions inc/Smartling/Helpers/AjaxAuthorizationFailure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Smartling\Helpers;

final class AjaxAuthorizationFailure
{
public const INVALID_NONCE = 'invalid_nonce';

public const INSUFFICIENT_CAPABILITY = 'insufficient_capability';
}
55 changes: 55 additions & 0 deletions inc/Smartling/Helpers/AjaxSecurityTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Smartling\Helpers;

trait AjaxSecurityTrait
{
/**
* @return string|null An AjaxAuthorizationFailure::* constant on failure, null when authorized.
*/
protected function checkAjaxNonceAndCapability(string $nonceAction, string $capability, string $actionName): ?string
{
if ($this->wpProxy->check_ajax_referer($nonceAction, '_wpnonce', false) === false) {
$this->getLogger()->warning(sprintf(
'Invalid nonce for action "%s" from userId=%d',
$actionName,
$this->wpProxy->get_current_user_id(),
));

return AjaxAuthorizationFailure::INVALID_NONCE;
}

if (!$this->wpProxy->current_user_can($capability)) {
$this->getLogger()->warning(sprintf(
'User %d lacks capability "%s" for action "%s"',
$this->wpProxy->get_current_user_id(),
$capability,
$actionName,
));

return AjaxAuthorizationFailure::INSUFFICIENT_CAPABILITY;
}

return null;
}

/**
* @return bool Whether the request is authorized. When false, an error response has already been sent.
*/
protected function enforceAjaxAuthorization(string $nonceAction, string $capability, string $actionName): bool
{
$authFailure = $this->checkAjaxNonceAndCapability($nonceAction, $capability, $actionName);
if ($authFailure === AjaxAuthorizationFailure::INVALID_NONCE) {
$this->wpProxy->wp_send_json_error(['message' => 'Invalid nonce'], 403);

return false;
}
if ($authFailure === AjaxAuthorizationFailure::INSUFFICIENT_CAPABILITY) {
$this->wpProxy->wp_send_json_error(['message' => 'Insufficient permissions'], 403);

return false;
}

return true;
}
}
22 changes: 22 additions & 0 deletions inc/Smartling/Helpers/NonceVerificationTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Smartling\Helpers;

/**
* Shared CSRF nonce verification for classes that render their own wp_nonce_field()
* and verify it directly against $_POST/$_REQUEST (WP_List_Table bulk actions,
* form-post controllers). For WordPress AJAX handlers, which combine nonce and
* capability checks via check_ajax_referer(), see AjaxSecurityTrait instead.
*
* Requires the using class to have a `WordpressFunctionProxyHelper $wpProxy` property.
*/
trait NonceVerificationTrait
{
/**
* @param mixed $nonce Raw value read from the request; anything other than a non-empty string fails verification.
*/
protected function verifyNonce(mixed $nonce, string $nonceAction): bool
{
return is_string($nonce) && $nonce !== '' && false !== $this->wpProxy->wp_verify_nonce($nonce, $nonceAction);
}
}
5 changes: 5 additions & 0 deletions inc/Smartling/Helpers/WordpressFunctionProxyHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ public function check_ajax_referer()
return check_ajax_referer(...func_get_args());
}

public function wp_verify_nonce()
{
return wp_verify_nonce(...func_get_args());
}

public function current_user_can()
{
return current_user_can(...func_get_args());
Expand Down
11 changes: 11 additions & 0 deletions inc/Smartling/Jobs/JobAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ protected function getCronFlagName(): string
return self::CRON_FLAG_PREFIX . $this->getJobHookName();
}

protected function usesDistributedLock(): bool
{
return true;
}

/**
* @throws EntityNotFoundException
* @throws SmartlingApiException
Expand Down Expand Up @@ -117,6 +122,9 @@ public function placeLockFlag(bool $renew = false, string $source = ''): void
if ($this->throttleIntervalSeconds > 0) {
$this->cache->set($flagName, 1, $this->throttleIntervalSeconds);
}
if (!$this->usesDistributedLock()) {
return;
}
if ($renew) {
$this->api->renewLock($profile, $flagName, $this->cronLockTtl);
} else {
Expand All @@ -130,6 +138,9 @@ public function placeLockFlag(bool $renew = false, string $source = ''): void
*/
public function dropLockFlag(): void
{
if (!$this->usesDistributedLock()) {
return;
}
$profile = $this->settingsManager->getActiveProfile();
$flagName = $this->getCronFlagName();
$this->getLogger()->debug(
Expand Down
20 changes: 5 additions & 15 deletions inc/Smartling/Jobs/UploadJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public function getJobHookName(): string
return self::JOB_HOOK_NAME;
}

protected function usesDistributedLock(): bool
{
return false;
}

public function run(string $source): void
{
$message = 'UploadJob';
Expand All @@ -48,8 +53,6 @@ public function run(string $source): void

$this->processUploadQueue($blogId);

$this->processCloning($blogId);

$this->getLogger()->debug("Finished $message");
}

Expand Down Expand Up @@ -124,17 +127,4 @@ private function failItem(UploadQueueItem $item, string $logVerb, string $errorM
$this->submissionManager->setErrorMessage($submission, $errorMessage);
}
}

private function processCloning(int $blogId): void
{
while (($submission = $this->submissionManager->findSubmissionForCloning($blogId)) !== null) {
try {
$this->wpProxy->do_action(ExportedAPI::ACTION_SMARTLING_CLONE_CONTENT, $submission);
} catch (\Throwable $e) {
$this->submissionManager->setErrorMessage($submission, $e->getMessage());
continue;
}
$this->placeLockFlag(true);
}
}
}
Loading