Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,7 @@
padding-top: 16px;
}
}

.asb-checkbox-group-disabled {
color: #8c8f94;
}
47 changes: 47 additions & 0 deletions assets/js/pre-release-notice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Dismiss the pre-release notice.
*/
( () => {
'use strict';

document.addEventListener( 'DOMContentLoaded', () => {
const notices = document.querySelectorAll( '[data-antispam-bee-pre-release-notice]' );

for ( const notice of notices ) {
notice.addEventListener( 'click', ( event ) => {
const dismiss = event.target.closest(
'.notice-dismiss, [data-antispam-bee-dismiss-link]'
);

if ( ! dismiss ) {
return;
}

// Core already hides and removes the notice for its own close
// button, so only block the Dismiss link's default navigation.
if ( ! dismiss.closest( '.notice-dismiss' ) ) {
event.preventDefault();
}

const href = notice.dataset.antispamBeeDismissLink;

// The notice may already be gone once core has handled the close
// button, so only hide it here when the AJAX call finished before
// core's own removal.
wp.ajax.post( antispamBeePreReleaseNotice.action, {
_ajax_nonce: antispamBeePreReleaseNotice.nonce,
} )
.done( () => {
if ( notice.isConnected ) {
notice.style.display = 'none';
}
} )
.fail( () => {
if ( href ) {
window.location.href = href;
}
} );
} );
}
} );
} )();
17 changes: 14 additions & 3 deletions src/Admin/Fields/CheckboxGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,25 @@ public function render(): void {
'<p class="asb-checkbox-group-label"><strong>%s</strong></p>',
$this->get_label()
);

$disabled_keys = isset( $this->option['disabled_keys'] ) && is_array( $this->option['disabled_keys'] )
? $this->option['disabled_keys']
: [];

foreach ( $options as $key => $value ) {
$is_disabled = isset( $disabled_keys[ $key ] );
$label_class = $is_disabled ? ' class="asb-checkbox-group-disabled"' : '';
$disabled = $is_disabled ? ' disabled' : '';

printf(
'<label for="%1$s">
<input type="checkbox" id="%1$s" name="%1$s" %2$s />%3$s
'<label for="%1$s"%4$s>
<input type="checkbox" id="%1$s" name="%1$s" %2$s%5$s />%3$s
</label><br>',
esc_attr( $this->get_name() . '[' . $key . ']' ),
checked( 'on', $this->get_custom_value( $key ), false ),
esc_html( $value )
esc_html( $value ),
$label_class,
$disabled
);
}
$this->maybe_show_description();
Expand Down
230 changes: 230 additions & 0 deletions src/Admin/PreReleaseNotice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php
/**
* Pre-release notice.
*
* @package AntispamBee\Admin
*/

namespace AntispamBee\Admin;

use const AntispamBee\MAIN_PLUGIN_FILE;
use const AntispamBee\PLUGIN_VERSION;

/**
* Show a dismissible notice while the installed version is a pre-release.
*/
class PreReleaseNotice {

/**
* The user meta key that stores whether the notice was dismissed.
*
* @var string
*/
public const DISMISSED_META_KEY = 'antispam_bee_pre_release_notice_dismissed';

/**
* The nonce action used to dismiss the notice.
*
* @var string
*/
public const DISMISS_ACTION = 'antispam_bee_dismiss_pre_release_notice';

/**
* The admin page hook suffix of the plugins list.
*
* @var string
*/
public const PLUGINS_PAGE = 'plugins.php';

/**
* The URL of the issue tracker used as the feedback channel.
*
* @var string
*/
public const FEEDBACK_URL = 'https://github.com/pluginkollektiv/antispam-bee/issues';

/**
* Register the notice hooks.
*/
public static function init(): void {
add_action( 'admin_notices', [ __CLASS__, 'admin_notices' ] );
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'maybe_enqueue_assets' ] );
}

/**
* Register the dismissal handlers.
*
* The handlers must be available whenever a request hits them, so they are
* registered here rather than in {@see init()}, which the bootloader skips
* during AJAX requests.
*/
public static function always_init(): void {
add_action( 'wp_ajax_' . self::DISMISS_ACTION, [ __CLASS__, 'handle_dismiss' ] );
add_action( 'admin_post_' . self::DISMISS_ACTION, [ __CLASS__, 'handle_dismiss' ] );
}

/**
* Render the notice, reading the page WordPress does not pass along.
*
* `admin_notices` invokes callbacks without the hook suffix, so it is read
* from the global that WordPress sets for admin pages.
*/
public static function admin_notices(): void {
global $hook_suffix;

self::maybe_render( (string) $hook_suffix );
}

/**
* Render the notice on the settings page and the plugins list, unless the
* installed version is stable or the user already dismissed it.
*
* @param string $hook_suffix The current admin page hook suffix.
*/
public static function maybe_render( string $hook_suffix = '' ): void {
if ( ! self::should_show( $hook_suffix ) ) {
return;
}

$dismiss_url = wp_nonce_url(
admin_url( 'admin-post.php?action=' . self::DISMISS_ACTION ),
self::DISMISS_ACTION
);

$version_label = wp_kses_post(
sprintf(
/* translators: %s: installed version, already wrapped in code tags. */
__( 'You are running version %s.', 'antispam-bee' ),
'<code>' . esc_html( PLUGIN_VERSION ) . '</code>'
)
);

printf(
'<div class="notice notice-warning is-dismissible" data-antispam-bee-pre-release-notice data-antispam-bee-dismiss-link="%5$s">' .
'<p><strong>%1$s</strong></p>' .
'<p>%2$s</p>' .
'<p>%3$s</p>' .
'<p><a class="button" href="%4$s" target="_blank" rel="noopener noreferrer">%6$s</a> ' .
'<a class="button-link" href="%5$s">%7$s</a></p>' .
'</div>',
esc_html__( 'Antispam Bee is a pre-release version', 'antispam-bee' ),
// The version label is sanitized by wp_kses_post() above.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$version_label,
esc_html__(
'This is a pre-release and not intended for production. Please test it and report any issues you find.',
'antispam-bee'
),
esc_url( self::FEEDBACK_URL ),
esc_url( $dismiss_url ),
esc_html__( 'Report a bug', 'antispam-bee' ),
esc_html__( 'Dismiss', 'antispam-bee' )
);
}

