-
Notifications
You must be signed in to change notification settings - Fork 6
rework upload, fix bulk submit (WP-1015) #630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b517e7c
64a716b
e5bffed
348217c
1aaac56
2e54b2a
9a03f73
49e9b29
b127d1c
31741e4
14ea14a
e13f921
ef06a23
677d4b2
b3a992b
ad623bd
a8f7a1f
c00b383
73ba29e
f983eee
3bcfa50
8f10638
50d0663
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,37 @@ 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 | ||
| { | ||
| // QueryBuilder::escapeName() wraps a bare column name in backticks, but would | ||
| // incorrectly wrap a full "alias.column" reference (e.g. 'q.claimed') the same | ||
| // way, producing invalid SQL - a prefixed reference must be used unescaped. | ||
| // Hence escaping only when there is no prefix to worry about. | ||
| $escapeField = $fieldPrefix === ''; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Suggestion:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added comment |
||
| $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 +198,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 +309,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 | ||
|
|
||
| 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'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| <?php | ||
|
|
||
| namespace Smartling\Helpers; | ||
|
|
||
| /** | ||
| * Shared nonce + capability check for WordPress AJAX handlers (wp_ajax_* actions), | ||
| * which combine both via check_ajax_referer(). For classes that render their own | ||
| * wp_nonce_field() and verify it directly (WP_List_Table bulk actions, form-post | ||
| * controllers), see NonceVerifier instead. | ||
| */ | ||
| class AjaxSecurityChecker | ||
| { | ||
| use LoggerSafeTrait; | ||
|
|
||
| public function __construct(private WordpressFunctionProxyHelper $wpProxy) | ||
| { | ||
| } | ||
|
|
||
| /** | ||
| * @return string|null An AjaxAuthorizationFailure::* constant on failure, null when authorized. | ||
| */ | ||
| public function check(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. | ||
| */ | ||
| public function enforce(string $nonceAction, string $capability, string $actionName): bool | ||
| { | ||
| $authFailure = $this->check($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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <?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 AjaxSecurityChecker instead. | ||
| */ | ||
| class NonceVerifier | ||
| { | ||
| public function __construct(private WordpressFunctionProxyHelper $wpProxy) | ||
| { | ||
| } | ||
|
|
||
| /** | ||
| * @param mixed $nonce Raw value read from the request; anything other than a non-empty string fails verification. | ||
| */ | ||
| public function verify(mixed $nonce, string $nonceAction): bool | ||
| { | ||
| return is_string($nonce) && $nonce !== '' && false !== $this->wpProxy->wp_verify_nonce($nonce, $nonceAction); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Warning: Attachment cloning now happens synchronously here, and the upload queue row is always marked complete afterward regardless of outcome (
UploadJob::processUploadQueue()callscomplete($item)unconditionally). IfcloneContent()throws, the submission is left withis_cloned=1,status=NEW, and an error message set — but the previous recovery path (SubmissionManager::findSubmissionForCloning()+UploadJob::processCloning()) was deleted in this same PR. Before this change a failed clone would be retried on every subsequentUploadJobrun; after this change it's terminal until someone manually resubmits it. Worth confirming this tradeoff is intentional rather than an oversight of removing the retry loop.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added comment, the submission is actually set to failed state, and manual resubmission is required indeed