log->purge( $days ); } /** * Posts a stored submission to every active endpoint of its form. * * @param int $submission_id Stored submission ID. * @param int $form_id Form the submission belongs to. * @param array $entries Stored values. */ public function on_submission_created( int $submission_id, int $form_id, array $entries ): void { $form = $this->forms->find( $form_id ); if ( ! $form instanceof Form ) { return; } $webhooks = $this->registry->active( $form ); if ( array() === $webhooks ) { return; } $values = array(); foreach ( $entries as $entry ) { $key = (string) ( $entry['key'] ?? '' ); if ( '' !== $key ) { $values[ $key ] = $entry['value'] ?? ''; } } $payload = $this->payload->build( $form, $submission_id, $values ); foreach ( $webhooks as $webhook ) { $this->sender->dispatch( $webhook, $payload, $form_id, $submission_id ); } } /** * Retries the deliveries that are due. */ public function process_queue(): void { $due = $this->log->due( self::BATCH ); foreach ( $due as $row ) { /* * Claimed before anything is done with it. WP-Cron has no mutex, so two * overlapping runs used to read the same batch and both deliver it — * every endpoint received the submission twice, and `attempts` advanced * non-monotonically, letting a row overrun the five-attempt ceiling. The * claim is a conditional UPDATE, so exactly one run wins it. */ if ( ! $this->log->claim( (int) ( $row['id'] ?? 0 ) ) ) { continue; } $form = $this->forms->find( (int) ( $row['form_id'] ?? 0 ) ); if ( ! $form instanceof Form ) { // The form was deleted while the delivery was waiting. Nothing can // be signed or addressed without it, so the row is closed rather // than left to be picked up on every run from now on. $this->log->close( (int) $row['id'], array( 'status' => DeliveryLog::FAILED, 'message' => __( 'The form no longer exists.', 'devplugins-custom-form-builder' ), 'next_attempt_at' => null, ) ); continue; } $key = (string) ( $row['webhook_key'] ?? '' ); $webhook = $this->registry->find( $form, $key ); if ( $webhook instanceof Webhook && ! $webhook->is_active() ) { $webhook = null; } if ( ! $webhook instanceof Webhook ) { /** * Filters the endpoint a delivery should be retried against. * * Deliveries that did not come from the form's own webhook list — * an integration, or anything else using this queue — resolve their * endpoint here. It is rebuilt rather than stored so that a * credential rotated since the first attempt is picked up, instead * of replaying a copy taken hours ago. * * @since 1.0.0 * * @param Webhook|null $webhook Endpoint, or null when unresolved. * @param Form $form Form the delivery belongs to. * @param string $key Key the delivery was recorded under. */ $webhook = apply_filters( 'formpilot_webhook_retry_target', null, $form, $key ); } if ( ! $webhook instanceof Webhook ) { $this->log->close( (int) $row['id'], array( 'status' => DeliveryLog::FAILED, 'message' => __( 'The webhook was removed or switched off.', 'devplugins-custom-form-builder' ), 'next_attempt_at' => null, ) ); continue; } $this->sender->retry( $row, $webhook ); } // Anything still pending after this run schedules the next one. $remaining = $this->log->due( 1 ); if ( array() !== $remaining ) { WebhookSender::schedule( time() + MINUTE_IN_SECONDS ); } } /** * Sends a sample payload and reports what came back. * * Answers with the status code and the body rather than a yes or no: an * endpoint that returns 401 has been reached, which is a different problem * from one that cannot be resolved at all, and the person configuring it * needs to be able to tell those apart. */ public function handle_test(): void { /* * A test posts to a URL of the caller's choosing from the server, which is * exactly the shape of a request forgery. Both halves are checked: the * capability, so a subscriber cannot reach it at all, and the nonce, so a * page elsewhere cannot make an editor's browser do it. */ if ( ! Nonce::verify_ajax( self::TEST_NONCE, 'nonce' ) || ! Capabilities::current_user_can( Capabilities::MANAGE_FORMS ) ) { wp_send_json_error( array( 'message' => __( 'This page has expired. Please reload it and try again.', 'devplugins-custom-form-builder' ) ), 403 ); } $form = $this->forms->find( Request::int( 'form_id' ) ); if ( ! $form instanceof Form ) { wp_send_json_error( array( 'message' => __( 'Save the form before testing a webhook.', 'devplugins-custom-form-builder' ) ), 404 ); } $webhook = new Webhook( key: 'test', name: __( 'Test', 'devplugins-custom-form-builder' ), url: Request::text( 'url' ), secret: Request::text( 'secret' ), headers: $this->test_headers(), enabled: true ); if ( ! $webhook->is_active() ) { wp_send_json_error( array( 'message' => __( 'Enter a valid URL first.', 'devplugins-custom-form-builder' ) ), 400 ); } $result = $this->sender->probe( $webhook, $this->payload->sample( $form ) ); wp_send_json_success( array( 'ok' => $result['ok'], 'code' => $result['code'], 'ms' => $result['ms'], 'message' => $this->test_message( $result ), 'body' => $result['body'], ) ); } /** * Reads the headers typed into the test form. * * @return array Values keyed by header name. */ private function test_headers(): array { $raw = Request::raw( 'headers', 'post' ); return WebhookHeaders::parse( is_scalar( $raw ) ? sanitize_textarea_field( (string) $raw ) : '' ); } /** * Builds the sentence shown under the test button. * * @param array{ok: bool, code: int, message: string, body: string, ms: int} $result Response details. */ private function test_message( array $result ): string { if ( 0 === $result['code'] ) { return sprintf( /* translators: %s: Error returned by the HTTP client. */ __( 'The request never reached the endpoint: %s', 'devplugins-custom-form-builder' ), $result['message'] ); } if ( $result['ok'] ) { return sprintf( /* translators: 1: HTTP status code. 2: Milliseconds the request took. */ __( 'The endpoint accepted the payload (HTTP %1$d, %2$d ms).', 'devplugins-custom-form-builder' ), $result['code'], $result['ms'] ); } return sprintf( /* translators: 1: HTTP status code. 2: HTTP status message. */ __( 'The endpoint answered HTTP %1$d %2$s.', 'devplugins-custom-form-builder' ), $result['code'], $result['message'] ); } }