/**
* Enqueue the dismiss handler on the pages that show the notice.
*
* @param string $hook_suffix The current admin page hook suffix.
*/
public static function maybe_enqueue_assets( string $hook_suffix = '' ): void {
if ( ! self::should_show( $hook_suffix ) ) {
return;
}

wp_enqueue_script(
'antispam-bee-pre-release-notice',
plugin_dir_url( MAIN_PLUGIN_FILE ) . 'assets/js/pre-release-notice.js',
[ 'wp-util' ],
PLUGIN_VERSION,
true
);

wp_localize_script(
'antispam-bee-pre-release-notice',
'antispamBeePreReleaseNotice',
[
'action' => self::DISMISS_ACTION,
'nonce' => wp_create_nonce( self::DISMISS_ACTION ),
]
);
}

/**
* Whether the notice should render for the current user and page.
*
* @param string $hook_suffix The current admin page hook suffix.
*
* @return bool Whether to show the notice.
*/
private static function should_show( string $hook_suffix ): bool {
if ( ! self::is_pre_release( PLUGIN_VERSION ) ) {
return false;
}

if ( ! current_user_can( 'manage_options' ) ) {
return false;
}

if ( 'settings_page_' . SettingsPage::SETTINGS_PAGE_SLUG !== $hook_suffix && self::PLUGINS_PAGE !== $hook_suffix ) {
return false;
}

return get_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, true ) !== PLUGIN_VERSION;
}

/**
* Whether a plugin version string marks a pre-release.
*
* A version is a pre-release if the measured number is followed by a
* semantic versioning pre-release suffix, e.g. `3.0.0-RC.1` or
* `3.0.0-beta.2`. The stable `3.0.0` has no such suffix. Build metadata
* after the pre-release suffix, e.g. `3.0.0-beta.2+build`, still marks a
* pre-release.
*
* @param string $version The version string.
*
* @return bool Whether the version is a pre-release.
*/
public static function is_pre_release( string $version ): bool {
return 1 === preg_match( '/^[0-9]+(?:\.[0-9]+){0,2}-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/', $version );
}

/**
* Persist the dismissal, then acknowledge an AJAX request or redirect.
*
* `wp_send_json_success()` ends an AJAX request, so the non-AJAX branch is
* the only one that reaches the redirect and `exit`.
*/
public static function handle_dismiss(): void {
if ( ! current_user_can( 'manage_options' ) ) {
$message = esc_html__( 'You do not have permission to do this.', 'antispam-bee' );

if ( wp_doing_ajax() ) {
wp_send_json_error( $message, 403 );
}

// The message is escaped by esc_html__() above.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_die( $message, '', 403 );
}

$is_ajax = wp_doing_ajax();

if ( $is_ajax ) {
check_ajax_referer( self::DISMISS_ACTION );
} else {
check_admin_referer( self::DISMISS_ACTION );
}

update_user_meta( get_current_user_id(), self::DISMISSED_META_KEY, PLUGIN_VERSION );

if ( $is_ajax ) {
wp_send_json_success();
}

wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );

exit;
}
}
37 changes: 36 additions & 1 deletion src/Helpers/Honeypot.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,49 @@ function ( array $matches ) use ( $honeypot_id, $attributes_string ) {
$markup
) ?? $markup;
break;
case 'input':
// The visible input gets the secret name so its real content is
// not the honeypot bait. A hidden duplicate carrying the comment
// name is appended right behind it, without an id so there are
// not two elements with the same id on the page.
$secret_name = self::get_secret_name_for_post();

// Rebuild only the matching input tag in the raw markup, so
// single and double quoting and attribute order all work. The
// lookaheads make sure this input has the comment id and name,
// matched as complete values, not prefix of another one.
$tag_re = '/<input\b(?=[^>]*\bid=("' . $honeypot_id . '"|\'' . $honeypot_id . '\'|' . $honeypot_id . '(?=[\s\/>])))(?=[^>]*\bname=("' . $honeypot_name . '"|\'' . $honeypot_name . '\'|' . $honeypot_name . '(?=[\s\/>])))[^>]*\/?>/';

$honeypot_attrs = sprintf(
'name="%1$s" aria-hidden="true" aria-label="hp-comment" autocomplete="new-password" tabindex="-1" style="%2$s"',
$honeypot_name,
$honeypot_styles
);

$markup = preg_replace_callback(
$tag_re,
function ( array $matches ) use ( $secret_name, $honeypot_attrs ) {
// Swap the name for the secret, keep everything else.
$rewritten = preg_replace(
'/\bname=["\']?[^"\'>\s]+["\']?/',
'name="' . esc_attr( $secret_name ) . '"',
$matches[0],
1
);

return $rewritten . '<input ' . $honeypot_attrs . '>';
},
$markup,
1
) ?? $markup;
break;
default:
break;
}

return $markup;
}


/**
* Return the secret of a post used in the textarea id attribute.
*
Expand Down
Loading