prefix . 'runline_subscribers'; } /** * The table of subscribers (created with the statistics table). */ function runline_signup_install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = runline_subscribers_table(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, email varchar(190) NOT NULL, bar_id bigint(20) unsigned NOT NULL DEFAULT 0, created datetime NOT NULL, sent_to varchar(20) NOT NULL DEFAULT '', PRIMARY KEY (id), UNIQUE KEY email (email) ) {$wpdb->get_charset_collate()};" ); } add_action( 'runline_install', 'runline_signup_install' ); /** * E-mail services: key => name. */ function runline_signup_services() { return array( 'local' => __( 'Only in WordPress', 'runline' ), 'mailchimp' => 'Mailchimp', 'mailerlite' => 'MailerLite', 'brevo' => 'Brevo', ); } /** * Adds a subscriber: in WordPress and, if set, in the e-mail service of the bar. * * @param int $bar_id Bar ID. * @param string $email E-mail address (valid). * @param array $signup Signup settings of the bar. * @return true|WP_Error */ function runline_signup_add( $bar_id, $email, $signup ) { global $wpdb; $sent = ''; if ( 'local' !== $signup['service'] ) { $result = runline_signup_send( $signup['service'], $signup['list'], $email ); if ( is_wp_error( $result ) ) { update_option( 'runline_signup_error', $signup['service'] . ': ' . $result->get_error_message(), false ); } else { $sent = $signup['service']; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- our own table. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (email, bar_id, created, sent_to) VALUES (%s, %d, %s, %s) ON DUPLICATE KEY UPDATE bar_id = VALUES(bar_id)', runline_subscribers_table(), $email, $bar_id, current_time( 'mysql' ), $sent ) ); do_action( 'runline_subscribed', $email, $bar_id, $signup ); return true; } /** * Sends a subscriber to an e-mail service. * * @param string $service mailchimp | mailerlite | brevo. * @param string $list_id Audience / group / list ID. * @param string $email E-mail address. * @return true|WP_Error */ function runline_signup_send( $service, $list_id, $email ) { $options = runline_options(); $key = isset( $options[ $service . '_key' ] ) ? $options[ $service . '_key' ] : ''; if ( '' === $key ) { return new WP_Error( 'runline_no_key', __( 'No API key in Runline → Settings.', 'runline' ) ); } $json = array( 'Content-Type' => 'application/json' ); switch ( $service ) { case 'mailchimp': $dc = substr( $key, strrpos( $key, '-' ) + 1 ); $response = wp_remote_post( 'https://' . rawurlencode( $dc ) . '.api.mailchimp.com/3.0/lists/' . rawurlencode( $list_id ) . '/members', array( 'timeout' => 10, 'headers' => $json + array( 'Authorization' => 'Basic ' . base64_encode( 'runline:' . $key ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP basic auth. 'body' => wp_json_encode( array( 'email_address' => $email, 'status' => 'pending', // Double opt-in: Mailchimp sends the confirmation e-mail. ) ), ) ); $ok = array( 200, 201 ); break; case 'mailerlite': $body = array( 'email' => $email ); if ( '' !== $list_id ) { $body['groups'] = array( $list_id ); } $response = wp_remote_post( 'https://connect.mailerlite.com/api/subscribers', array( 'timeout' => 10, 'headers' => $json + array( 'Authorization' => 'Bearer ' . $key ), 'body' => wp_json_encode( $body ), ) ); $ok = array( 200, 201 ); break; case 'brevo': $body = array( 'email' => $email, 'updateEnabled' => true, ); if ( '' !== $list_id ) { $body['listIds'] = array( (int) $list_id ); } $response = wp_remote_post( 'https://api.brevo.com/v3/contacts', array( 'timeout' => 10, 'headers' => $json + array( 'api-key' => $key ), 'body' => wp_json_encode( $body ), ) ); $ok = array( 200, 201, 204 ); break; default: return true; } if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 'mailchimp' === $service && 400 === $code ) { // Already on the list: fine. Any other 400 (wrong list, invalid address…) is an error. $answer = json_decode( wp_remote_retrieve_body( $response ), true ); if ( is_array( $answer ) && isset( $answer['title'] ) && 'Member Exists' === $answer['title'] ) { return true; } } return in_array( $code, $ok, true ) ? true : new WP_Error( 'runline_service', 'HTTP ' . $code . ' ' . wp_strip_all_tags( wp_remote_retrieve_body( $response ) ) ); } add_action( 'rest_api_init', function () { register_rest_route( RUNLINE_REST_NS, '/subscribe', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'runline_rest_subscribe', 'permission_callback' => '__return_true', 'args' => array( 'bar' => array( 'type' => 'integer', 'required' => true, ), 'email' => array( 'type' => 'string', 'required' => true, ), 'consent' => array( 'type' => 'boolean', 'default' => false, ), 'website' => array( 'type' => 'string', 'default' => '', ), ), ) ); } ); /** * Signup from the browser of a visitor. Public (pages may be cached), protected by a hidden field and a limit of * 5 tries per 10 minutes per address (the address is only hashed and kept for those 10 minutes). */ function runline_rest_subscribe( WP_REST_Request $request ) { $bar = (int) $request['bar']; if ( RUNLINE_POST_TYPE !== get_post_type( $bar ) || '1' !== get_post_meta( $bar, RUNLINE_ACTIVE_META, true ) ) { return new WP_Error( 'runline_bar', __( 'This form is not available.', 'runline' ), array( 'status' => 404 ) ); } $settings = runline_get_settings( $bar ); $signup = $settings['signup']; if ( ! $signup['on'] ) { return new WP_Error( 'runline_bar', __( 'This form is not available.', 'runline' ), array( 'status' => 404 ) ); } // Bots fill in the hidden field. if ( '' !== (string) $request['website'] ) { return rest_ensure_response( array( 'ok' => true ) ); } // Limits per 10 minutes: 5 tries for one address from one IP, 30 tries from one IP in all. Behind a proxy // (Cloudflare…) every visitor may share one IP, so the address part keeps people apart; see runline_client_ip(). $ip = runline_client_ip(); $email = sanitize_email( (string) $request['email'] ); $keys = array( 'runline_su_' . md5( wp_salt() . $ip . '|' . strtolower( $email ) ) => 5, 'runline_si_' . md5( wp_salt() . $ip ) => 30, ); foreach ( $keys as $limit => $max ) { if ( (int) get_transient( $limit ) >= $max ) { return new WP_Error( 'runline_limit', __( 'Too many tries. Please wait a few minutes.', 'runline' ), array( 'status' => 429 ) ); } } foreach ( $keys as $limit => $max ) { set_transient( $limit, (int) get_transient( $limit ) + 1, 10 * MINUTE_IN_SECONDS ); } if ( ! is_email( $email ) ) { return new WP_Error( 'runline_email', __( 'Please enter a valid e-mail address.', 'runline' ), array( 'status' => 400 ) ); } if ( '' !== $signup['consent'] && ! $request['consent'] ) { return new WP_Error( 'runline_consent', __( 'Please tick the box to agree.', 'runline' ), array( 'status' => 400 ) ); } runline_signup_add( $bar, $email, $signup ); return rest_ensure_response( array( 'ok' => true ) ); } /** * IP address of the visitor for the signup limits: REMOTE_ADDR. Headers such as X-Forwarded-For or CF-Connecting-IP * can be faked, so they are not trusted; a site behind a proxy it trusts can use the filter: * add_filter( 'runline_client_ip', fn() => $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['REMOTE_ADDR'] ); */ function runline_client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; return (string) apply_filters( 'runline_client_ip', $ip ); } /** * HTML of the signup form of a bar ("" when off). * * @param int $bar_id Bar ID. * @param array $settings Bar settings. * @param bool $preview Editor preview (no address to send to). */ function runline_signup_form( $bar_id, $settings, $preview ) { $s = $settings['signup']; if ( ! $s['on'] ) { return ''; } $consent = ''; if ( '' !== $s['consent'] ) { $consent = ''; } return '
' . '' . $consent . '' . '' . '' . '
'; } /* Admin: Runline → Subscribers. */ add_action( 'admin_menu', function () { add_submenu_page( 'runline', __( 'Subscribers', 'runline' ), __( 'Subscribers', 'runline' ), 'manage_options', 'runline-subscribers', 'runline_subscribers_page' ); }, 20 ); function runline_subscribers_page() { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, email, bar_id, created, sent_to FROM %i ORDER BY id DESC LIMIT 500', runline_subscribers_table() ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- our own table in the admin. $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', runline_subscribers_table() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $error = get_option( 'runline_signup_error' ); ?>

