diff --git a/composer.json b/composer.json index d4952c3d..2467c479 100644 --- a/composer.json +++ b/composer.json @@ -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": [ diff --git a/css/smartling-connector-admin.css b/css/smartling-connector-admin.css index eeedb158..e89d29bd 100644 --- a/css/smartling-connector-admin.css +++ b/css/smartling-connector-admin.css @@ -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; +} diff --git a/inc/Smartling/Base/ExportedAPI.php b/inc/Smartling/Base/ExportedAPI.php index 61f1a2bc..8ec603b8 100755 --- a/inc/Smartling/Base/ExportedAPI.php +++ b/inc/Smartling/Base/ExportedAPI.php @@ -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 diff --git a/inc/Smartling/Base/SmartlingCore.php b/inc/Smartling/Base/SmartlingCore.php index 8a3ea7c3..f08ffa05 100644 --- a/inc/Smartling/Base/SmartlingCore.php +++ b/inc/Smartling/Base/SmartlingCore.php @@ -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',]); diff --git a/inc/Smartling/Base/SmartlingCoreUploadTrait.php b/inc/Smartling/Base/SmartlingCoreUploadTrait.php index 86d68534..f81f2357 100644 --- a/inc/Smartling/Base/SmartlingCoreUploadTrait.php +++ b/inc/Smartling/Base/SmartlingCoreUploadTrait.php @@ -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') { @@ -541,7 +541,7 @@ 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(), @@ -549,6 +549,18 @@ public function sendForTranslation(UploadQueueItem $item): void $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); } } @@ -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 @@ -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); diff --git a/inc/Smartling/DbAl/UploadQueueManager.php b/inc/Smartling/DbAl/UploadQueueManager.php index 5e393a9c..45775a67 100644 --- a/inc/Smartling/DbAl/UploadQueueManager.php +++ b/inc/Smartling/DbAl/UploadQueueManager.php @@ -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 @@ -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())) { @@ -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 @@ -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 diff --git a/inc/Smartling/Helpers/AjaxAuthorizationFailure.php b/inc/Smartling/Helpers/AjaxAuthorizationFailure.php new file mode 100644 index 00000000..6771692b --- /dev/null +++ b/inc/Smartling/Helpers/AjaxAuthorizationFailure.php @@ -0,0 +1,10 @@ +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; + } +} diff --git a/inc/Smartling/Helpers/NonceVerificationTrait.php b/inc/Smartling/Helpers/NonceVerificationTrait.php new file mode 100644 index 00000000..c4dcd5f1 --- /dev/null +++ b/inc/Smartling/Helpers/NonceVerificationTrait.php @@ -0,0 +1,22 @@ +wpProxy->wp_verify_nonce($nonce, $nonceAction); + } +} diff --git a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php index e3aa9027..b265e758 100644 --- a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php +++ b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php @@ -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()); diff --git a/inc/Smartling/Jobs/JobAbstract.php b/inc/Smartling/Jobs/JobAbstract.php index 8f1ce056..e17cb078 100644 --- a/inc/Smartling/Jobs/JobAbstract.php +++ b/inc/Smartling/Jobs/JobAbstract.php @@ -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 @@ -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 { @@ -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( diff --git a/inc/Smartling/Jobs/UploadJob.php b/inc/Smartling/Jobs/UploadJob.php index c903cf36..5a1a9339 100644 --- a/inc/Smartling/Jobs/UploadJob.php +++ b/inc/Smartling/Jobs/UploadJob.php @@ -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'; @@ -48,8 +53,6 @@ public function run(string $source): void $this->processUploadQueue($blogId); - $this->processCloning($blogId); - $this->getLogger()->debug("Finished $message"); } @@ -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); - } - } } diff --git a/inc/Smartling/Models/UserCloneRequest.php b/inc/Smartling/Models/UserCloneRequest.php deleted file mode 100644 index 4942c1ed..00000000 --- a/inc/Smartling/Models/UserCloneRequest.php +++ /dev/null @@ -1,68 +0,0 @@ -contentId = $contentId; - $this->contentType = $contentType; - $this->description = $description; - krsort($relations); - $this->relations = $relations; - $this->targetBlogIds = ArrayHelper::toArrayOfIntegers($targetBlogIds, 'Target blog id expected to be numeric'); - } - - public function getContentId(): int - { - return $this->contentId; - } - - public function getContentType(): string - { - return $this->contentType; - } - - public function getDescription(): string - { - return $this->description; - } - - public function getRelationsOrdered(): array - { - return $this->relations; - } - - /** - * @return int[] - */ - public function getTargetBlogIds(): array - { - return $this->targetBlogIds; - } - - public static function fromArray(array $array): self - { - return new self(self::getSourceId($array), $array['source']['contentType'], $array['relations'] ?? [], explode(',', $array['targetBlogIds'])); - } - - // Might be 0 in case of bulk upload - protected static function getSourceId(array $array): int - { - $id = $array['source']['id'][0] ?? null; - if ($id === null) { - throw new SmartlingHumanReadableException('Source content id is empty, please save content prior to uploading', 'source.id.empty', 400); - } - return (int)$id; - } -} diff --git a/inc/Smartling/Models/UserTranslationRequest.php b/inc/Smartling/Models/UserTranslationRequest.php index dec87596..a2a2991b 100644 --- a/inc/Smartling/Models/UserTranslationRequest.php +++ b/inc/Smartling/Models/UserTranslationRequest.php @@ -2,20 +2,59 @@ namespace Smartling\Models; +use Smartling\Exception\SmartlingHumanReadableException; use Smartling\Helpers\ArrayHelper; -class UserTranslationRequest extends UserCloneRequest +class UserTranslationRequest { + private int $contentId; + private string $contentType; + private string $description; + private array $relations; + private array $targetBlogIds; private JobInformation $jobInformation; private array $ids; public function __construct(int $contentId, string $contentType, array $relations, array $targetBlogIds, JobInformation $jobInformation, array $ids = [], string $description = '') { - parent::__construct($contentId, $contentType, $relations, $targetBlogIds, $description); + $this->contentId = $contentId; + $this->contentType = $contentType; + $this->description = $description; + krsort($relations); + $this->relations = $relations; + $this->targetBlogIds = ArrayHelper::toArrayOfIntegers($targetBlogIds, 'Target blog id expected to be numeric'); $this->jobInformation = $jobInformation; $this->ids = self::toIntegerArray($ids); } + public function getContentId(): int + { + return $this->contentId; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getRelationsOrdered(): array + { + return $this->relations; + } + + /** + * @return int[] + */ + public function getTargetBlogIds(): array + { + return $this->targetBlogIds; + } + public function getJobInformation(): JobInformation { return $this->jobInformation; @@ -30,15 +69,16 @@ public static function fromArray(array $array): self { self::validate($array); $ids = self::toIntegerArray($array['ids'] ?? []); + $contentId = count($ids) > 0 ? 0 : self::getSourceId($array); return new self( - self::getSourceId($array), + $contentId, $array['source']['contentType'] ?? '', $array['relations'] ?? [], explode(',', $array['targetBlogIds']), new JobInformation($array['job']['id'], $array['job']['authorize'] === 'true', $array['job']['name'], $array['job']['description'], $array['job']['dueDate'], $array['job']['timeZone']), $ids, - $array['description'] ?? count($ids) > 0 ? 'From Bulk Submit' : 'From Widget', + $array['description'] ?? (count($ids) > 0 ? 'From Bulk Submit' : 'From Widget'), ); } @@ -47,6 +87,15 @@ public function isBulk(): bool return count($this->ids) > 0; } + private static function getSourceId(array $array): int + { + $id = $array['source']['id'][0] ?? null; + if ($id === null) { + throw new SmartlingHumanReadableException('Source content id is empty, please save content prior to uploading', 'source.id.empty', 400); + } + return (int)$id; + } + private static function validate(array $array): void { if (!array_key_exists('source', $array)) { diff --git a/inc/Smartling/Services/ContentRelationsDiscoveryService.php b/inc/Smartling/Services/ContentRelationsDiscoveryService.php index 108c2dec..663ee8db 100644 --- a/inc/Smartling/Services/ContentRelationsDiscoveryService.php +++ b/inc/Smartling/Services/ContentRelationsDiscoveryService.php @@ -34,7 +34,6 @@ use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Jobs\JobEntity; use Smartling\Models\IntegerIterator; -use Smartling\Models\UserCloneRequest; use Smartling\Models\DetectedRelation; use Smartling\Models\DetectedRelations; use Smartling\Models\GutenbergBlock; @@ -146,56 +145,6 @@ public function bulkUpload( return $queueIds; } - public function clone(UserCloneRequest $request): void - { - $sourceBlogId = $this->wordpressProxy->get_current_blog_id(); - $submissionArray = [ - SubmissionEntity::FIELD_SOURCE_BLOG_ID => $sourceBlogId, - ]; - $submissions = []; - - foreach ($request->getTargetBlogIds() as $targetBlogId) { - $submissionArray[SubmissionEntity::FIELD_TARGET_BLOG_ID] = $targetBlogId; - $sources = $this->getSources($request, $targetBlogId); - - $sources[] = [ - 'id' => $request->getContentId(), - 'type' => $request->getContentType(), - ]; - - foreach ($sources as $source) { - $submissionArray[SubmissionEntity::FIELD_CONTENT_TYPE] = $source['type']; - $submissionArray[SubmissionEntity::FIELD_SOURCE_ID] = (int)$source['id']; - $existing = $this->submissionManager->findTargetBlogSubmission( - $submissionArray[SubmissionEntity::FIELD_CONTENT_TYPE], - $submissionArray[SubmissionEntity::FIELD_SOURCE_BLOG_ID], - $submissionArray[SubmissionEntity::FIELD_SOURCE_ID], - $submissionArray[SubmissionEntity::FIELD_TARGET_BLOG_ID], - ); - if ($existing instanceof SubmissionEntity) { - $submission = $existing; - if ($submission->isLocked()) { - $this->getLogger()->debug('Skipping cloning for submissionId=' . $submission->getId() . ', because it is locked'); - continue; - } - $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_NEW); - } else { - $submissionArray[SubmissionEntity::FIELD_STATUS] = SubmissionEntity::SUBMISSION_STATUS_NEW; - $submissionArray[SubmissionEntity::FIELD_SUBMISSION_DATE] = DateTimeHelper::nowAsString(); - $submission = $this->submissionFactory->fromArray($submissionArray); - $title = $this->getTitle($submission); - if ($title !== '') { - $submission->setSourceTitle($title); - } - $submission->setFileUri($this->fileUriHelper->generateFileUri($submission)); - } - $submission->setIsCloned(1); - $submissions[] = $submission; - } - } - $this->submissionManager->storeSubmissions($submissions); - } - public function createSubmissions(UserTranslationRequest $request): void { $curBlogId = $this->wordpressProxy->get_current_blog_id(); @@ -652,7 +601,7 @@ public function getTitle(SubmissionEntity $submission): string } } - private function getSources(UserCloneRequest $request, int $targetBlogId): array + private function getSources(UserTranslationRequest $request, int $targetBlogId): array { $sources = []; diff --git a/inc/Smartling/Services/ContentRelationsHandler.php b/inc/Smartling/Services/ContentRelationsHandler.php index 62d8a935..72d685c3 100644 --- a/inc/Smartling/Services/ContentRelationsHandler.php +++ b/inc/Smartling/Services/ContentRelationsHandler.php @@ -4,10 +4,11 @@ use Exception; use Smartling\Exception\SmartlingHumanReadableException; +use Smartling\Helpers\AjaxAuthorizationFailure; +use Smartling\Helpers\AjaxSecurityTrait; use Smartling\Helpers\LoggerSafeTrait; use Smartling\Helpers\SmartlingUserCapabilities; use Smartling\Helpers\WordpressFunctionProxyHelper; -use Smartling\Models\UserCloneRequest; use Smartling\Models\UserTranslationRequest; /** @@ -36,13 +37,13 @@ */ class ContentRelationsHandler extends BaseAjaxServiceAbstract { + use AjaxSecurityTrait; use LoggerSafeTrait; public const ACTION_NAME = 'smartling-get-relations'; public const ACTION_NAME_CREATE_SUBMISSIONS = 'smartling-create-submissions'; - public const FORM_ACTION_CLONE = 'clone'; public const FORM_ACTION_UPLOAD = 'upload'; private ContentRelationsDiscoveryService $service; @@ -81,13 +82,16 @@ public function register(): void */ public function createSubmissionsHandler(array $data = null): void { - if ($this->wpProxy->check_ajax_referer('smartling_translation', '_wpnonce', false) === false) { - $this->getLogger()->warning(sprintf('Invalid nonce for action "%s" from userId=%d', 'smartling_translation', get_current_user_id())); + $authFailure = $this->checkAjaxNonceAndCapability( + 'smartling_translation', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP, + self::ACTION_NAME_CREATE_SUBMISSIONS, + ); + if ($authFailure === AjaxAuthorizationFailure::INVALID_NONCE) { $this->returnError('invalid.nonce', 'Invalid nonce', 403); return; } - if (!$this->wpProxy->current_user_can(SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)) { - $this->getLogger()->warning(sprintf('User %d lacks capability "%s"', get_current_user_id(), SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)); + if ($authFailure === AjaxAuthorizationFailure::INSUFFICIENT_CAPABILITY) { $this->returnError('permission.denied', 'Insufficient permissions', 403); return; } @@ -96,11 +100,7 @@ public function createSubmissionsHandler(array $data = null): void $data = $_POST; } try { - if ($data['formAction'] === self::FORM_ACTION_CLONE) { - $this->service->clone(UserCloneRequest::fromArray($data)); - } else { - $this->service->createSubmissions(UserTranslationRequest::fromArray($data)); - } + $this->service->createSubmissions(UserTranslationRequest::fromArray($data)); $this->returnResponse(['status' => BaseAjaxServiceAbstract::RESPONSE_SUCCESS]); } catch (Exception $e) { $this->returnError('content.submission.failed', $e->getMessage()); @@ -109,13 +109,16 @@ public function createSubmissionsHandler(array $data = null): void public function actionHandler(): void { - if ($this->wpProxy->check_ajax_referer('smartling_translation', '_wpnonce', false) === false) { - $this->getLogger()->warning(sprintf('Invalid nonce for action "%s" from userId=%d', 'smartling_translation', get_current_user_id())); + $authFailure = $this->checkAjaxNonceAndCapability( + 'smartling_translation', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP, + static::ACTION_NAME, + ); + if ($authFailure === AjaxAuthorizationFailure::INVALID_NONCE) { $this->returnError('invalid.nonce', 'Invalid nonce', 403); return; } - if (!$this->wpProxy->current_user_can(SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)) { - $this->getLogger()->warning(sprintf('User %d lacks capability "%s"', get_current_user_id(), SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)); + if ($authFailure === AjaxAuthorizationFailure::INSUFFICIENT_CAPABILITY) { $this->returnError('permission.denied', 'Insufficient permissions', 403); return; } diff --git a/inc/Smartling/Submissions/SubmissionManager.php b/inc/Smartling/Submissions/SubmissionManager.php index afd0b8ec..f2927825 100644 --- a/inc/Smartling/Submissions/SubmissionManager.php +++ b/inc/Smartling/Submissions/SubmissionManager.php @@ -288,24 +288,6 @@ public function findOne(array $parameters): ?SubmissionEntity return null; } - public function findSubmissionForCloning(int $blogId): ?SubmissionEntity - { - $block = new ConditionBlock(ConditionBuilder::CONDITION_BLOCK_LEVEL_OPERATOR_AND); - $block->addCondition(Condition::getCondition(ConditionBuilder::CONDITION_SIGN_EQ, SubmissionEntity::FIELD_STATUS, [SubmissionEntity::SUBMISSION_STATUS_NEW])); - $block->addCondition(Condition::getCondition(ConditionBuilder::CONDITION_SIGN_EQ, SubmissionEntity::FIELD_IS_CLONED, [1])); - $block->addCondition(Condition::getCondition(ConditionBuilder::CONDITION_SIGN_EQ, SubmissionEntity::FIELD_IS_LOCKED, [0])); - $block->addCondition(new Condition(ConditionBuilder::CONDITION_SIGN_EQ, SubmissionEntity::FIELD_SOURCE_BLOG_ID, [$blogId])); - - $data = $this->fetchData(QueryBuilder::buildSelectQuery( - $this->getDbal()->completeTableName(SubmissionEntity::getTableName()), - array_keys(SubmissionEntity::getFieldDefinitions()), - $block, - ['id' => 'asc'], - ['limit' => 1, 'page' => 1], - )); - - return ArrayHelper::first($data) ?: null; - } /** * @param int[] $ids * @return SubmissionEntity[] diff --git a/inc/Smartling/WP/Controller/BulkSubmitController.php b/inc/Smartling/WP/Controller/BulkSubmitController.php index 17178451..aa374838 100644 --- a/inc/Smartling/WP/Controller/BulkSubmitController.php +++ b/inc/Smartling/WP/Controller/BulkSubmitController.php @@ -13,6 +13,7 @@ use Smartling\Helpers\PluginInfo; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\SmartlingUserCapabilities; +use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionManager; use Smartling\WP\Table\BulkSubmitTableWidget; @@ -32,6 +33,7 @@ public function __construct( SubmissionManager $manager, private UploadQueueManager $uploadQueueManager, Cache $cache, + private WordpressFunctionProxyHelper $wpProxy, ) { parent::__construct($api, $connector, $pluginInfo, $settingsManager, $siteHelper, $manager, $cache); } @@ -89,7 +91,8 @@ public function renderPage() $this->core, $this->submissionManager, $this->uploadQueueManager, - $profile + $profile, + $this->wpProxy, ); $this->view($table); } diff --git a/inc/Smartling/WP/Controller/ConfigurationProfilesController.php b/inc/Smartling/WP/Controller/ConfigurationProfilesController.php index aad4f779..f79ac5d7 100644 --- a/inc/Smartling/WP/Controller/ConfigurationProfilesController.php +++ b/inc/Smartling/WP/Controller/ConfigurationProfilesController.php @@ -11,7 +11,6 @@ use Smartling\Helpers\PluginInfo; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\SmartlingUserCapabilities; -use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Jobs\JobAbstract; use Smartling\Queue\QueueInterface; use Smartling\Services\GlobalSettingsManager; @@ -39,7 +38,6 @@ public function __construct( Cache $cache, private QueueInterface $queue, private UploadQueueManager $uploadQueueManager, - private WordpressFunctionProxyHelper $wpProxy, ) { parent::__construct($api, $connector, $pluginInfo, $settingsManager, $siteHelper, $manager, $cache); } @@ -204,7 +202,6 @@ public function listProfiles(): void $this->settingsManager, $this->submissionManager, $this->uploadQueueManager, - $this->wpProxy, ), ]); } diff --git a/inc/Smartling/WP/Controller/InstantTranslationController.php b/inc/Smartling/WP/Controller/InstantTranslationController.php index 56067f0b..5f8224a1 100644 --- a/inc/Smartling/WP/Controller/InstantTranslationController.php +++ b/inc/Smartling/WP/Controller/InstantTranslationController.php @@ -3,6 +3,7 @@ namespace Smartling\WP\Controller; use Smartling\FTS\FtsService; +use Smartling\Helpers\AjaxSecurityTrait; use Smartling\Helpers\DateTimeHelper; use Smartling\Helpers\FileUriHelper; use Smartling\Helpers\LoggerSafeTrait; @@ -15,6 +16,7 @@ class InstantTranslationController implements WPHookInterface { + use AjaxSecurityTrait; use LoggerSafeTrait; private const ACTION_REQUEST_TRANSLATION = 'smartling_instant_translation'; @@ -37,15 +39,11 @@ public function register(): void public function handleRequestTranslation(): void { - if ($this->wpProxy->check_ajax_referer('smartling_translation', '_wpnonce', false) === false) { - $this->getLogger()->warning(sprintf('Invalid nonce for action "%s" from userId=%d', 'smartling_translation', get_current_user_id())); - $this->wpProxy->wp_send_json_error(['message' => 'Invalid nonce'], 403); - return; - } - - if (!$this->wpProxy->current_user_can(SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)) { - $this->getLogger()->warning(sprintf('User %d lacks capability "%s"', get_current_user_id(), SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)); - $this->wpProxy->wp_send_json_error(['message' => 'Insufficient permissions'], 403); + if (!$this->enforceAjaxAuthorization( + 'smartling_translation', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP, + self::ACTION_REQUEST_TRANSLATION, + )) { return; } @@ -144,15 +142,11 @@ public function handleRequestTranslation(): void public function handlePollStatus(): void { - if ($this->wpProxy->check_ajax_referer('smartling_translation', '_wpnonce', false) === false) { - $this->getLogger()->warning(sprintf('Invalid nonce for action "%s" from userId=%d', 'smartling_translation', get_current_user_id())); - $this->wpProxy->wp_send_json_error(['message' => 'Invalid nonce'], 403); - return; - } - - if (!$this->wpProxy->current_user_can(SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)) { - $this->getLogger()->warning(sprintf('User %d lacks capability "%s"', get_current_user_id(), SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP)); - $this->wpProxy->wp_send_json_error(['message' => 'Insufficient permissions'], 403); + if (!$this->enforceAjaxAuthorization( + 'smartling_translation', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_WIDGET_CAP, + self::ACTION_POLL_STATUS, + )) { return; } diff --git a/inc/Smartling/WP/Controller/SubmissionsPageController.php b/inc/Smartling/WP/Controller/SubmissionsPageController.php index 08ea1560..41960ea5 100644 --- a/inc/Smartling/WP/Controller/SubmissionsPageController.php +++ b/inc/Smartling/WP/Controller/SubmissionsPageController.php @@ -8,6 +8,7 @@ use Smartling\Helpers\PluginInfo; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\SmartlingUserCapabilities; +use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Queue\Queue; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionManager; @@ -26,6 +27,7 @@ public function __construct( SubmissionManager $manager, Cache $cache, private Queue $queue, + private WordpressFunctionProxyHelper $wpProxy, ) { parent::__construct($api, $connector, $pluginInfo, $settingsManager, $siteHelper, $manager, $cache); } @@ -66,6 +68,7 @@ public function renderPage(): void $this->siteHelper, $this->submissionManager, $this->queue, + $this->wpProxy, ); $table->prepare_items(); $this->view($table); diff --git a/inc/Smartling/WP/Controller/TranslationLockController.php b/inc/Smartling/WP/Controller/TranslationLockController.php index c4819609..9c2e8392 100644 --- a/inc/Smartling/WP/Controller/TranslationLockController.php +++ b/inc/Smartling/WP/Controller/TranslationLockController.php @@ -9,9 +9,11 @@ use Smartling\Helpers\ContentHelper; use Smartling\Helpers\DiagnosticsHelper; use Smartling\Helpers\HtmlTagGeneratorHelper; +use Smartling\Helpers\NonceVerificationTrait; use Smartling\Helpers\PluginInfo; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\SmartlingUserCapabilities; +use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionManager; @@ -21,6 +23,11 @@ class TranslationLockController extends WPAbstract implements WPHookInterface { + use NonceVerificationTrait; + + public const LOCK_ACTION_NONCE_ACTION = 'smartling-translation-lock-action'; + public const LOCK_ACTION_NONCE_FIELD = '_wpnonce'; + public function __construct( protected ApiWrapperInterface $api, LocalizationPluginProxyInterface $connector, @@ -30,6 +37,7 @@ public function __construct( SubmissionManager $manager, Cache $cache, private ContentHelper $contentHelper, + private WordpressFunctionProxyHelper $wpProxy, ) { parent::__construct($api, $connector, $pluginInfo, $settingsManager, $siteHelper, $manager, $cache); } @@ -140,6 +148,11 @@ public function popupIFrame() public function handleFormPost(): void { + if (!$this->verifyLockActionNonce()) { + $this->getLogger()->warning('Rejected Translation Lock action: missing or invalid nonce.'); + return; + } + $submission = $this->getSubmissionFromQuery(); if (false !== $submission) { $submission->setLockedFields(array_keys($_POST['lockField'] ?? [])); @@ -148,6 +161,11 @@ public function handleFormPost(): void } } + private function verifyLockActionNonce(): bool + { + return $this->verifyNonce($_POST[self::LOCK_ACTION_NONCE_FIELD] ?? '', self::LOCK_ACTION_NONCE_ACTION); + } + public function notAllowed() { echo "Sorry, you're not allowed to go here."; diff --git a/inc/Smartling/WP/Controller/UploadQueueCountController.php b/inc/Smartling/WP/Controller/UploadQueueCountController.php new file mode 100644 index 00000000..d59f7c97 --- /dev/null +++ b/inc/Smartling/WP/Controller/UploadQueueCountController.php @@ -0,0 +1,42 @@ +wpProxy->add_action('wp_ajax_' . self::ACTION_NAME, [$this, 'handleGetCount']); + } + + public function handleGetCount(): void + { + if (!$this->enforceAjaxAuthorization( + 'smartling_connector_ajax', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_PROFILE_CAP, + self::ACTION_NAME, + )) { + return; + } + + $this->wpProxy->wp_send_json_success(['count' => $this->uploadQueueManager->count()]); + } +} diff --git a/inc/Smartling/WP/Table/BulkSubmitTableWidget.php b/inc/Smartling/WP/Table/BulkSubmitTableWidget.php index e717c322..25ebc58e 100644 --- a/inc/Smartling/WP/Table/BulkSubmitTableWidget.php +++ b/inc/Smartling/WP/Table/BulkSubmitTableWidget.php @@ -17,10 +17,12 @@ use Smartling\Helpers\DateTimeHelper; use Smartling\Helpers\HtmlTagGeneratorHelper; use Smartling\Helpers\LoggerSafeTrait; +use Smartling\Helpers\NonceVerificationTrait; use Smartling\Helpers\PluginInfo; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\StringHelper; use Smartling\Helpers\WordpressContentTypeHelper; +use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Jobs\JobEntityWithBatchUid; use Smartling\Models\IntegerIterator; use Smartling\Settings\ConfigurationProfileEntity; @@ -32,9 +34,13 @@ class BulkSubmitTableWidget extends SmartlingListTable { use CommonLogMessagesTrait; use LoggerSafeTrait; + use NonceVerificationTrait; private const CUSTOM_CONTROLS_NAMESPACE = 'smartling-bulk-submit-page'; + public const BULK_ACTION_NONCE_ACTION = 'smartling-bulk-submit-action'; + public const BULK_ACTION_NONCE_FIELD = '_wpnonce'; + /** * base name of Content-type filtering select */ @@ -75,6 +81,7 @@ public function __construct( protected SubmissionManager $manager, protected UploadQueueManager $uploadQueueManager, protected ConfigurationProfileEntity $profile, + protected WordpressFunctionProxyHelper $wpProxy, ) { $this->setSource($_REQUEST); @@ -172,15 +179,22 @@ public function processBulkAction(): void { $action = $this->getFromSource('action', 'send'); $submissions = $this->getFormElementValue('submission', []); + $submissions = is_array($submissions) ? $submissions : []; $locales = []; $batchUid = ''; $data = $this->getFromSource('bulk-submit-locales', []); + $data = is_array($data) ? $data : []; $jobName = ''; - $smartlingData = []; + $smartlingData = $this->getFromSource('smartling', []); + $smartlingData = is_array($smartlingData) ? $smartlingData : []; $profile = $this->getProfile(); + if ($this->isPostRequest() && !$this->verifyBulkActionNonce()) { + $this->getLogger()->warning('Rejected Bulk Submit action: missing or invalid nonce.'); + return; + } + if ($action === 'send') { - $smartlingData = $this->getFromSource('smartling', []); if (empty($smartlingData)) { return; } @@ -219,7 +233,7 @@ public function processBulkAction(): void } } - if (null !== $data && array_key_exists('locales', $data)) { + if (array_key_exists('locales', $data)) { foreach ($data['locales'] as $blogId => $blogName) { if (array_key_exists('enabled', $blogName) && 'on' === $blogName['enabled']) { $locales[$blogId] = $blogName['locale']; @@ -228,23 +242,19 @@ public function processBulkAction(): void $queueIds = new IntegerIterator(); if (is_array($submissions) && count($locales) > 0) { - $clone = 'clone' === $action; foreach ($submissions as $submission) { [$id] = explode('-', $submission); $type = $this->getContentTypeFilterValue(); $curBlogId = $this->getProfile()->getSourceLocale()->getBlogId(); foreach ($locales as $blogId => $blogName) { - $submissionId = $this->core->prepareForUpload( + $submissionId = $this->core->prepareForUpload( $type, $curBlogId, $id, (int)$blogId, - new JobEntityWithBatchUid($batchUid, $jobName, $clone ? '' : $smartlingData['jobId'], $profile->getProjectId()), - $clone, + new JobEntityWithBatchUid($batchUid, $jobName, $smartlingData['jobId'] ?? '', $profile->getProjectId()), )->getId(); - if (!$clone) { - $queueIds[] = $submissionId; - } + $queueIds[] = $submissionId; } } @@ -253,6 +263,16 @@ public function processBulkAction(): void } } + private function verifyBulkActionNonce(): bool + { + return $this->verifyNonce($this->getFromSource(self::BULK_ACTION_NONCE_FIELD, ''), self::BULK_ACTION_NONCE_ACTION); + } + + private function isPostRequest(): bool + { + return ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'; + } + private function getContentTypeFilterValue(): ?string { $value = $this->getFormElementValue( diff --git a/inc/Smartling/WP/Table/QueueManagerTableWidget.php b/inc/Smartling/WP/Table/QueueManagerTableWidget.php index 5bc497eb..6f9cd79f 100644 --- a/inc/Smartling/WP/Table/QueueManagerTableWidget.php +++ b/inc/Smartling/WP/Table/QueueManagerTableWidget.php @@ -6,7 +6,6 @@ use Smartling\DbAl\UploadQueueManager; use Smartling\Exception\EntityNotFoundException; use Smartling\Helpers\HtmlTagGeneratorHelper; -use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Jobs\DownloadTranslationJob; use Smartling\Jobs\JobAbstract; use Smartling\Jobs\LastModifiedCheckJob; @@ -38,7 +37,6 @@ public function __construct( protected SettingsManager $settingsManager, protected SubmissionManager $submissionManager, protected UploadQueueManager $uploadQueueManager, - protected WordpressFunctionProxyHelper $wpProxy, ) { $this->setSource($_REQUEST); @@ -81,7 +79,7 @@ public function prepare_items(): void $data = [ [ 'cron_name' => __('Upload'), - 'run_cron' => $this->getUploadCronActionCell($profile, $newSubmissionsCount), + 'run_cron' => $this->getUploadCronActionCell($newSubmissionsCount), 'queue_name' => __(' '), 'queue_purge' => 0 === $newSubmissionsCount ? __('Nothing to purge') @@ -140,23 +138,17 @@ public function prepare_items(): void $this->items = $data; } - private function getUploadCronActionCell(?ConfigurationProfileEntity $profile, int $count): string + private function getUploadCronActionCell(int $count): string { - if ($count === 0 && $this->submissionManager->findSubmissionForCloning($this->wpProxy->get_current_blog_id()) === null) { + if ($count === 0) { return self::MESSAGE_NOTHING_TO_DO; } - $jobName = UploadJob::JOB_HOOK_NAME; - try { - $this->testLock($profile, $jobName); - return sprintf( - '%s (%s submissions waiting)', - $this->getLockTag($jobName), - $count, - ); - } catch (SmartlingApiException $e) { - return sprintf('%s (%s submissions queued)', $this->getRunningMessage($e), $count); - } + return sprintf( + '%s (%s submissions waiting)', + $this->getLockTag(UploadJob::JOB_HOOK_NAME), + $count, + ); } private function getCheckStatusHelperCronActionCell(?ConfigurationProfileEntity $profile, int $count): string diff --git a/inc/Smartling/WP/Table/SubmissionTableWidget.php b/inc/Smartling/WP/Table/SubmissionTableWidget.php index 13cb87fd..2dcebf9f 100644 --- a/inc/Smartling/WP/Table/SubmissionTableWidget.php +++ b/inc/Smartling/WP/Table/SubmissionTableWidget.php @@ -11,12 +11,14 @@ use Smartling\Helpers\DiagnosticsHelper; use Smartling\Helpers\HtmlTagGeneratorHelper; use Smartling\Helpers\LoggerSafeTrait; +use Smartling\Helpers\NonceVerificationTrait; use Smartling\Helpers\QueryBuilder\Condition\Condition; use Smartling\Helpers\QueryBuilder\Condition\ConditionBlock; use Smartling\Helpers\QueryBuilder\Condition\ConditionBuilder; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\StringHelper; use Smartling\Helpers\WordpressContentTypeHelper; +use Smartling\Helpers\WordpressFunctionProxyHelper; use Smartling\Jobs\JobEntity; use Smartling\Queue\QueueInterface; use Smartling\Settings\Locale; @@ -30,6 +32,7 @@ class SubmissionTableWidget extends SmartlingListTable { use CommonLogMessagesTrait; use LoggerSafeTrait; + use NonceVerificationTrait; private const ACTION_CHECK_STATUS = 'checkStatus'; public const ACTION_DOWNLOAD = 'download'; @@ -37,6 +40,9 @@ class SubmissionTableWidget extends SmartlingListTable private const ACTION_UNLOCK = 'unlock'; private const ACTION_UPLOAD = 'upload'; + public const BULK_ACTION_NONCE_ACTION = 'smartling-submissions-bulk-action'; + public const BULK_ACTION_NONCE_FIELD = '_wpnonce'; + /** * base name of Content-type filtering select */ @@ -75,6 +81,7 @@ public function __construct( protected SiteHelper $siteHelper, protected SubmissionManager $submissionManager, protected QueueInterface $queue, + protected WordpressFunctionProxyHelper $wpProxy, ) { $this->setSource($_REQUEST); @@ -163,9 +170,17 @@ public function get_bulk_actions(): array public function processBulkAction(): void { + $requestedSubmissions = $this->getFormElementValue('submission', []); + $requestedSubmissions = is_array($requestedSubmissions) ? $requestedSubmissions : []; + + if (count($requestedSubmissions) > 0 && !$this->verifyBulkActionNonce()) { + $this->getLogger()->warning('Rejected Translation Progress bulk action: missing or invalid nonce.'); + return; + } + $submissionsIds = array_map(static function ($value) { return (int)$value; - }, $this->getFormElementValue('submission', [])); + }, $requestedSubmissions); if (0 < count($submissionsIds)) { $submissions = $this->submissionManager->findByIds($submissionsIds); @@ -231,6 +246,11 @@ public function processBulkAction(): void } } + private function verifyBulkActionNonce(): bool + { + return $this->verifyNonce($this->getFromSource(self::BULK_ACTION_NONCE_FIELD, ''), self::BULK_ACTION_NONCE_ACTION); + } + /** * Handles actions */ diff --git a/inc/Smartling/WP/View/BulkSubmit.php b/inc/Smartling/WP/View/BulkSubmit.php index 689240bb..65963886 100644 --- a/inc/Smartling/WP/View/BulkSubmit.php +++ b/inc/Smartling/WP/View/BulkSubmit.php @@ -3,7 +3,6 @@ use Smartling\Helpers\ArrayHelper; use Smartling\WP\Controller\BulkSubmitController; use Smartling\WP\Table\BulkSubmitTableWidget; -use Smartling\WP\WPAbstract; /** * @var BulkSubmitController $this @@ -46,6 +45,7 @@
#is', '', $source); - $source = preg_replace('##is', '', $source); - - return $source; + if (!function_exists('wp_create_nonce')) { + function wp_create_nonce($action = -1) + { + return 'test-nonce'; + } } +} - private function assertDivBalanced(string $html, string $context): void +namespace Smartling\Tests\Smartling\WP\View { + + use PHPUnit\Framework\TestCase; + use Smartling\ApiWrapperInterface; + use Smartling\DbAl\LocalizationPluginProxyInterface; + use Smartling\Helpers\Cache; + use Smartling\Helpers\PluginInfo; + use Smartling\Helpers\SiteHelper; + use Smartling\Helpers\WordpressFunctionProxyHelper; + use Smartling\Settings\ConfigurationProfileEntity; + use Smartling\Settings\SettingsManager; + use Smartling\Submissions\SubmissionManager; + use Smartling\Tests\Mocks\WordpressFunctionsMockHelper; + use Smartling\WP\Controller\ContentEditJobController; + + class ContentEditJobViewTest extends TestCase { - $opens = preg_match_all('#