v1.0.1 — security hardening, TEC integration fixes, and documentation
Security: - Validate CSV upload MIME type server-side via finfo - Deliver import notices via per-user transient (prevents GET-param spoofing) - Sanitise translatable success string with wp_kses to block HTML injection - Switch sanitize_url to esc_url_raw; wp_kses_post to sanitize_textarea_field for plain-text bio Bug fixes: - Guard preg_replace null return in normalise_name() to prevent TypeError on PHP 8 - Replace generic save_post hook with save_post_tec_speaker / save_post_tribe_events so saves no longer need a manual revision check and cannot interact with TEC's own save_post handler at priority 15 TEC integration: - Check for tribe-select2 / tribe-select2-css handles first (TEC ships SelectWoo, not vanilla Select2); CDN was previously always loaded unnecessarily - Type-specific save hooks make event/speaker save paths explicit and independent Improvements: - Add register_activation_hook to flush rewrite rules on activation - Wrap instantiation in plugins_loaded so TEC is guaranteed loaded first - Show admin notice and skip TEC-specific hooks when TEC is inactive - Cap event picker query at PICKER_LIMIT = 200 (was unbounded -1) - Register front-end CSS via wp_add_inline_style on wp_enqueue_scripts - absint() on speaker IDs in option value attributes Documentation: - Write full README.md (was blank) - Add CHANGELOG.md with detailed 1.0.0 and 1.0.1 entries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6ef4229cce
commit
fef0766e67
3 changed files with 276 additions and 90 deletions
|
|
@ -3,7 +3,7 @@
|
|||
* Plugin Name: Speakers for The Events Calendar
|
||||
* Plugin URI: https://git.ankh-morpork.discworld.network/laurence/speakers-for-the-events-calendar
|
||||
* Description: Add speaker profiles to The Events Calendar events. Registers a Speaker custom post type, provides a searchable speaker picker on event edit screens, displays speakers on event pages, and includes a CSV bulk importer.
|
||||
* Version: 1.0.0
|
||||
* Version: 1.0.1
|
||||
* Author: Laurence Horrocks-Barlow
|
||||
* Author URI: https://qsplace.co.uk
|
||||
* Text Domain: speakers-for-tec
|
||||
|
|
@ -16,27 +16,39 @@
|
|||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
register_activation_hook( __FILE__, function () {
|
||||
$plugin = new Speakers_For_TEC();
|
||||
$plugin->register_cpt();
|
||||
flush_rewrite_rules();
|
||||
} );
|
||||
|
||||
final class Speakers_For_TEC {
|
||||
|
||||
const CPT = 'tec_speaker';
|
||||
const META_KEY = '_tec_event_speakers'; // stored on tribe_events posts
|
||||
const CPT = 'tec_speaker';
|
||||
const META_KEY = '_tec_event_speakers';
|
||||
const PICKER_LIMIT = 200;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Boot
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function __construct() {
|
||||
add_action( 'init', [ $this, 'register_cpt' ] );
|
||||
add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
|
||||
add_action( 'save_post', [ $this, 'save_meta' ], 10, 2 );
|
||||
add_action( 'admin_menu', [ $this, 'add_admin_menu' ] );
|
||||
add_action( 'init', [ $this, 'register_cpt' ] );
|
||||
add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
|
||||
add_action( 'save_post_' . self::CPT, [ $this, 'save_speaker_meta' ], 10, 2 );
|
||||
add_action( 'save_post_tribe_events', [ $this, 'save_event_meta' ], 10, 2 );
|
||||
add_action( 'admin_menu', [ $this, 'add_admin_menu' ] );
|
||||
add_action( 'admin_post_sftec_import', [ $this, 'handle_csv_import' ] );
|
||||
add_action( 'admin_notices', [ $this, 'admin_notices' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
|
||||
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] );
|
||||
add_shortcode( 'tec_speakers', [ $this, 'speakers_shortcode' ] );
|
||||
|
||||
// TEC single-event display — hook fires after the event meta block
|
||||
add_action( 'tribe_events_single_event_after_the_meta', [ $this, 'display_event_speakers' ] );
|
||||
if ( class_exists( 'Tribe__Events__Main' ) ) {
|
||||
add_action( 'tribe_events_single_event_after_the_meta', [ $this, 'display_event_speakers' ] );
|
||||
} else {
|
||||
add_action( 'admin_notices', [ $this, 'notice_tec_missing' ] );
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -46,17 +58,17 @@ final class Speakers_For_TEC {
|
|||
public function register_cpt(): void {
|
||||
register_post_type( self::CPT, [
|
||||
'labels' => [
|
||||
'name' => __( 'Speakers', 'speakers-for-tec' ),
|
||||
'singular_name' => __( 'Speaker', 'speakers-for-tec' ),
|
||||
'add_new_item' => __( 'Add New Speaker', 'speakers-for-tec' ),
|
||||
'edit_item' => __( 'Edit Speaker', 'speakers-for-tec' ),
|
||||
'new_item' => __( 'New Speaker', 'speakers-for-tec' ),
|
||||
'view_item' => __( 'View Speaker', 'speakers-for-tec' ),
|
||||
'search_items' => __( 'Search Speakers', 'speakers-for-tec' ),
|
||||
'not_found' => __( 'No speakers found', 'speakers-for-tec' ),
|
||||
'not_found_in_trash' => __( 'No speakers in trash','speakers-for-tec' ),
|
||||
'all_items' => __( 'All Speakers', 'speakers-for-tec' ),
|
||||
'menu_name' => __( 'Speakers', 'speakers-for-tec' ),
|
||||
'name' => __( 'Speakers', 'speakers-for-tec' ),
|
||||
'singular_name' => __( 'Speaker', 'speakers-for-tec' ),
|
||||
'add_new_item' => __( 'Add New Speaker', 'speakers-for-tec' ),
|
||||
'edit_item' => __( 'Edit Speaker', 'speakers-for-tec' ),
|
||||
'new_item' => __( 'New Speaker', 'speakers-for-tec' ),
|
||||
'view_item' => __( 'View Speaker', 'speakers-for-tec' ),
|
||||
'search_items' => __( 'Search Speakers', 'speakers-for-tec' ),
|
||||
'not_found' => __( 'No speakers found', 'speakers-for-tec' ),
|
||||
'not_found_in_trash' => __( 'No speakers in trash', 'speakers-for-tec' ),
|
||||
'all_items' => __( 'All Speakers', 'speakers-for-tec' ),
|
||||
'menu_name' => __( 'Speakers', 'speakers-for-tec' ),
|
||||
],
|
||||
'public' => true,
|
||||
'publicly_queryable' => true,
|
||||
|
|
@ -74,12 +86,21 @@ final class Speakers_For_TEC {
|
|||
] );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dependency notice
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function notice_tec_missing(): void {
|
||||
echo '<div class="notice notice-error"><p>'
|
||||
. esc_html__( 'Speakers for The Events Calendar requires The Events Calendar plugin to be installed and active.', 'speakers-for-tec' )
|
||||
. '</p></div>';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Meta boxes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function add_meta_boxes(): void {
|
||||
// Extra fields on speaker edit screen
|
||||
add_meta_box(
|
||||
'sftec_speaker_details',
|
||||
__( 'Speaker Details', 'speakers-for-tec' ),
|
||||
|
|
@ -89,7 +110,6 @@ final class Speakers_For_TEC {
|
|||
'default'
|
||||
);
|
||||
|
||||
// Speaker picker on TEC event edit screen
|
||||
if ( post_type_exists( 'tribe_events' ) ) {
|
||||
add_meta_box(
|
||||
'sftec_event_speakers',
|
||||
|
|
@ -126,7 +146,7 @@ final class Speakers_For_TEC {
|
|||
|
||||
$speakers = get_posts( [
|
||||
'post_type' => self::CPT,
|
||||
'posts_per_page' => -1,
|
||||
'posts_per_page' => self::PICKER_LIMIT,
|
||||
'orderby' => 'title',
|
||||
'order' => 'ASC',
|
||||
'post_status' => 'publish',
|
||||
|
|
@ -146,7 +166,7 @@ final class Speakers_For_TEC {
|
|||
foreach ( $speakers as $speaker ) {
|
||||
printf(
|
||||
'<option value="%d"%s>%s</option>',
|
||||
$speaker->ID,
|
||||
absint( $speaker->ID ),
|
||||
in_array( $speaker->ID, $selected, true ) ? ' selected' : '',
|
||||
esc_html( $speaker->post_title )
|
||||
);
|
||||
|
|
@ -161,32 +181,34 @@ final class Speakers_For_TEC {
|
|||
// Saving meta
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function save_meta( int $post_id, \WP_Post $post ): void {
|
||||
public function save_speaker_meta( int $post_id, \WP_Post $post ): void {
|
||||
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Speaker detail fields
|
||||
if (
|
||||
isset( $_POST['_sftec_speaker_nonce'] )
|
||||
&& wp_verify_nonce( $_POST['_sftec_speaker_nonce'], 'sftec_speaker_details_save' )
|
||||
&& $post->post_type === self::CPT
|
||||
&& current_user_can( 'edit_post', $post_id )
|
||||
! isset( $_POST['_sftec_speaker_nonce'] )
|
||||
|| ! wp_verify_nonce( $_POST['_sftec_speaker_nonce'], 'sftec_speaker_details_save' )
|
||||
|| ! current_user_can( 'edit_post', $post_id )
|
||||
) {
|
||||
update_post_meta( $post_id, '_tec_speaker_website', sanitize_url( $_POST['sftec_speaker_website'] ?? '' ) );
|
||||
update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( $_POST['sftec_speaker_email'] ?? '' ) );
|
||||
return;
|
||||
}
|
||||
update_post_meta( $post_id, '_tec_speaker_website', esc_url_raw( $_POST['sftec_speaker_website'] ?? '' ) );
|
||||
update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( $_POST['sftec_speaker_email'] ?? '' ) );
|
||||
}
|
||||
|
||||
// Event → speaker associations
|
||||
if (
|
||||
isset( $_POST['_sftec_event_speakers_nonce'] )
|
||||
&& wp_verify_nonce( $_POST['_sftec_event_speakers_nonce'], 'sftec_event_speakers_save' )
|
||||
&& $post->post_type === 'tribe_events'
|
||||
&& current_user_can( 'edit_post', $post_id )
|
||||
) {
|
||||
$ids = array_map( 'intval', (array) ( $_POST['sftec_event_speakers'] ?? [] ) );
|
||||
update_post_meta( $post_id, self::META_KEY, array_filter( $ids ) );
|
||||
public function save_event_meta( int $post_id, \WP_Post $post ): void {
|
||||
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
! isset( $_POST['_sftec_event_speakers_nonce'] )
|
||||
|| ! wp_verify_nonce( $_POST['_sftec_event_speakers_nonce'], 'sftec_event_speakers_save' )
|
||||
|| ! current_user_can( 'edit_post', $post_id )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$ids = array_map( 'intval', (array) ( $_POST['sftec_event_speakers'] ?? [] ) );
|
||||
update_post_meta( $post_id, self::META_KEY, array_filter( $ids ) );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -213,8 +235,6 @@ final class Speakers_For_TEC {
|
|||
return;
|
||||
}
|
||||
|
||||
$this->enqueue_styles();
|
||||
|
||||
$heading = count( $speakers ) > 1
|
||||
? __( 'Speakers', 'speakers-for-tec' )
|
||||
: __( 'Speaker', 'speakers-for-tec' );
|
||||
|
|
@ -253,8 +273,6 @@ final class Speakers_For_TEC {
|
|||
return '<p>' . esc_html__( 'No speakers found.', 'speakers-for-tec' ) . '</p>';
|
||||
}
|
||||
|
||||
$this->enqueue_styles();
|
||||
|
||||
ob_start();
|
||||
echo '<div class="sftec-speakers-archive">';
|
||||
foreach ( $speakers as $speaker ) {
|
||||
|
|
@ -301,17 +319,17 @@ final class Speakers_For_TEC {
|
|||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Styles (enqueued once per page load)
|
||||
// Styles — registered through WordPress, not echoed inline
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function enqueue_styles(): void {
|
||||
static $done = false;
|
||||
if ( $done ) {
|
||||
return;
|
||||
}
|
||||
$done = true;
|
||||
public function enqueue_styles(): void {
|
||||
wp_register_style( 'sftec-speakers', false, [], '1.0.0' );
|
||||
wp_enqueue_style( 'sftec-speakers' );
|
||||
wp_add_inline_style( 'sftec-speakers', $this->get_css() );
|
||||
}
|
||||
|
||||
echo '<style id="sftec-speakers-css">
|
||||
private function get_css(): string {
|
||||
return '
|
||||
.sftec-event-speakers { margin: 2em 0; border-top: 1px solid #e0e0e0; padding-top: 1.5em; }
|
||||
.sftec-speakers-heading { font-size: 1.15em; font-weight: 600; margin: 0 0 1em; }
|
||||
.sftec-speakers-list,
|
||||
|
|
@ -324,7 +342,7 @@ final class Speakers_For_TEC {
|
|||
.sftec-speaker-name a:hover { text-decoration: underline; }
|
||||
.sftec-speaker-bio { margin: 0 0 0.4em; font-size: 0.875em; color: #555; line-height: 1.4; }
|
||||
.sftec-speaker-link { font-size: 0.825em; }
|
||||
</style>';
|
||||
';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -337,8 +355,13 @@ final class Speakers_For_TEC {
|
|||
return;
|
||||
}
|
||||
|
||||
// Use WP-bundled Select2 if available (TEC ships it), otherwise CDN fallback
|
||||
if ( wp_script_is( 'select2', 'registered' ) ) {
|
||||
// TEC ships SelectWoo as 'tribe-select2'; fall back to a plain 'select2'
|
||||
// registration from another plugin, then finally our own CDN copy.
|
||||
if ( wp_script_is( 'tribe-select2', 'registered' ) ) {
|
||||
wp_enqueue_script( 'tribe-select2' );
|
||||
wp_enqueue_style( 'tribe-select2-css' );
|
||||
$handle = 'tribe-select2';
|
||||
} elseif ( wp_script_is( 'select2', 'registered' ) ) {
|
||||
wp_enqueue_script( 'select2' );
|
||||
wp_enqueue_style( 'select2' );
|
||||
$handle = 'select2';
|
||||
|
|
@ -438,14 +461,28 @@ final class Speakers_For_TEC {
|
|||
wp_die( 'Unauthorised' );
|
||||
}
|
||||
|
||||
$transient_key = 'sftec_import_result_' . get_current_user_id();
|
||||
$redirect_url = admin_url( 'edit.php?post_type=' . self::CPT . '&page=sftec-import-speakers' );
|
||||
|
||||
if ( empty( $_FILES['speakers_csv']['tmp_name'] ) ) {
|
||||
wp_redirect( $this->import_redirect( [ 'sftec_err' => 'no_file' ] ) );
|
||||
set_transient( $transient_key, [ 'err' => 'no_file' ], 60 );
|
||||
wp_redirect( $redirect_url );
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate MIME type server-side — browser accept=".csv" is not a security control
|
||||
$finfo = new \finfo( FILEINFO_MIME_TYPE );
|
||||
$mime = $finfo->file( $_FILES['speakers_csv']['tmp_name'] );
|
||||
if ( ! in_array( $mime, [ 'text/plain', 'text/csv', 'application/csv', 'application/vnd.ms-excel' ], true ) ) {
|
||||
set_transient( $transient_key, [ 'err' => 'invalid_type' ], 60 );
|
||||
wp_redirect( $redirect_url );
|
||||
exit;
|
||||
}
|
||||
|
||||
$handle = fopen( $_FILES['speakers_csv']['tmp_name'], 'r' );
|
||||
if ( ! $handle ) {
|
||||
wp_redirect( $this->import_redirect( [ 'sftec_err' => 'read_fail' ] ) );
|
||||
set_transient( $transient_key, [ 'err' => 'read_fail' ], 60 );
|
||||
wp_redirect( $redirect_url );
|
||||
exit;
|
||||
}
|
||||
|
||||
|
|
@ -488,11 +525,12 @@ final class Speakers_For_TEC {
|
|||
continue;
|
||||
}
|
||||
|
||||
$bio = sanitize_textarea_field( $bio );
|
||||
$post_id = wp_insert_post( [
|
||||
'post_type' => self::CPT,
|
||||
'post_title' => sanitize_text_field( $name ),
|
||||
'post_content' => wp_kses_post( $bio ),
|
||||
'post_excerpt' => wp_trim_words( wp_strip_all_tags( $bio ), 30 ),
|
||||
'post_content' => $bio,
|
||||
'post_excerpt' => wp_trim_words( $bio, 30 ),
|
||||
'post_status' => 'publish',
|
||||
] );
|
||||
|
||||
|
|
@ -502,10 +540,10 @@ final class Speakers_For_TEC {
|
|||
}
|
||||
|
||||
if ( $email ) {
|
||||
update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( trim( $email ) ) );
|
||||
update_post_meta( $post_id, '_tec_speaker_email', sanitize_email( trim( $email ) ) );
|
||||
}
|
||||
if ( $website ) {
|
||||
update_post_meta( $post_id, '_tec_speaker_website', sanitize_url( trim( $website ) ) );
|
||||
update_post_meta( $post_id, '_tec_speaker_website', esc_url_raw( trim( $website ) ) );
|
||||
}
|
||||
|
||||
$existing_names[ $key ] = true;
|
||||
|
|
@ -514,25 +552,20 @@ final class Speakers_For_TEC {
|
|||
|
||||
fclose( $handle );
|
||||
|
||||
wp_redirect( $this->import_redirect( [
|
||||
'sftec_created' => $created,
|
||||
'sftec_skipped' => $skipped,
|
||||
'sftec_errors' => $errors,
|
||||
] ) );
|
||||
exit;
|
||||
}
|
||||
set_transient( $transient_key, [
|
||||
'created' => $created,
|
||||
'skipped' => $skipped,
|
||||
'errors' => $errors,
|
||||
], 60 );
|
||||
|
||||
private function import_redirect( array $args ): string {
|
||||
return add_query_arg(
|
||||
array_merge( [ 'post_type' => self::CPT, 'page' => 'sftec-import-speakers' ], $args ),
|
||||
admin_url( 'edit.php' )
|
||||
);
|
||||
wp_redirect( $redirect_url );
|
||||
exit;
|
||||
}
|
||||
|
||||
private function normalise_name( string $name ): string {
|
||||
$name = strtolower( trim( $name ) );
|
||||
$name = preg_replace( '/^(dr\.?|prof\.?|mr\.?|mrs\.?|ms\.?|miss\.?)\s+/u', '', $name );
|
||||
return preg_replace( '/\s+/', ' ', $name );
|
||||
$name = preg_replace( '/^(dr\.?|prof\.?|mr\.?|mrs\.?|ms\.?|miss\.?)\s+/u', '', $name ) ?? $name;
|
||||
return preg_replace( '/\s+/', ' ', $name ) ?? $name;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -540,16 +573,22 @@ final class Speakers_For_TEC {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
public function admin_notices(): void {
|
||||
if ( ! isset( $_GET['sftec_created'] ) && ! isset( $_GET['sftec_err'] ) ) {
|
||||
$transient_key = 'sftec_import_result_' . get_current_user_id();
|
||||
$result = get_transient( $transient_key );
|
||||
|
||||
if ( ! is_array( $result ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $_GET['sftec_err'] ) ) {
|
||||
delete_transient( $transient_key );
|
||||
|
||||
if ( isset( $result['err'] ) ) {
|
||||
$msgs = [
|
||||
'no_file' => __( 'No file was uploaded.', 'speakers-for-tec' ),
|
||||
'read_fail' => __( 'Could not read the uploaded file.', 'speakers-for-tec' ),
|
||||
'no_file' => __( 'No file was uploaded.', 'speakers-for-tec' ),
|
||||
'read_fail' => __( 'Could not read the uploaded file.', 'speakers-for-tec' ),
|
||||
'invalid_type' => __( 'The uploaded file is not a valid CSV.', 'speakers-for-tec' ),
|
||||
];
|
||||
$msg = $msgs[ $_GET['sftec_err'] ] ?? __( 'An unknown error occurred.', 'speakers-for-tec' );
|
||||
$msg = $msgs[ $result['err'] ] ?? __( 'An unknown error occurred.', 'speakers-for-tec' );
|
||||
printf(
|
||||
'<div class="notice notice-error is-dismissible"><p>%s %s</p></div>',
|
||||
esc_html__( 'Import failed:', 'speakers-for-tec' ),
|
||||
|
|
@ -559,14 +598,19 @@ final class Speakers_For_TEC {
|
|||
}
|
||||
|
||||
printf(
|
||||
'<div class="notice notice-success is-dismissible"><p>'
|
||||
. __( 'Import complete — <strong>%d created</strong>, %d skipped (already exist), %d errors.', 'speakers-for-tec' )
|
||||
. '</p></div>',
|
||||
(int) $_GET['sftec_created'],
|
||||
(int) $_GET['sftec_skipped'],
|
||||
(int) $_GET['sftec_errors']
|
||||
'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
|
||||
wp_kses(
|
||||
sprintf(
|
||||
/* translators: 1: number of speakers created, 2: number skipped, 3: number of errors */
|
||||
__( 'Import complete — <strong>%1$d created</strong>, %2$d skipped (already exist), %3$d errors.', 'speakers-for-tec' ),
|
||||
(int) $result['created'],
|
||||
(int) $result['skipped'],
|
||||
(int) $result['errors']
|
||||
),
|
||||
[ 'strong' => [] ]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
new Speakers_For_TEC();
|
||||
add_action( 'plugins_loaded', fn() => new Speakers_For_TEC() );
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue