'Bearer ' . self::api_key(), 'Accept' => 'application/json', 'X-HTform-Site' => home_url(), 'User-Agent' => 'HTform-WordPress/' . HTFORM_VERSION . '; ' . home_url(), ); } /** * Perform a request with retry on temporary failures (network, 429, 5xx). * * @param string $method HTTP method. * @param string $path Path below /api/public/v1. * @param array $args Extra wp_remote_request args. * @return array{ok:bool,status:int,body:array,error:string} */ public static function request( $method, $path, $args = array(), $retry_reads = true ) { if ( ! self::is_configured() ) { return array( 'ok' => false, 'status' => 0, 'body' => array(), 'error' => __( 'HTforms is not connected.', 'htforms-builder' ) ); } $url = self::base_url() . '/api/public/v1' . $path; $args = wp_parse_args( $args, array( 'method' => $method, 'timeout' => 15, 'headers' => array(), ) ); $args['headers'] = array_merge( self::headers(), $args['headers'] ); $attempts = 0; do { $attempts++; $res = wp_remote_request( esc_url_raw( $url ), $args ); if ( is_wp_error( $res ) ) { $status = 0; $error = $res->get_error_message(); } else { $status = (int) wp_remote_retrieve_response_code( $res ); $error = ''; } $retry = $retry_reads && ( 0 === $status || 429 === $status || $status >= 500 ) && $attempts < 3 && 'GET' === $method; if ( $retry ) { usleep( 250000 * $attempts ); } } while ( $retry ); if ( 0 === $status ) { return array( 'ok' => false, 'status' => 0, 'body' => array(), 'error' => $error ); } $body = json_decode( wp_remote_retrieve_body( $res ), true ); $body = is_array( $body ) ? $body : array(); $ok = $status >= 200 && $status < 300; return array( 'ok' => $ok, 'status' => $status, 'body' => $body, 'error' => $ok ? '' : ( isset( $body['error']['message'] ) ? (string) $body['error']['message'] : 'HTTP ' . $status ), ); } /** * Check the connection and remember the result. * * @return array */ public static function status() { $r = self::request( 'GET', '/status' ); update_option( self::OPT_LAST, array( 'ok' => $r['ok'], 'error' => $r['error'], 'workspace' => $r['ok'] && isset( $r['body']['data']['workspace'] ) ? sanitize_text_field( $r['body']['data']['workspace'] ) : '', 'plan' => $r['ok'] && isset( $r['body']['data']['plan'] ) ? sanitize_key( $r['body']['data']['plan'] ) : '', 'time' => time(), ), false ); return $r; } /** * Cache key namespaced by version so we can flush all at once. * * @param string $key Key. * @return string */ private static function cache_key( $key ) { return 'htform_' . (int) get_option( self::CACHE_VERSION, 1 ) . '_' . md5( $key ); } /** * Invalidate all cached API responses. */ public static function flush_cache() { update_option( self::CACHE_VERSION, (int) get_option( self::CACHE_VERSION, 1 ) + 1, false ); } /** * List forms in the connected workspace (cached). * * @return array */ public static function list_forms() { $ck = self::cache_key( 'forms' ); $r = self::request( 'GET', '/forms' ); if ( ! $r['ok'] || ! isset( $r['body']['data'] ) || ! is_array( $r['body']['data'] ) ) { return array(); } set_transient( $ck, $r['body']['data'], self::CACHE_TTL ); return $r['body']['data']; } /** * Fetch a published form definition. Verified server-side to belong to this key's workspace. * * @param string $id Form ID. * @return array|null */ public static function cached_form( $id ) { if ( ! self::valid_form_id( $id ) ) { return null; } $cached = get_transient( self::cache_key( 'form_' . $id ) ); if ( is_array( $cached ) ) { return $cached; } // Last known published copy never expires, so pages render instantly. $stored = get_option( 'htform_form_' . $id ); return is_array( $stored ) ? $stored : null; } public static function get_form( $id, $fresh = false ) { if ( ! self::valid_form_id( $id ) ) { return null; } $ck = self::cache_key( 'form_' . $id ); $cached = get_transient( $ck ); if ( ! $fresh && is_array( $cached ) ) { return $cached; } $r = self::request( 'GET', '/forms/' . rawurlencode( $id ), $fresh ? array( 'timeout' => 5 ) : array( 'timeout' => 4 ), false ); if ( ! $r['ok'] || empty( $r['body']['data'] ) ) { if ( $r['status'] >= 400 && $r['status'] < 500 ) { delete_option( 'htform_form_' . $id ); } return null; } set_transient( $ck, $r['body']['data'], self::CACHE_TTL ); update_option( 'htform_form_' . $id, $r['body']['data'], false ); return $r['body']['data']; } /** * Forward a submission to HTforms as multipart/form-data. * * @param string $id Form ID (already validated). * @param array $fields Scalar/array values keyed by field id. * @param array $files List of [ 'field' => id, 'path' => tmp, 'name' => name, 'type' => mime ]. * @param string $ip Visitor IP for per-visitor rate limits. * @return array */ public static function submit( $id, $fields, $files, $ip ) { $boundary = 'htform' . wp_generate_password( 24, false ); $body = ''; foreach ( $fields as $name => $value ) { foreach ( (array) $value as $v ) { $key = is_array( $value ) ? $name . '[]' : $name; $body .= '--' . $boundary . "\r\n"; $body .= 'Content-Disposition: form-data; name="' . str_replace( '"', '', $key ) . "\"\r\n\r\n"; $body .= (string) $v . "\r\n"; } } foreach ( $files as $f ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a validated PHP upload tmp file. $content = file_get_contents( $f['path'] ); if ( false === $content ) { continue; } $body .= '--' . $boundary . "\r\n"; $body .= 'Content-Disposition: form-data; name="' . str_replace( '"', '', $f['field'] ) . '"; filename="' . str_replace( '"', '', $f['name'] ) . "\"\r\n"; $body .= 'Content-Type: ' . $f['type'] . "\r\n\r\n"; $body .= $content . "\r\n"; } $body .= '--' . $boundary . "--\r\n"; return self::request( 'POST', '/forms/' . rawurlencode( $id ) . '/submissions', array( 'timeout' => 30, 'body' => $body, 'headers' => array( 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, 'X-HTform-Visitor-IP' => $ip, 'X-HTform-Mail' => class_exists( 'HTForm_Mailer' ) ? HTForm_Mailer::settings()['mode'] : 'wordpress', 'X-HTform-Visitor-UA' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 300 ) : '', ), ) ); } /** * Entries for the connected workspace (never cached: admin data). * * @param array $args Query args (form_id, status, q, field, from, to, days, page, per_page). * @return array{ok:bool,status:int,body:array,error:string} */ public static function entries( $args ) { $args = array_filter( array_map( 'strval', $args ), 'strlen' ); return self::request( 'GET', '/entries?' . http_build_query( $args ), array( 'timeout' => 20 ) ); } /** * Bulk entry action. * * @param string[] $ids Entry IDs. * @param string $action read|unread|star|unstar|spam|notspam|trash. * @return array */ public static function entries_action( $ids, $action ) { return self::request( 'POST', '/entries', array( 'body' => wp_json_encode( array( 'ids' => array_values( $ids ), 'action' => $action ) ), 'headers' => array( 'Content-Type' => 'application/json' ), ) ); } }