prefix . 'fixlight_reports';
}
/**
* Menu entry under Fixlight.
*/
public static function menu() {
$open = self::count_open();
$label = __( 'Reports', 'fixlight' ) . ( $open ? ' ' . (int) $open . ' ' . esc_html__( 'waiting for an answer', 'fixlight' ) . '' : '' );
add_submenu_page( Admin::SLUG, __( 'Accessibility reports', 'fixlight' ), $label, 'manage_options', self::SLUG, array( __CLASS__, 'page' ) );
}
/**
* A signed time stamp: forms sent faster than a person can type are refused.
*
* @return string
*/
private static function stamp() {
$time = (string) time();
return $time . '.' . substr( hash_hmac( 'sha256', $time, wp_salt( 'nonce' ) ), 0, 20 );
}
/**
* Whether a time stamp is genuine, older than 3 seconds and newer than 30 days.
*
* @param string $stamp Time stamp from the form.
* @return bool
*/
private static function stamp_ok( $stamp ) {
$parts = explode( '.', (string) $stamp );
if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) ) {
return false;
}
$age = time() - (int) $parts[0];
return $age >= 3 && $age <= 30 * DAY_IN_SECONDS && hash_equals( substr( hash_hmac( 'sha256', $parts[0], wp_salt( 'nonce' ) ), 0, 20 ), $parts[1] );
}
/**
* [fixlight_report_form]
*
* @return string
*/
public static function form() {
$result = isset( $_GET['fixlight_report'] ) ? sanitize_key( wp_unslash( $_GET['fixlight_report'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks which message to show.
$here = ( is_ssl() ? 'https://' : 'http://' ) . ( isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/' );
$here = remove_query_arg( 'fixlight_report', $here );
$out = '';
if ( 'sent' === $result ) {
$out .= '
' . esc_html__( 'Thank you. We got your message and will reply soon.', 'fixlight' ) . '
';
} elseif ( '' !== $result ) {
$messages = array(
'empty' => __( 'Please describe the problem, then send the form again.', 'fixlight' ),
'limit' => __( 'Too many messages were sent from here. Please try again in an hour, or email us.', 'fixlight' ),
'spam' => __( 'The form could not be sent. Please wait a few seconds and send it again, or email us.', 'fixlight' ),
);
$out .= '' . esc_html( isset( $messages[ $result ] ) ? $messages[ $result ] : $messages['spam'] ) . '
';
}
self::trap_style();
$out .= '';
return $out;
}
/**
* Hides the spam trap field. Added when a form is shown, so other pages get no CSS.
*/
private static function trap_style() {
if ( is_admin() || wp_style_is( 'fixlight-report', 'enqueued' ) ) {
return;
}
wp_register_style( 'fixlight-report', false, array(), FIXLIGHT_VERSION );
wp_enqueue_style( 'fixlight-report' );
wp_add_inline_style( 'fixlight-report', '.fixlight-report-extra{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}' );
}
/**
* Back to the page the form was on, with a result.
*
* @param string $page Page address.
* @param string $result Result code.
*/
private static function back_to( $page, $result ) {
$target = wp_validate_redirect( $page, home_url( '/' ) );
wp_safe_redirect( add_query_arg( 'fixlight_report', $result, $target ) . '#fixlight-report-status' );
exit;
}
/**
* Receives a report.
*/
public static function submit() {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public form; the nonce is checked below for logged-in users only. Visitors also get a signed time stamp, a hidden field and a limit per visitor.
$page = isset( $_POST['fixlight_page'] ) ? esc_url_raw( wp_unslash( $_POST['fixlight_page'] ) ) : '';
$message = isset( $_POST['fixlight_message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['fixlight_message'] ) ) : '';
$contact = isset( $_POST['fixlight_contact'] ) ? sanitize_text_field( wp_unslash( $_POST['fixlight_contact'] ) ) : '';
$trap = isset( $_POST['fixlight_website'] ) ? sanitize_text_field( wp_unslash( $_POST['fixlight_website'] ) ) : '';
$stamp = isset( $_POST['fixlight_t'] ) ? sanitize_text_field( wp_unslash( $_POST['fixlight_t'] ) ) : '';
$nonce = isset( $_POST['fixlight_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['fixlight_nonce'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
// Only addresses on this site are kept, emailed and linked.
$page = '' !== $page ? wp_validate_redirect( $page, home_url( '/' ) ) : home_url( '/' );
if ( '' !== $trap || ! self::stamp_ok( $stamp ) ) {
self::back_to( $page, 'spam' );
}
// Cached pages give visitors stale nonces, so only logged-in users' nonces are checked.
if ( is_user_logged_in() && ! wp_verify_nonce( $nonce, 'fixlight_report' ) ) {
self::back_to( $page, 'spam' );
}
if ( mb_strlen( trim( $message ) ) < 5 ) {
self::back_to( $page, 'empty' );
}
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$limit = 'fixlight_report_' . substr( hash_hmac( 'sha256', $ip . '|' . gmdate( 'YmdH' ), wp_salt( 'nonce' ) ), 0, 20 );
$count = (int) get_transient( $limit );
if ( $count >= self::PER_HOUR ) {
self::back_to( $page, 'limit' );
}
set_transient( $limit, $count + 1, HOUR_IN_SECONDS );
global $wpdb;
$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Own table.
self::table(),
array(
'created_at' => current_time( 'mysql', true ),
'page_url' => mb_substr( $page, 0, 500 ),
'message' => mb_substr( $message, 0, 5000 ),
'contact' => mb_substr( $contact, 0, 200 ),
'status' => 'new',
)
);
$id = (int) $wpdb->insert_id;
// A limit for the whole site, so a flood of reports cannot flood the inbox.
// Reports over it are still saved and listed on the Reports screen.
$mail_key = 'fixlight_report_mails_' . gmdate( 'YmdH' );
$mailed = (int) get_transient( $mail_key );
if ( $mailed < self::EMAILS_PER_HOUR ) {
set_transient( $mail_key, $mailed + 1, HOUR_IN_SECONDS );
$settings = Statement::settings();
$to = is_email( $settings['email'] ) ? $settings['email'] : get_option( 'admin_email' );
wp_mail(
$to,
/* translators: %s: site name. */
sprintf( __( 'Accessibility problem reported on %s', 'fixlight' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),
implode(
"\n\n",
array_filter(
array(
$message,
/* translators: %s: page address. */
sprintf( __( 'Page: %s', 'fixlight' ), $page ),
/* translators: %s: email or phone. */
'' !== $contact ? sprintf( __( 'Contact: %s', 'fixlight' ), $contact ) : __( 'No contact given.', 'fixlight' ),
/* translators: %s: link to the reports screen. */
sprintf( __( 'See and answer it: %s', 'fixlight' ), admin_url( 'admin.php?page=' . self::SLUG ) ),
)
)
)
);
}
/**
* Fires after a visitor reports an accessibility problem.
*
* @since 0.1.0
*
* @param int $id Report ID.
*/
do_action( 'fixlight_report_received', $id );
self::back_to( $page, 'sent' );
}
/**
* Number of reports not yet answered.
*
* @return int
*/
public static function count_open() {
global $wpdb;
return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', self::table(), 'new' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
}
/**
* Reports, newest first.
*
* @param int $limit Maximum.
* @return array[]
*/
public static function recent( $limit = 100 ) {
global $wpdb;
return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT %d', self::table(), $limit ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
}
/**
* The Reports screen.
*/
public static function page() {
$rows = self::recent();
Admin::header( self::SLUG, __( 'Accessibility reports', 'fixlight' ) );
$msg = get_transient( 'fixlight_reports_notice_' . get_current_user_id() );
if ( is_string( $msg ) && '' !== $msg ) {
delete_transient( 'fixlight_reports_notice_' . get_current_user_id() );
echo '';
}
/**
* Prints above the reports list (Fixlight Pro adds response times).
*
* @since 0.1.0
*
* @param array[] $rows Reports.
*/
do_action( 'fixlight_reports_before_list', $rows );
?>
403 ) );
}
check_admin_referer( 'fixlight_report_update_' . $id );
$do = isset( $_POST['do'] ) ? sanitize_key( wp_unslash( $_POST['do'] ) ) : '';
$now = current_time( 'mysql', true );
$notice = __( 'Saved.', 'fixlight' );
global $wpdb;
if ( 'replied' === $do ) {
$wpdb->query( $wpdb->prepare( 'UPDATE %i SET status = %s, replied_at = COALESCE(replied_at, %s) WHERE id = %d AND status = %s', self::table(), 'replied', $now, $id, 'new' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
} elseif ( 'closed' === $do ) {
$wpdb->query( $wpdb->prepare( 'UPDATE %i SET status = %s, replied_at = COALESCE(replied_at, %s), closed_at = %s WHERE id = %d', self::table(), 'closed', $now, $now, $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
} elseif ( 'delete' === $do ) {
self::delete( $id );
$notice = __( 'Deleted.', 'fixlight' );
}
set_transient( 'fixlight_reports_notice_' . get_current_user_id(), $notice, MINUTE_IN_SECONDS );
wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG ) );
exit;
}
/**
* Deletes a report. Reports cannot be recovered.
*
* @param int $id Report ID.
* @return bool Whether a report was deleted.
*/
public static function delete( $id ) {
global $wpdb;
return (bool) $wpdb->delete( self::table(), array( 'id' => (int) $id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
}
/**
* Reports whose contact detail is this email address, oldest first.
*
* @param string $email Email address.
* @param int $offset Reports to skip.
* @return array[]
*/
private static function by_email( $email, $offset = 0 ) {
$email = trim( (string) $email );
if ( '' === $email ) {
return array();
}
global $wpdb;
return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE LOWER(TRIM(contact)) = LOWER(%s) ORDER BY id LIMIT %d OFFSET %d', self::table(), $email, self::PRIVACY_BATCH, $offset ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
}
/**
* Adds reports to Tools > Export Personal Data.
*
* @param array $exporters Exporters.
* @return array
*/
public static function register_exporter( $exporters ) {
$exporters['fixlight-reports'] = array(
'exporter_friendly_name' => __( 'Fixlight accessibility reports', 'fixlight' ),
'callback' => array( __CLASS__, 'export' ),
);
return $exporters;
}
/**
* Adds reports to Tools > Erase Personal Data.
*
* @param array $erasers Erasers.
* @return array
*/
public static function register_eraser( $erasers ) {
$erasers['fixlight-reports'] = array(
'eraser_friendly_name' => __( 'Fixlight accessibility reports', 'fixlight' ),
'callback' => array( __CLASS__, 'erase' ),
);
return $erasers;
}
/**
* Reports sent with this email address, for a personal data export.
*
* @param string $email Email address.
* @param int $page Page, from 1.
* @return array
*/
public static function export( $email, $page = 1 ) {
$rows = self::by_email( $email, ( max( 1, (int) $page ) - 1 ) * self::PRIVACY_BATCH );
$data = array();
foreach ( $rows as $row ) {
$data[] = array(
'group_id' => 'fixlight-reports',
'group_label' => __( 'Accessibility reports', 'fixlight' ),
'item_id' => 'fixlight-report-' . $row['id'],
'data' => array(
array(
'name' => __( 'Date', 'fixlight' ),
'value' => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $row['created_at'] . ' UTC' ) ),
),
array(
'name' => __( 'Page', 'fixlight' ),
'value' => $row['page_url'],
),
array(
'name' => __( 'Message', 'fixlight' ),
'value' => $row['message'],
),
array(
'name' => __( 'Contact', 'fixlight' ),
'value' => $row['contact'],
),
array(
'name' => __( 'Status', 'fixlight' ),
'value' => self::status_label( $row ),
),
),
);
}
return array(
'data' => $data,
'done' => count( $rows ) < self::PRIVACY_BATCH,
);
}
/**
* Deletes the reports sent with this email address, for a personal data
* erasure. Each call deletes the first batch left, so the page is not needed.
*
* @param string $email Email address.
* @return array
*/
public static function erase( $email ) {
$rows = self::by_email( $email );
$removed = 0;
foreach ( $rows as $row ) {
$removed += (int) self::delete( $row['id'] );
}
return array(
'items_removed' => $removed > 0,
'items_retained' => $removed < count( $rows ),
'messages' => array(),
// Stops when a batch could not be deleted, so the same batch is not tried forever.
'done' => count( $rows ) < self::PRIVACY_BATCH || $removed < count( $rows ),
);
}
/**
* Suggested text for Settings > Privacy > Policy guide.
*/
public static function privacy_policy() {
wp_add_privacy_policy_content(
'Fixlight',
'' . esc_html__( 'If you use the form to report an accessibility problem, we collect your message, the address of the page you were on, and the email address or phone number you choose to give us, if any. The report is stored in this site\'s database and emailed to the site\'s contact address for accessibility. Your IP address is not stored; to limit how many reports can be sent, a one-way code made from it is kept for one hour. We keep each report until we delete it.', 'fixlight' ) . '
'
);
}
}