Security-only release per ADR-0001. Closes all six ship-blockers from the post-v1.5.0 audit: PSEC-001 (no rate limiting), PSEC-002 (account enumeration via separate error messages), PSEC-003 (registration auto-login with no email verification), PSEC-004 (ungated "My Data" templates), PSEC-005 (CSV formula injection on subscriber export), PSEC-006 (no honeypot/CAPTCHA on public forms). Adds inc/config.php (single source of truth for all security thresholds, filterable via peptide_starter_security_config), inc/rate-limiter.php (Peptide_Starter_Rate_Limiter + Cloudflare-aware client IP resolver, transient-backed, never stores raw IPs), and inc/email-verification.php (43-char token, 24h TTL, /verify rewrite, rate-limited resend endpoint). Auth handlers rewritten to: unify all failure messages, rate-limit before any expensive check, drop registrations with a filled honeypot silently, stop auto-login on register, send a verification email instead. The require_login() helper lives in inc/helpers.php and is applied at the top of page-tracker.php, page-subject-log.php, and page-protocol-builder.php; page-calculator.php stays open (no PII, no writes). Newsletter signup gains: consent checkbox, privacy link, honeypot, autoload=false on the wp_options blob, unsubscribe token stored per row, /newsletter-unsubscribe route, merged success+duplicate into a single "ok" state. Admin export runs every cell through peptide_starter_csv_safe() which prefixes leading =/+/-/@/tab/CR with a quote. Contact handler hardens against email-header injection (rejects name values with \r \n , < >), rate-limits, honors honeypot, tags mail failures with a request ID for correlation without logging message content. Adds Tools → Mail Test (inc/mail-diagnostic.php): a permanent wp_mail probe so we can check Hostinger outbound deliverability any time it looks flaky. Adds a one-time admin_init migration that enrols pre-v1.5.1 subscribers into the verification flow (currently a no-op — zero users — but guarded for the future). Other cleanup from the audit: fallback primary menu moved out of header.php into inc/helpers.php (redeclaration risk), placeholder footer language links replaced with "Translations coming soon", footer copyright now reads through the customizer getter, settings-panel gains a proper Tab/Shift+Tab focus trap and saves/restores body overflow, documentation heading IDs slugified from heading text with collision counters, JS files standardized on const/let. Six new PHPUnit suites cover rate limiter lifecycle, auth handler error unification and rate limits, email verification token generation and expiry, contact handler header injection + rate limit + honeypot, newsletter signup consent + CSV safety, and require_login gate behavior. ARCHITECTURE.md and CONVENTIONS.md updated in the same commit — new module layout, data flows, security patterns for rate-limiting, honeypotting, gating, and CSV-safe export. Version bumped to 1.5.1 in style.css and functions.php. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
6.5 KiB
PHP
226 lines
6.5 KiB
PHP
<?php
|
|
/**
|
|
* Newsletter Admin Page
|
|
*
|
|
* Admin interface under Tools for viewing / exporting newsletter
|
|
* subscribers. Exports apply CSV-injection safety to every cell. A
|
|
* migration-advisory notice is emitted once the subscriber count
|
|
* exceeds the configured threshold.
|
|
*
|
|
* @see functions.php — peptide_starter_handle_newsletter_signup() writes the option
|
|
* @see template-parts/newsletter-signup.php — the frontend form
|
|
* @see inc/config.php — newsletter_autoload_threshold
|
|
* @see inc/helpers.php — peptide_starter_csv_safe()
|
|
*
|
|
* What: Admin subscriber viewer + CSV export.
|
|
* Who calls it: WordPress admin_menu + admin_init actions.
|
|
* Dependencies: wp_options storage, CSV-safe helper.
|
|
*
|
|
* @package peptide-starter
|
|
*/
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Register the newsletter admin page under Tools.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_newsletter_admin_menu() {
|
|
add_management_page(
|
|
__( 'Newsletter Subscribers', 'peptide-starter' ),
|
|
__( 'Newsletter', 'peptide-starter' ),
|
|
'manage_options',
|
|
'ps-newsletter',
|
|
'peptide_starter_newsletter_admin_page'
|
|
);
|
|
}
|
|
add_action( 'admin_menu', 'peptide_starter_newsletter_admin_menu' );
|
|
|
|
/**
|
|
* Render the newsletter admin page.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_newsletter_admin_page() {
|
|
$emails = get_option( 'ps_newsletter_emails', array() );
|
|
$threshold = (int) peptide_starter_config_get( 'newsletter_autoload_threshold', 1000 );
|
|
$count = count( $emails );
|
|
?>
|
|
<div class="wrap">
|
|
<h1><?php esc_html_e( 'Newsletter Subscribers', 'peptide-starter' ); ?></h1>
|
|
<p>
|
|
<?php
|
|
echo esc_html(
|
|
sprintf(
|
|
/* translators: %d: number of subscribers */
|
|
_n( '%d subscriber', '%d subscribers', $count, 'peptide-starter' ),
|
|
$count
|
|
)
|
|
);
|
|
?>
|
|
</p>
|
|
|
|
<?php if ( $count > $threshold ) : ?>
|
|
<div class="notice notice-warning">
|
|
<p>
|
|
<?php
|
|
echo esc_html(
|
|
sprintf(
|
|
/* translators: %d: subscriber threshold */
|
|
__( 'Subscriber count exceeds %d — consider migrating newsletter storage from wp_options to a dedicated table for performance (tracked as ADR-0004).', 'peptide-starter' ),
|
|
$threshold
|
|
)
|
|
);
|
|
?>
|
|
</p>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ( ! empty( $emails ) ) : ?>
|
|
<form method="post">
|
|
<?php wp_nonce_field( 'ps_newsletter_export', 'ps_export_nonce' ); ?>
|
|
<input type="hidden" name="ps_action" value="export_csv">
|
|
<?php submit_button( __( 'Export CSV', 'peptide-starter' ), 'secondary', 'export', false ); ?>
|
|
</form>
|
|
|
|
<table class="widefat striped" style="margin-top: 16px;">
|
|
<thead>
|
|
<tr>
|
|
<th><?php esc_html_e( 'Email', 'peptide-starter' ); ?></th>
|
|
<th><?php esc_html_e( 'Date Added', 'peptide-starter' ); ?></th>
|
|
<th><?php esc_html_e( 'Status', 'peptide-starter' ); ?></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ( $emails as $entry ) : ?>
|
|
<tr>
|
|
<td><?php echo esc_html( $entry['email'] ); ?></td>
|
|
<td><?php echo esc_html( isset( $entry['date'] ) ? $entry['date'] : '' ); ?></td>
|
|
<td>
|
|
<?php
|
|
$status = isset( $entry['unsubscribed'] ) && $entry['unsubscribed'] ? 'unsubscribed' : 'active';
|
|
echo esc_html( $status );
|
|
?>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php else : ?>
|
|
<p><?php esc_html_e( 'No subscribers yet.', 'peptide-starter' ); ?></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Handle CSV export of newsletter subscribers.
|
|
*
|
|
* Every cell is routed through peptide_starter_csv_safe() to neutralise
|
|
* formula-injection attacks (values starting with =, +, -, @, tab, CR).
|
|
*
|
|
* Side effects: emits CSV response and exits.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_newsletter_export_csv() {
|
|
if ( ! isset( $_POST['ps_action'] ) || 'export_csv' !== $_POST['ps_action'] ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! isset( $_POST['ps_export_nonce'] ) ||
|
|
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_export_nonce'] ) ), 'ps_newsletter_export' ) ) {
|
|
return;
|
|
}
|
|
|
|
$emails = get_option( 'ps_newsletter_emails', array() );
|
|
|
|
header( 'Content-Type: text/csv; charset=utf-8' );
|
|
header( 'Content-Disposition: attachment; filename=newsletter-subscribers-' . gmdate( 'Y-m-d' ) . '.csv' );
|
|
|
|
$output = fopen( 'php://output', 'w' );
|
|
fputcsv( $output, array( 'Email', 'Date Added', 'Status' ) );
|
|
|
|
foreach ( $emails as $entry ) {
|
|
$status = isset( $entry['unsubscribed'] ) && $entry['unsubscribed'] ? 'unsubscribed' : 'active';
|
|
fputcsv(
|
|
$output,
|
|
array(
|
|
peptide_starter_csv_safe( isset( $entry['email'] ) ? $entry['email'] : '' ),
|
|
peptide_starter_csv_safe( isset( $entry['date'] ) ? $entry['date'] : '' ),
|
|
peptide_starter_csv_safe( $status ),
|
|
)
|
|
);
|
|
}
|
|
|
|
fclose( $output );
|
|
exit;
|
|
}
|
|
add_action( 'admin_init', 'peptide_starter_newsletter_export_csv' );
|
|
|
|
/**
|
|
* Handle /newsletter-unsubscribe?token=... — token-gated, no login required.
|
|
*
|
|
* Side effects: flips the unsubscribed flag on the matching entry.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_handle_unsubscribe() {
|
|
if ( ! is_main_query() || ! isset( $_GET['ps_unsubscribe'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
|
return;
|
|
}
|
|
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
|
if ( '' === $token ) {
|
|
return;
|
|
}
|
|
|
|
$emails = get_option( 'ps_newsletter_emails', array() );
|
|
$changed = false;
|
|
foreach ( $emails as $idx => $entry ) {
|
|
if ( isset( $entry['unsub_token'] ) && hash_equals( (string) $entry['unsub_token'], $token ) ) {
|
|
$emails[ $idx ]['unsubscribed'] = true;
|
|
$changed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ( $changed ) {
|
|
update_option( 'ps_newsletter_emails', $emails, false );
|
|
}
|
|
|
|
wp_safe_redirect( home_url( '/?ps_newsletter=unsubscribed' ) );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Register the /newsletter-unsubscribe rewrite.
|
|
*
|
|
* @return void
|
|
*/
|
|
function peptide_starter_register_unsubscribe_rewrite() {
|
|
add_rewrite_rule( '^newsletter-unsubscribe/?$', 'index.php?ps_unsubscribe=1', 'top' );
|
|
}
|
|
add_action( 'init', 'peptide_starter_register_unsubscribe_rewrite' );
|
|
|
|
/**
|
|
* Register the ps_unsubscribe query var.
|
|
*
|
|
* @param array $vars Existing query vars.
|
|
* @return array
|
|
*/
|
|
function peptide_starter_register_unsubscribe_query_var( $vars ) {
|
|
$vars[] = 'ps_unsubscribe';
|
|
return $vars;
|
|
}
|
|
add_filter( 'query_vars', 'peptide_starter_register_unsubscribe_query_var' );
|
|
|
|
add_action( 'template_redirect', 'peptide_starter_handle_unsubscribe' );
|