Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.52% |
108 / 122 |
|
50.00% |
4 / 8 |
CRAP | |
0.00% |
0 / 1 |
| Slack | |
88.52% |
108 / 122 |
|
50.00% |
4 / 8 |
27.02 | |
0.00% |
0 / 1 |
| is_valid_webhook_url | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| send | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
| build_payload | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
3 | |||
| build_field_blocks | |
72.73% |
24 / 33 |
|
0.00% |
0 / 1 |
9.30 | |||
| get_form_title | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| escape | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| truncate | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| record_result | |
80.00% |
12 / 15 |
|
0.00% |
0 / 1 |
4.13 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Posts new form responses to Slack. |
| 4 | * |
| 5 | * @package automattic/jetpack-forms |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Forms\Service; |
| 9 | |
| 10 | use Automattic\Jetpack\Forms\Dashboard\Dashboard; |
| 11 | |
| 12 | /** |
| 13 | * Sends a Block Kit message to a Slack incoming webhook when a form receives a response. |
| 14 | * |
| 15 | * The webhook URL is the credential: anyone holding it can post to the channel it was created |
| 16 | * for. It is stored per form, in the form's block attributes, which means it is readable by |
| 17 | * anyone who can edit the form. That is the same exposure the existing webhooks feature has, |
| 18 | * but it is why the URL is pinned to Slack's own host — a value that cannot be pointed |
| 19 | * anywhere else cannot be used to reach the site's own network, and cannot quietly become an |
| 20 | * exfiltration target for response data. |
| 21 | */ |
| 22 | class Slack { |
| 23 | |
| 24 | /** |
| 25 | * The only URLs accepted as a Slack webhook. |
| 26 | * |
| 27 | * Slack issues incoming webhooks on exactly this path. Pinning to it makes the field |
| 28 | * unusable for anything but Slack, which rules out SSRF without needing to reason about |
| 29 | * address ranges and DNS rebinding the way a general-purpose webhook must. |
| 30 | */ |
| 31 | private const WEBHOOK_URL_PATTERN = '#^https://hooks\.slack\.com/services/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$#'; |
| 32 | |
| 33 | /** |
| 34 | * Slack rejects a header longer than this. |
| 35 | */ |
| 36 | private const HEADER_LIMIT = 150; |
| 37 | |
| 38 | /** |
| 39 | * Slack rejects a section field longer than this. |
| 40 | */ |
| 41 | private const FIELD_LIMIT = 2000; |
| 42 | |
| 43 | /** |
| 44 | * Slack rejects a section body longer than this. |
| 45 | */ |
| 46 | private const SECTION_LIMIT = 3000; |
| 47 | |
| 48 | /** |
| 49 | * A value at or below this length is shown in the two-column grid; longer values get a |
| 50 | * block of their own so they are not truncated into uselessness. |
| 51 | */ |
| 52 | private const SHORT_VALUE_LENGTH = 60; |
| 53 | |
| 54 | /** |
| 55 | * Slack renders at most this many fields in one section. |
| 56 | */ |
| 57 | private const MAX_GRID_FIELDS = 10; |
| 58 | |
| 59 | /** |
| 60 | * Whether a string is a usable Slack incoming webhook URL. |
| 61 | * |
| 62 | * @param mixed $url The candidate URL. |
| 63 | * @return bool |
| 64 | */ |
| 65 | public static function is_valid_webhook_url( $url ) { |
| 66 | return is_string( $url ) && (bool) preg_match( self::WEBHOOK_URL_PATTERN, trim( $url ) ); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Post a new response to Slack. |
| 71 | * |
| 72 | * Registered as the `on_submission` callback for the Slack integration. |
| 73 | * |
| 74 | * @param array $context Submission context supplied by Integration_Dispatcher. |
| 75 | * @return void |
| 76 | */ |
| 77 | public static function send( array $context ) { |
| 78 | $settings = $context['settings'] ?? array(); |
| 79 | $url = isset( $settings['webhookUrl'] ) ? trim( (string) $settings['webhookUrl'] ) : ''; |
| 80 | |
| 81 | // A form can be enabled while the URL is missing or has been edited into something |
| 82 | // invalid. Nothing is sent, and nothing is logged that would leak the value. |
| 83 | if ( ! self::is_valid_webhook_url( $url ) ) { |
| 84 | return; |
| 85 | } |
| 86 | |
| 87 | $payload = self::build_payload( $context ); |
| 88 | |
| 89 | $response = wp_remote_post( |
| 90 | $url, |
| 91 | array( |
| 92 | 'timeout' => 10, |
| 93 | 'redirection' => 0, |
| 94 | 'headers' => array( 'Content-Type' => 'application/json' ), |
| 95 | 'body' => wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), |
| 96 | ) |
| 97 | ); |
| 98 | |
| 99 | self::record_result( $context['post_id'], $response ); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Build the Block Kit message for a response. |
| 104 | * |
| 105 | * @param array $context Submission context. |
| 106 | * @return array The Slack message payload. |
| 107 | */ |
| 108 | private static function build_payload( array $context ) { |
| 109 | $settings = $context['settings']; |
| 110 | $feedback = $context['feedback']; |
| 111 | $form = $context['form']; |
| 112 | $post_id = $context['post_id']; |
| 113 | $form_title = self::get_form_title( $form ); |
| 114 | |
| 115 | $response_url = Dashboard::get_single_response_admin_url( $post_id ); |
| 116 | |
| 117 | /* translators: %s is the title of the form that received a response. */ |
| 118 | $heading = sprintf( __( 'New response: %s', 'jetpack-forms' ), $form_title ); |
| 119 | |
| 120 | $blocks = array( |
| 121 | array( |
| 122 | 'type' => 'header', |
| 123 | 'text' => array( |
| 124 | 'type' => 'plain_text', |
| 125 | 'text' => self::truncate( $heading, self::HEADER_LIMIT ), |
| 126 | ), |
| 127 | ), |
| 128 | ); |
| 129 | |
| 130 | // Sending the response itself is opt-out per form: form responses routinely carry |
| 131 | // personal data, and copying it into Slack puts it under different retention and |
| 132 | // access rules than the site's own. |
| 133 | $include_content = ! isset( $settings['includeContent'] ) || (bool) $settings['includeContent']; |
| 134 | |
| 135 | if ( $include_content ) { |
| 136 | $blocks = array_merge( $blocks, self::build_field_blocks( $feedback ) ); |
| 137 | } |
| 138 | |
| 139 | $blocks[] = array( |
| 140 | 'type' => 'actions', |
| 141 | 'elements' => array( |
| 142 | array( |
| 143 | 'type' => 'button', |
| 144 | 'text' => array( |
| 145 | 'type' => 'plain_text', |
| 146 | 'text' => __( 'View response', 'jetpack-forms' ), |
| 147 | ), |
| 148 | 'url' => $response_url, |
| 149 | 'style' => 'primary', |
| 150 | ), |
| 151 | ), |
| 152 | ); |
| 153 | |
| 154 | $blocks[] = array( |
| 155 | 'type' => 'context', |
| 156 | 'elements' => array( |
| 157 | array( |
| 158 | 'type' => 'mrkdwn', |
| 159 | 'text' => sprintf( |
| 160 | '%s · %s', |
| 161 | self::escape( wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), |
| 162 | self::escape( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ) |
| 163 | ), |
| 164 | ), |
| 165 | ), |
| 166 | ); |
| 167 | |
| 168 | return array( |
| 169 | // Shown in notifications and by clients that cannot render blocks. |
| 170 | 'text' => self::truncate( $heading, self::SECTION_LIMIT ), |
| 171 | 'blocks' => $blocks, |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Turn a response's answers into Block Kit blocks. |
| 177 | * |
| 178 | * Short answers are grouped into a two-column grid; anything longer gets its own block so |
| 179 | * it stays readable. |
| 180 | * |
| 181 | * @param object $feedback The Feedback object for the response. |
| 182 | * @return array Block Kit blocks. |
| 183 | */ |
| 184 | private static function build_field_blocks( $feedback ) { |
| 185 | $blocks = array(); |
| 186 | $grid = array(); |
| 187 | |
| 188 | foreach ( (array) $feedback->get_compiled_fields( 'email', 'label|value' ) as $field ) { |
| 189 | $label = isset( $field['label'] ) ? (string) $field['label'] : ''; |
| 190 | $value = $field['value'] ?? ''; |
| 191 | |
| 192 | if ( is_array( $value ) ) { |
| 193 | $value = implode( ', ', array_map( 'strval', $value ) ); |
| 194 | } |
| 195 | |
| 196 | $value = trim( (string) $value ); |
| 197 | |
| 198 | if ( '' === $value ) { |
| 199 | continue; |
| 200 | } |
| 201 | |
| 202 | $rendered = sprintf( "*%s*\n%s", self::escape( $label ), self::escape( $value ) ); |
| 203 | |
| 204 | if ( mb_strlen( $value ) <= self::SHORT_VALUE_LENGTH && count( $grid ) < self::MAX_GRID_FIELDS ) { |
| 205 | $grid[] = array( |
| 206 | 'type' => 'mrkdwn', |
| 207 | 'text' => self::truncate( $rendered, self::FIELD_LIMIT ), |
| 208 | ); |
| 209 | continue; |
| 210 | } |
| 211 | |
| 212 | $blocks[] = array( |
| 213 | 'type' => 'section', |
| 214 | 'text' => array( |
| 215 | 'type' => 'mrkdwn', |
| 216 | 'text' => self::truncate( $rendered, self::SECTION_LIMIT ), |
| 217 | ), |
| 218 | ); |
| 219 | } |
| 220 | |
| 221 | if ( $grid ) { |
| 222 | // The grid renders before the long-form answers, matching the order fields are |
| 223 | // usually laid out: short identifying answers first, then the message body. |
| 224 | array_unshift( |
| 225 | $blocks, |
| 226 | array( |
| 227 | 'type' => 'section', |
| 228 | 'fields' => $grid, |
| 229 | ) |
| 230 | ); |
| 231 | } |
| 232 | |
| 233 | return $blocks; |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * The title to show for a form. |
| 238 | * |
| 239 | * @param object $form The Contact_Form the response belongs to. |
| 240 | * @return string |
| 241 | */ |
| 242 | private static function get_form_title( $form ) { |
| 243 | $title = isset( $form->attributes['formTitle'] ) ? trim( (string) $form->attributes['formTitle'] ) : ''; |
| 244 | |
| 245 | if ( '' !== $title ) { |
| 246 | return $title; |
| 247 | } |
| 248 | |
| 249 | return __( 'Untitled form', 'jetpack-forms' ); |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Escape the three characters Slack treats as markup. |
| 254 | * |
| 255 | * @param string $text Raw text. |
| 256 | * @return string |
| 257 | */ |
| 258 | private static function escape( $text ) { |
| 259 | return str_replace( array( '&', '<', '>' ), array( '&', '<', '>' ), (string) $text ); |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Shorten text to fit one of Slack's limits. |
| 264 | * |
| 265 | * @param string $text Text to shorten. |
| 266 | * @param int $limit Maximum length Slack accepts. |
| 267 | * @return string |
| 268 | */ |
| 269 | private static function truncate( $text, $limit ) { |
| 270 | if ( mb_strlen( $text ) <= $limit ) { |
| 271 | return $text; |
| 272 | } |
| 273 | |
| 274 | return mb_substr( $text, 0, $limit - 1 ) . '…'; |
| 275 | } |
| 276 | |
| 277 | /** |
| 278 | * Record whether the message went out, for stats and for debugging a silent integration. |
| 279 | * |
| 280 | * @param int $post_id Feedback post ID. |
| 281 | * @param array|\WP_Error $response The result of the request to Slack. |
| 282 | * @return void |
| 283 | */ |
| 284 | private static function record_result( $post_id, $response ) { |
| 285 | if ( is_wp_error( $response ) ) { |
| 286 | update_post_meta( $post_id, '_jetpack_forms_slack_error', sanitize_text_field( $response->get_error_message() ) ); |
| 287 | /** This action is documented in src/service/class-form-webhooks.php */ |
| 288 | do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_slack_request', 'error' ); |
| 289 | return; |
| 290 | } |
| 291 | |
| 292 | $code = wp_remote_retrieve_response_code( $response ); |
| 293 | |
| 294 | if ( $code >= 200 && $code < 300 ) { |
| 295 | delete_post_meta( $post_id, '_jetpack_forms_slack_error' ); |
| 296 | /** This action is documented in src/service/class-form-webhooks.php */ |
| 297 | do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_slack_request', 'success' ); |
| 298 | return; |
| 299 | } |
| 300 | |
| 301 | // Slack answers a bad or revoked webhook with a short plain-text reason such as |
| 302 | // "no_service" or "invalid_payload", which is what someone debugging needs to see. |
| 303 | update_post_meta( |
| 304 | $post_id, |
| 305 | '_jetpack_forms_slack_error', |
| 306 | sanitize_text_field( sprintf( 'HTTP %d: %s', $code, wp_remote_retrieve_body( $response ) ) ) |
| 307 | ); |
| 308 | /** This action is documented in src/service/class-form-webhooks.php */ |
| 309 | do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_slack_request', 'failed' ); |
| 310 | } |
| 311 | } |