get_results( $wpdb->prepare( 'SELECT email, bar_id, created, sent_to FROM %i ORDER BY id', runline_subscribers_table() ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=runline-subscribers-' . gmdate( 'Y-m-d' ) . '.csv' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming the CSV. fputcsv( $out, array( 'email', 'bar', 'date', 'sent_to' ) ); foreach ( (array) $rows as $row ) { // A leading = + - @ would be a formula in spreadsheets. $email = preg_match( '/^[=+\-@]/', $row['email'] ) ? "'" . $row['email'] : $row['email']; fputcsv( $out, array( $email, get_post_field( 'post_title', (int) $row['bar_id'], 'raw' ), $row['created'], $row['sent_to'] ) ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } ); add_action( 'admin_post_runline_subscriber_delete', function () { $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked on the next line. check_admin_referer( 'runline_subscriber_delete_' . $id ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You cannot do this.', 'runline' ) ); } global $wpdb; $wpdb->delete( runline_subscribers_table(), array( 'id' => $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery wp_safe_redirect( admin_url( 'admin.php?page=runline-subscribers' ) ); exit; } ); // Privacy tools of WordPress (Tools → Export / Erase Personal Data) know about the subscribers. add_filter( 'wp_privacy_personal_data_erasers', function ( $erasers ) { $erasers['runline'] = array( 'eraser_friendly_name' => __( 'Runline subscribers', 'runline' ), 'callback' => function ( $email ) { global $wpdb; $removed = (bool) $wpdb->delete( runline_subscribers_table(), array( 'email' => $email ), array( '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return array( 'items_removed' => $removed, 'items_retained' => false, 'messages' => array(), 'done' => true, ); }, ); return $erasers; } ); add_filter( 'wp_privacy_personal_data_exporters', function ( $exporters ) { $exporters['runline'] = array( 'exporter_friendly_name' => __( 'Runline subscribers', 'runline' ), 'callback' => function ( $email ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT email, created FROM %i WHERE email = %s', runline_subscribers_table(), $email ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $data = array(); if ( $row ) { $data[] = array( 'group_id' => 'runline', 'group_label' => __( 'Runline subscribers', 'runline' ), 'item_id' => 'runline-' . md5( $email ), 'data' => array( array( 'name' => __( 'E-mail', 'runline' ), 'value' => $row['email'], ), array( 'name' => __( 'Date', 'runline' ), 'value' => $row['created'], ), ), ); } return array( 'data' => $data, 'done' => true, ); }, ); return $exporters; } );