v1.5.1: frontend auth hardening — rate limiting, email verification, honeypots, CSV safety

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>
This commit is contained in:
peptiderepo
2026-04-14 13:01:22 +07:00
parent d28d5f45d5
commit cb8b73f4ab
34 changed files with 2816 additions and 745 deletions

View File

@@ -2,16 +2,23 @@
/**
* Authentication AJAX Handlers
*
* Handles login and registration via AJAX from the branded auth page.
* Both handlers validate CSRF nonces and return JSON responses.
* Login + registration endpoints for the branded /auth page. Both are
* nonce-verified, rate-limited, honeypot-checked, and emit only unified
* error messages — no enumeration surface.
*
* @see page-auth.php — renders the auth forms
* @see assets/js/auth.js — submits forms via XMLHttpRequest
* Registration does NOT auto-login — users must click the verification
* link emailed to them (see inc/email-verification.php).
*
* @see page-auth.php — renders the forms
* @see assets/js/auth.js — AJAX submitter
* @see inc/rate-limiter.php — Peptide_Starter_Rate_Limiter
* @see inc/email-verification.php — sends verification email on register
* @see inc/config.php — thresholds for rate limits, username/password rules
* @see functions.php — includes this file
*
* What: AJAX endpoints for sign-in and registration.
* Who calls it: WordPress admin-ajax.php via auth.js.
* Dependencies: WordPress core auth functions (wp_signon, wp_create_user).
* What: AJAX login / register with unified errors and abuse controls.
* Who calls it: admin-ajax.php via auth.js POST.
* Dependencies: wp_signon, wp_create_user, rate limiter, email verification.
*
* @package peptide-starter
*/
@@ -21,33 +28,70 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Unified login failure message.
*
* Kept in a helper so every failure path returns byte-identical output —
* critical for defeating account enumeration via error-message oracle.
*
* @return string Translated failure message.
*/
function peptide_starter_login_failure_message() {
return __( 'Invalid email or password.', 'peptide-starter' );
}
/**
* Unified registration failure message.
*
* @return string Translated failure message.
*/
function peptide_starter_register_failure_message() {
return __( 'Unable to create account. Please check your entries and try again.', 'peptide-starter' );
}
/**
* Handle AJAX login.
*
* Verifies nonce, authenticates via wp_signon(), returns JSON with redirect URL.
* Rate limiting: WordPress core handles lockout via wp_login_failed hooks.
* Order of operations:
* 1. Nonce check (returns security error — separate from auth failure).
* 2. Honeypot check (silent fake success).
* 3. Rate-limit check keyed on IP + sha256(lowercased email).
* 4. Email lookup + wp_signon.
* 5. On success: reset the limiter; redirect via wp_validate_redirect.
*
* @return void Sends JSON response and exits.
* Side effects: may set auth cookie, writes rate-limit transient.
*
* @return void Emits JSON and exits.
*/
function peptide_starter_ajax_login() {
// Verify CSRF nonce.
if ( ! isset( $_POST['ps_login_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_login_nonce'] ) ), 'ps_auth_login' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
// Honeypot — any value means bot. Return fake success, don't log PII.
if ( peptide_starter_honeypot_triggered( 'ps_hp_login' ) ) {
wp_send_json_success( array( 'redirect' => home_url( '/' ) ) );
}
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be modified.
if ( empty( $email ) || empty( $password ) ) {
wp_send_json_error( array( 'message' => __( 'Email and password are required.', 'peptide-starter' ) ) );
$identifier = peptide_starter_hash_identifier( strtolower( $email ) );
if ( ! Peptide_Starter_Rate_Limiter::check( 'login', $identifier ) ) {
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
// WordPress authenticates by username or email. Resolve email to username.
$user = get_user_by( 'email', $email );
if ( '' === $email || '' === $password ) {
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
$user = get_user_by( 'email', $email );
if ( ! $user ) {
wp_send_json_error( array( 'message' => __( 'Invalid email or password.', 'peptide-starter' ) ) );
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
$creds = array(
@@ -59,12 +103,19 @@ function peptide_starter_ajax_login() {
$signed_in = wp_signon( $creds, is_ssl() );
if ( is_wp_error( $signed_in ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid email or password.', 'peptide-starter' ) ) );
Peptide_Starter_Rate_Limiter::record( 'login', $identifier );
wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
}
$redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : home_url( '/' );
// Prevent open redirect attacks — only allow internal URLs.
$redirect = wp_validate_redirect( $redirect, home_url( '/' ) );
Peptide_Starter_Rate_Limiter::reset( 'login', $identifier );
$redirect_raw = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : home_url( '/' );
$redirect = wp_validate_redirect( $redirect_raw, home_url( '/' ) );
// Unverified users land on profile with the verify banner.
if ( ! peptide_starter_user_is_verified( $signed_in->ID ) ) {
$redirect = home_url( '/profile?verify_required=1' );
}
wp_send_json_success( array( 'redirect' => $redirect ) );
}
@@ -73,65 +124,97 @@ add_action( 'wp_ajax_nopriv_ps_auth_login', 'peptide_starter_ajax_login' );
/**
* Handle AJAX registration.
*
* Validates username (5-15 alphanumeric), email, password (min 8 chars).
* Creates user via wp_create_user() and auto-logs them in.
* Never auto-logs-in. Sends a verification email and responds with a
* "check your inbox" message. All validation-failure paths collapse to
* one message to avoid username/email enumeration.
*
* @return void Sends JSON response and exits.
* Side effects: creates a user, writes verify meta, sends email.
*
* @return void Emits JSON and exits.
*/
function peptide_starter_ajax_register() {
// Verify CSRF nonce.
if ( ! isset( $_POST['ps_register_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_register_nonce'] ) ), 'ps_auth_register' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
// Check if registration is enabled in WordPress settings.
if ( ! get_option( 'users_can_register' ) ) {
if ( peptide_starter_honeypot_triggered( 'ps_hp_register' ) ) {
wp_send_json_success( array( 'message' => __( 'Check your email for a verification link.', 'peptide-starter' ) ) );
}
$config = peptide_starter_security_config();
if ( ! $config['registration_enabled'] || ! get_option( 'users_can_register' ) ) {
wp_send_json_error( array( 'message' => __( 'Registration is currently disabled.', 'peptide-starter' ) ) );
}
// Rate-limit registration by IP only (identifier empty → IP-only bucket).
if ( ! Peptide_Starter_Rate_Limiter::check( 'register', 'ip' ) ) {
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
}
Peptide_Starter_Rate_Limiter::record( 'register', 'ip' );
$username = isset( $_POST['username'] ) ? sanitize_user( wp_unslash( $_POST['username'] ) ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$password = isset( $_POST['password'] ) ? wp_unslash( $_POST['password'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be modified.
// Validate username: 5-15 alphanumeric characters.
if ( ! preg_match( '/^[a-zA-Z0-9]{5,15}$/', $username ) ) {
wp_send_json_error( array( 'message' => __( 'Username must be 5-15 alphanumeric characters.', 'peptide-starter' ) ) );
}
$pattern = sprintf( '/^[a-zA-Z0-9]{%d,%d}$/', (int) $config['username_min'], (int) $config['username_max'] );
if ( ! is_email( $email ) ) {
wp_send_json_error( array( 'message' => __( 'Please enter a valid email address.', 'peptide-starter' ) ) );
}
if ( strlen( $password ) < 8 ) {
wp_send_json_error( array( 'message' => __( 'Password must be at least 8 characters.', 'peptide-starter' ) ) );
}
if ( username_exists( $username ) ) {
wp_send_json_error( array( 'message' => __( 'This username is already taken.', 'peptide-starter' ) ) );
}
if ( email_exists( $email ) ) {
wp_send_json_error( array( 'message' => __( 'An account with this email already exists.', 'peptide-starter' ) ) );
if ( ! preg_match( $pattern, $username ) ||
! is_email( $email ) ||
strlen( $password ) < (int) $config['password_min'] ||
username_exists( $username ) ||
email_exists( $email ) ) {
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
}
$user_id = wp_create_user( $username, $password, $email );
if ( is_wp_error( $user_id ) ) {
wp_send_json_error( array( 'message' => $user_id->get_error_message() ) );
wp_send_json_error( array( 'message' => peptide_starter_register_failure_message() ) );
}
// Auto-login after registration.
wp_set_current_user( $user_id );
wp_set_auth_cookie( $user_id, true, is_ssl() );
// Send verification email. Intentionally ignore the wp_mail boolean —
// we never tell the client whether delivery succeeded (attacker signal).
peptide_starter_send_verification_email( $user_id );
/**
* Fires after a new user registers via the frontend auth form.
*
* Listeners may attach additional profile-setup logic. Fired BEFORE
* email verification is complete — do not grant privileged access from
* this hook without checking peptide_starter_user_is_verified().
*
* @param int $user_id The newly created user ID.
*/
do_action( 'peptide_starter_user_registered', $user_id );
wp_send_json_success( array( 'redirect' => home_url( '/profile' ) ) );
wp_send_json_success(
array(
'message' => __( 'Account created. Check your inbox for a verification link — it expires in 24 hours.', 'peptide-starter' ),
)
);
}
add_action( 'wp_ajax_nopriv_ps_auth_register', 'peptide_starter_ajax_register' );
/**
* Detect a tripped honeypot field.
*
* Hidden fields should never receive input from a human using the real
* form. Any non-empty value → bot, silently drop the request.
*
* Side effects: on trip, writes a single low-signal log line with only
* a hash of the client IP — zero PII.
*
* @param string $field_name POST key for the honeypot input.
* @return bool True if the trap was triggered.
*/
function peptide_starter_honeypot_triggered( $field_name ) {
if ( empty( $_POST[ $field_name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller already verifies nonce.
return false;
}
$hash = substr( hash( 'sha256', peptide_starter_get_client_ip() ), 0, 12 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'peptide_starter_honeypot field=%s ip_hash=%s', $field_name, $hash ) );
return true;
}

122
inc/config.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
/**
* Security and Feature Configuration
*
* Single source of truth for every security threshold, cooldown, and
* toggle used by the theme. All values are filterable via
* peptide_starter_security_config so deployments can override without
* forking code.
*
* @see inc/rate-limiter.php — consumes login/register/contact/newsletter limits
* @see inc/auth-handlers.php — consumes registration + email-verify settings
* @see inc/email-verification.php — consumes token TTL + resend limit
* @see inc/contact-handler.php — consumes contact rate limit
* @see functions.php — includes this file
*
* What: Returns the security configuration array for all abuse controls.
* Who calls it: Any handler needing a threshold; called once per request.
* Dependencies: None — pure array + filter.
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Return the theme-wide security configuration.
*
* Every threshold in the theme must be read from this function; no magic
* numbers in handler files. Override with add_filter( 'peptide_starter_security_config', ... ).
*
* Cost note: this function is cheap and pure — no DB reads. Call it freely.
*
* @return array {
* @type array $rate_limits Per-action limit + window tuples.
* @type int $verify_token_ttl Seconds a verification token stays valid.
* @type int $verify_resend_limit Resends permitted per hour per IP+email.
* @type bool $email_verify_required Whether new registrations require email verification.
* @type bool $registration_enabled Master switch (separate from users_can_register).
* @type int $newsletter_autoload_threshold Subscribers before admin migration notice.
* @type array $contact_topics Allowlisted contact form topic slugs.
* @type int $username_min Minimum username length.
* @type int $username_max Maximum username length.
* @type int $password_min Minimum password length.
* }
*/
function peptide_starter_security_config() {
$defaults = array(
'rate_limits' => array(
// action => array( limit, window_seconds ).
'login' => array(
'limit' => 5,
'window' => 60,
),
'register' => array(
'limit' => 3,
'window' => HOUR_IN_SECONDS,
),
'contact' => array(
'limit' => 5,
'window' => HOUR_IN_SECONDS,
),
'newsletter' => array(
'limit' => 3,
'window' => HOUR_IN_SECONDS,
),
'verify_resend' => array(
'limit' => 2,
'window' => HOUR_IN_SECONDS,
),
),
'verify_token_ttl' => DAY_IN_SECONDS,
'email_verify_required' => true,
'registration_enabled' => true,
'newsletter_autoload_threshold' => 1000,
'contact_topics' => array( 'bug', 'feature', 'data', 'other' ),
'username_min' => 5,
'username_max' => 15,
'password_min' => 8,
);
/**
* Filter the theme's security configuration.
*
* @param array $defaults Default configuration.
*/
return apply_filters( 'peptide_starter_security_config', $defaults );
}
/**
* Shortcut to read a single top-level config key.
*
* @param string $key Top-level key (e.g. 'verify_token_ttl').
* @param mixed $fallback Value returned when the key is missing.
* @return mixed Configured value or $fallback.
*/
function peptide_starter_config_get( $key, $fallback = null ) {
$config = peptide_starter_security_config();
return array_key_exists( $key, $config ) ? $config[ $key ] : $fallback;
}
/**
* Read a rate limit tuple (limit, window) for a given action.
*
* @param string $action Action slug matching a key in rate_limits.
* @return array { 'limit' => int, 'window' => int } with safe defaults.
*/
function peptide_starter_config_rate_limit( $action ) {
$limits = peptide_starter_config_get( 'rate_limits', array() );
if ( isset( $limits[ $action ]['limit'], $limits[ $action ]['window'] ) ) {
return array(
'limit' => (int) $limits[ $action ]['limit'],
'window' => (int) $limits[ $action ]['window'],
);
}
return array(
'limit' => 5,
'window' => HOUR_IN_SECONDS,
);
}

View File

@@ -2,16 +2,21 @@
/**
* Contact Form AJAX Handler
*
* Processes the contact/support form from the settings panel.
* Sends email via wp_mail() to the site admin.
* Support contact form from the settings panel. Sanitises inputs,
* rejects header-injection characters in the sender name, enforces
* rate limits, honors the honeypot, and emails the admin.
*
* @see template-parts/settings-panel.php — renders the contact form
* @see assets/js/settings-panel.js — submits form via XMLHttpRequest
* Unverified users may still use contact (it isn't a write endpoint
* on user data) — the verify gate is only applied to user-data tools.
*
* @see template-parts/settings-panel.php — renders the form
* @see inc/rate-limiter.php — Peptide_Starter_Rate_Limiter
* @see inc/config.php — contact_topics allowlist + rate limit
* @see functions.php — includes this file
*
* What: AJAX endpoint for the support contact form.
* Who calls it: WordPress admin-ajax.php via settings-panel.js.
* Dependencies: wp_mail() for email delivery.
* What: AJAX contact endpoint with abuse controls.
* Who calls it: admin-ajax.php via settings-panel.js POST.
* Dependencies: wp_mail, rate limiter, honeypot helper.
*
* @package peptide-starter
*/
@@ -24,39 +29,54 @@ if ( ! defined( 'ABSPATH' ) ) {
/**
* Handle AJAX contact form submission.
*
* Validates nonce, sanitizes all inputs, sends email to admin.
* Works for both authenticated and unauthenticated users.
* Side effects: may call wp_mail. Writes rate-limit transient.
*
* @return void Sends JSON response and exits.
* @return void Emits JSON and exits.
*/
function peptide_starter_ajax_contact() {
// Verify CSRF nonce.
if ( ! isset( $_POST['ps_contact_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_contact_nonce'] ) ), 'ps_contact_form' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
if ( peptide_starter_honeypot_triggered( 'ps_hp_contact' ) ) {
// Fake success to poison the bot feedback loop.
wp_send_json_success( array( 'message' => __( 'Message sent successfully. We will respond within 48 hours.', 'peptide-starter' ) ) );
}
if ( ! Peptide_Starter_Rate_Limiter::check( 'contact', 'ip' ) ) {
wp_send_json_error( array( 'message' => __( 'Too many requests. Please try again later.', 'peptide-starter' ) ) );
}
Peptide_Starter_Rate_Limiter::record( 'contact', 'ip' );
$name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
$topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
$message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
// Validate required fields.
if ( empty( $name ) || empty( $email ) || empty( $topic ) || empty( $message ) ) {
// Reject header-injection characters in the name explicitly — don't
// rely on sanitize_text_field side-effects for email-header safety.
if ( preg_match( '/[\r\n,<>]/', $name ) ) {
peptide_starter_contact_log_failure( 'name_injection' );
wp_send_json_error( array( 'message' => __( 'Invalid characters in name.', 'peptide-starter' ) ) );
}
if ( '' === $name || '' === $email || '' === $topic || '' === $message ) {
peptide_starter_contact_log_failure( 'missing_field' );
wp_send_json_error( array( 'message' => __( 'All fields are required.', 'peptide-starter' ) ) );
}
if ( ! is_email( $email ) ) {
peptide_starter_contact_log_failure( 'bad_email' );
wp_send_json_error( array( 'message' => __( 'Please enter a valid email address.', 'peptide-starter' ) ) );
}
// Allowed topics — reject anything unexpected.
$allowed_topics = array( 'bug', 'feature', 'data', 'other' );
$allowed_topics = (array) peptide_starter_config_get( 'contact_topics', array( 'bug', 'feature', 'data', 'other' ) );
if ( ! in_array( $topic, $allowed_topics, true ) ) {
peptide_starter_contact_log_failure( 'bad_topic' );
wp_send_json_error( array( 'message' => __( 'Invalid topic selected.', 'peptide-starter' ) ) );
}
// Build the email.
$topic_labels = array(
'bug' => __( 'Bug Report', 'peptide-starter' ),
'feature' => __( 'Feature Request', 'peptide-starter' ),
@@ -64,21 +84,25 @@ function peptide_starter_ajax_contact() {
'other' => __( 'Other', 'peptide-starter' ),
);
$request_id = substr( wp_hash( microtime() . wp_rand() ), 0, 10 );
$subject = sprintf(
/* translators: 1: topic label, 2: sender name */
__( '[Peptide Repo] %1$s from %2$s', 'peptide-starter' ),
$topic_labels[ $topic ],
$name
/* translators: 1: topic label, 2: sender name, 3: request id */
__( '[Peptide Repo] %1$s from %2$s (req %3$s)', 'peptide-starter' ),
isset( $topic_labels[ $topic ] ) ? $topic_labels[ $topic ] : $topic,
$name,
$request_id
);
$body = sprintf( __( 'Name: %s', 'peptide-starter' ), $name ) . "\n";
$body .= sprintf( __( 'Email: %s', 'peptide-starter' ), $email ) . "\n";
$body .= sprintf( __( 'Topic: %s', 'peptide-starter' ), $topic_labels[ $topic ] ) . "\n\n";
$body .= sprintf( __( 'Topic: %s', 'peptide-starter' ), isset( $topic_labels[ $topic ] ) ? $topic_labels[ $topic ] : $topic ) . "\n";
$body .= sprintf( __( 'Request ID: %s', 'peptide-starter' ), $request_id ) . "\n\n";
$body .= __( 'Message:', 'peptide-starter' ) . "\n";
$body .= $message;
$headers = array(
'Reply-To: ' . $name . ' <' . $email . '>',
'Reply-To: ' . $email,
'Content-Type: text/plain; charset=UTF-8',
);
@@ -86,9 +110,23 @@ function peptide_starter_ajax_contact() {
if ( $sent ) {
wp_send_json_success( array( 'message' => __( 'Message sent successfully. We will respond within 48 hours.', 'peptide-starter' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to send message. Please try again later.', 'peptide-starter' ) ) );
}
peptide_starter_contact_log_failure( 'mail_failed:' . $request_id );
wp_send_json_error( array( 'message' => __( 'Failed to send message. Please try again later.', 'peptide-starter' ) ) );
}
add_action( 'wp_ajax_ps_contact_submit', 'peptide_starter_ajax_contact' );
add_action( 'wp_ajax_nopriv_ps_contact_submit', 'peptide_starter_ajax_contact' );
/**
* Log a contact-handler failure with no PII — only a reason code and
* a hashed IP so admins can correlate without exposing submitters.
*
* @param string $reason Short reason code.
* @return void
*/
function peptide_starter_contact_log_failure( $reason ) {
$hash = substr( hash( 'sha256', peptide_starter_get_client_ip() ), 0, 12 );
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'peptide_starter_contact_failure reason=%s ip_hash=%s', $reason, $hash ) );
}

240
inc/email-verification.php Normal file
View File

@@ -0,0 +1,240 @@
<?php
/**
* Email Verification Flow
*
* Generates signed tokens, sends verification email, handles the /verify
* click-through, and exposes a resend endpoint + gate helper for downstream
* handlers that should refuse writes from unverified accounts.
*
* @see inc/auth-handlers.php — calls send_verification_email after register
* @see inc/config.php — supplies verify_token_ttl and verify_resend limit
* @see inc/contact-handler.php — gates on peptide_starter_user_is_verified()
* @see page-profile.php — renders the "verify your email" notice
* @see functions.php — includes this file
*
* What: Token-gated email verification for new registrations.
* Who calls it: Auth handlers, /verify rewrite, AJAX resend endpoint.
* Dependencies: wp_mail, user meta (ps_verify_token, ps_verify_expires,
* ps_pending_verification), Peptide_Starter_Rate_Limiter.
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Generate a verification token, persist it, and email the user.
*
* Side effects:
* - Sets user meta ps_pending_verification, ps_verify_token, ps_verify_expires.
* - Calls wp_mail (network I/O).
* - Logs wp_mail return value via error_log at debug level for triage.
*
* Event: fires 'peptide_starter_verification_sent' with ( $user_id, $sent_ok ).
* No listeners registered in-theme; plugins may attach for auditing.
*
* @param int $user_id Target user.
* @return bool wp_mail() return value; true on dispatch, false on failure.
*/
function peptide_starter_send_verification_email( $user_id ) {
$user = get_user_by( 'id', (int) $user_id );
if ( ! $user ) {
return false;
}
$token = wp_generate_password( 43, false, false );
$ttl = (int) peptide_starter_config_get( 'verify_token_ttl', DAY_IN_SECONDS );
$expires = time() + $ttl;
update_user_meta( $user_id, 'ps_pending_verification', 1 );
update_user_meta( $user_id, 'ps_verify_token', $token );
update_user_meta( $user_id, 'ps_verify_expires', $expires );
$verify_url = add_query_arg(
array(
'uid' => $user_id,
'token' => $token,
),
home_url( '/verify' )
);
$subject = sprintf(
/* translators: %s: site name */
__( '[%s] Verify your email address', 'peptide-starter' ),
wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES )
);
$body = sprintf(
/* translators: %s: user display name */
__( 'Hi %s,', 'peptide-starter' ),
$user->display_name
) . "\n\n";
$body .= __( 'Thanks for registering with Peptide Repo. To activate your account, click the link below within 24 hours:', 'peptide-starter' ) . "\n\n";
$body .= $verify_url . "\n\n";
$body .= __( "If you didn't create this account, you can safely ignore this email — it will expire on its own.", 'peptide-starter' ) . "\n\n";
$body .= __( '— Peptide Repo', 'peptide-starter' );
$headers = array( 'Content-Type: text/plain; charset=UTF-8' );
$sent = wp_mail( $user->user_email, $subject, $body, $headers );
// Log for deliverability triage; never log the token or body.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( sprintf( 'peptide_starter_verify_mail user=%d sent=%s', $user_id, $sent ? '1' : '0' ) );
/**
* Fires after the verification email dispatch attempt.
*
* @param int $user_id Recipient user ID.
* @param bool $sent Whether wp_mail returned true.
*/
do_action( 'peptide_starter_verification_sent', $user_id, (bool) $sent );
return (bool) $sent;
}
/**
* Whether a user has completed email verification.
*
* Users without the ps_pending_verification meta are treated as verified
* (covers accounts created before v1.5.1 that were never enrolled; the
* migration in inc/page-setup.php enrolls existing v1.5.0 users explicitly
* where applicable).
*
* @param int $user_id User ID.
* @return bool True if verified or exempt; false if pending.
*/
function peptide_starter_user_is_verified( $user_id ) {
$pending = get_user_meta( (int) $user_id, 'ps_pending_verification', true );
return '1' !== (string) $pending;
}
/**
* Add the /verify rewrite rule and the query var it emits.
*
* @return void
*/
function peptide_starter_register_verify_rewrite() {
add_rewrite_rule( '^verify/?$', 'index.php?ps_verify=1', 'top' );
}
add_action( 'init', 'peptide_starter_register_verify_rewrite' );
/**
* Register the ps_verify query var.
*
* @param array $vars Existing query vars.
* @return array
*/
function peptide_starter_register_verify_query_var( $vars ) {
$vars[] = 'ps_verify';
return $vars;
}
add_filter( 'query_vars', 'peptide_starter_register_verify_query_var' );
/**
* Handle a /verify click-through.
*
* Timing-safe token comparison. On success: clears verify meta, logs the
* user in, redirects to /profile?verified=1. On failure: redirects to
* /auth?verify_error=1.
*
* Side effects: deletes user meta, sets auth cookie, emits 302.
*
* @return void
*/
function peptide_starter_handle_verify_request() {
if ( ! (int) get_query_var( 'ps_verify' ) ) {
return;
}
$uid = isset( $_GET['uid'] ) ? (int) $_GET['uid'] : 0;
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
if ( $uid <= 0 || '' === $token ) {
wp_safe_redirect( home_url( '/auth?verify_error=1' ) );
exit;
}
$stored_token = (string) get_user_meta( $uid, 'ps_verify_token', true );
$stored_expires = (int) get_user_meta( $uid, 'ps_verify_expires', true );
if ( '' === $stored_token || $stored_expires < time() || ! hash_equals( $stored_token, $token ) ) {
wp_safe_redirect( home_url( '/auth?verify_error=1' ) );
exit;
}
delete_user_meta( $uid, 'ps_pending_verification' );
delete_user_meta( $uid, 'ps_verify_token' );
delete_user_meta( $uid, 'ps_verify_expires' );
wp_set_current_user( $uid );
wp_set_auth_cookie( $uid, true, is_ssl() );
/**
* Fires when a user successfully verifies their email.
*
* @param int $uid Verified user ID.
*/
do_action( 'peptide_starter_user_verified', $uid );
wp_safe_redirect( home_url( '/profile?verified=1' ) );
exit;
}
add_action( 'template_redirect', 'peptide_starter_handle_verify_request' );
/**
* AJAX endpoint: resend a verification email.
*
* Rate-limited via the 'verify_resend' action. Always responds with a
* generic success to avoid leaking whether the address is registered.
*
* Side effects: may call peptide_starter_send_verification_email().
*
* @return void Sends JSON and exits.
*/
function peptide_starter_ajax_resend_verify() {
if ( ! isset( $_POST['ps_resend_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_resend_nonce'] ) ), 'ps_resend_verify' ) ) {
wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'peptide-starter' ) ) );
}
$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
if ( ! is_email( $email ) ) {
wp_send_json_success( array( 'message' => __( 'If that address needs a verification link, one has been sent.', 'peptide-starter' ) ) );
}
$identifier = peptide_starter_hash_identifier( strtolower( $email ) );
if ( ! Peptide_Starter_Rate_Limiter::check( 'verify_resend', $identifier ) ) {
wp_send_json_success( array( 'message' => __( 'If that address needs a verification link, one has been sent.', 'peptide-starter' ) ) );
}
Peptide_Starter_Rate_Limiter::record( 'verify_resend', $identifier );
$user = get_user_by( 'email', $email );
if ( $user && ! peptide_starter_user_is_verified( $user->ID ) ) {
peptide_starter_send_verification_email( $user->ID );
}
wp_send_json_success( array( 'message' => __( 'If that address needs a verification link, one has been sent.', 'peptide-starter' ) ) );
}
add_action( 'wp_ajax_nopriv_ps_resend_verify', 'peptide_starter_ajax_resend_verify' );
add_action( 'wp_ajax_ps_resend_verify', 'peptide_starter_ajax_resend_verify' );
/**
* Flush rewrite rules when the /verify rule is freshly registered.
*
* Idempotent — guarded by an option flag so we only flush once per release.
*
* @return void
*/
function peptide_starter_maybe_flush_verify_rewrite() {
$stamp = get_option( 'peptide_starter_verify_rewrite_version' );
if ( PEPTIDE_STARTER_VERSION === $stamp ) {
return;
}
flush_rewrite_rules( false );
update_option( 'peptide_starter_verify_rewrite_version', PEPTIDE_STARTER_VERSION, false );
}
add_action( 'init', 'peptide_starter_maybe_flush_verify_rewrite', 20 );

View File

@@ -2,16 +2,17 @@
/**
* Theme Helper Functions
*
* Customizer output helpers, nav walker, custom logo, pagination,
* and other utility functions shared across the theme.
* Customizer output helpers, nav walker, custom logo, pagination, menu
* fallback, auth gates, and assorted utilities shared across the theme.
*
* @see functions.php — includes this file
* @see header.php — uses peptide_starter_the_custom_logo()
* @see front-page.php — uses hero getters
* @see header.php — uses peptide_starter_the_custom_logo() + menu fallback
* @see page-subject-log.php, page-tracker.php, page-protocol-builder.php —
* use peptide_starter_require_login()
*
* What: Utility functions for template output and theme mods.
* Who calls it: Various templates and functions.php.
* Dependencies: WordPress Customizer API, Walker_Nav_Menu.
* What: Utility functions for template output, theme mods, auth gating.
* Who calls it: Templates and handler files.
* Dependencies: WordPress Customizer API, Walker_Nav_Menu, email-verification.
*
* @package peptide-starter
*/
@@ -22,33 +23,34 @@ if ( ! defined( 'ABSPATH' ) ) {
}
/**
* Navigation Menu Walker for handling active states.
* Navigation Menu Walker — adds active classes.
*
* Adds an 'active' class to current menu items and their parents
* for CSS styling via the .primary-navigation a.active selector.
* What: Renders menu items with an 'active' class for current + ancestor.
* Who calls it: wp_nav_menu() in header.php with walker => new instance.
* Dependencies: Walker_Nav_Menu.
*/
class Peptide_Starter_Nav_Walker extends Walker_Nav_Menu {
/**
* Render a single menu item.
*
* @param string $output Item output.
* @param string $output Item output accumulator.
* @param WP_Post $item Menu item data.
* @param int $depth Depth of menu item.
* @param int $depth Menu depth.
* @param stdClass $args Menu arguments.
* @param int $id Current item ID.
* @return void
*/
public function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ) {
$indent = ( $depth ) ? str_repeat( "\t", $depth ) : '';
$classes = empty( $item->classes ) ? array() : (array) $item->classes;
$indent = ( $depth ) ? str_repeat( "\t", $depth ) : '';
$classes = empty( $item->classes ) ? array() : (array) $item->classes;
$classes[] = 'menu-item-' . $item->ID;
if ( in_array( 'current-menu-item', $classes, true ) || in_array( 'current-menu-parent', $classes, true ) ) {
$classes[] = 'active';
}
$args = apply_filters( 'nav_menu_item_args', $args, $item, $depth );
$args = apply_filters( 'nav_menu_item_args', $args, $item, $depth );
$class_names = join( ' ', apply_filters( 'nav_menu_css_class', array_filter( $classes ), $item, $args, $depth ) );
$class_names = $class_names ? ' class="' . esc_attr( $class_names ) . '"' : '';
@@ -87,17 +89,55 @@ class Peptide_Starter_Nav_Walker extends Walker_Nav_Menu {
}
/**
* Display custom logo with fallback to site name.
* Fallback menu when no primary nav is assigned in WP admin.
*
* Previously lived inline inside header.php; moved here in v1.5.1 to
* prevent redeclaration if header.php is ever included twice.
*
* @return void Echoes a complete <ul>.
*/
function peptide_starter_primary_menu_fallback() {
?>
<ul>
<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php esc_html_e( 'Home', 'peptide-starter' ); ?></a></li>
<li class="menu-item-has-children">
<a href="#"><?php esc_html_e( 'Tools', 'peptide-starter' ); ?></a>
<ul class="sub-menu">
<li><a href="<?php echo esc_url( home_url( '/calculator' ) ); ?>"><?php esc_html_e( 'Calculator', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/protocol-builder' ) ); ?>"><?php esc_html_e( 'Protocol Builder', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/tracker' ) ); ?>"><?php esc_html_e( 'Tracker', 'peptide-starter' ); ?></a></li>
</ul>
</li>
<li class="menu-item-has-children">
<a href="#"><?php esc_html_e( 'My Data', 'peptide-starter' ); ?></a>
<ul class="sub-menu">
<li><a href="<?php echo esc_url( home_url( '/peptides' ) ); ?>"><?php esc_html_e( 'Peptides', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/subject-log' ) ); ?>"><?php esc_html_e( 'Subject Log', 'peptide-starter' ); ?></a></li>
</ul>
</li>
<li class="menu-item-has-children">
<a href="#"><?php esc_html_e( 'Resources', 'peptide-starter' ); ?></a>
<ul class="sub-menu">
<li><a href="<?php echo esc_url( home_url( '/documentation' ) ); ?>"><?php esc_html_e( 'Documentation', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/news' ) ); ?>"><?php esc_html_e( 'Science Feed', 'peptide-starter' ); ?></a></li>
</ul>
</li>
</ul>
<?php
}
/**
* Display custom logo with fallback to site name link.
*
* @return void
*/
function peptide_starter_the_custom_logo() {
if ( function_exists( 'the_custom_logo' ) ) {
if ( function_exists( 'the_custom_logo' ) && has_custom_logo() ) {
the_custom_logo();
} else {
$site_name = get_bloginfo( 'name' );
echo '<a class="site-logo" href="' . esc_url( home_url( '/' ) ) . '" rel="home">' . esc_html( $site_name ) . '</a>';
return;
}
$site_name = get_bloginfo( 'name' );
echo '<a class="site-logo" href="' . esc_url( home_url( '/' ) ) . '" rel="home">' . esc_html( $site_name ) . '</a>';
}
/**
@@ -137,16 +177,16 @@ function peptide_starter_get_footer_copyright() {
}
/**
* Check if newsletter form should display.
* Whether to render the newsletter signup section. Filterable.
*
* @return bool Whether to show the newsletter form.
* @return bool
*/
function peptide_starter_show_newsletter_form() {
return apply_filters( 'peptide_starter_show_newsletter', true );
}
/**
* Render pagination links for archive pages.
* Render pagination for archive pages.
*
* @return void
*/
@@ -161,6 +201,7 @@ function peptide_starter_pagination() {
$total = $wp_query->max_num_pages;
echo '<nav class="ps-pagination">';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- paginate_links returns safe HTML.
echo paginate_links(
array(
'base' => get_pagenum_link( 1 ) . '%_%',
@@ -176,11 +217,86 @@ function peptide_starter_pagination() {
}
/**
* Check if a specific plugin is active by its main file path.
* Whether a plugin is active by its main file path.
*
* @param string $plugin Plugin file path relative to plugins directory.
* @return bool Whether the plugin is active.
* @param string $plugin Plugin file path relative to plugins/.
* @return bool
*/
function peptide_starter_is_plugin_active( $plugin ) {
return in_array( $plugin, (array) get_option( 'active_plugins', array() ), true );
}
/**
* Safe wp_get_referer fallback — never returns empty.
*
* @return string Referer or home_url('/').
*/
function peptide_starter_safe_referer() {
$ref = wp_get_referer();
return $ref ? $ref : home_url( '/' );
}
/**
* Gate a template behind login + verified email.
*
* Unauthenticated → redirect to /auth?redirect_to=current-uri.
* Logged in but unverified → redirect to /profile?verify_required=1.
*
* Side effects: emits 302 and exits the request.
*
* @param string|null $redirect_to Optional override for post-login target.
* @return void
*/
function peptide_starter_require_login( $redirect_to = null ) {
if ( ! is_user_logged_in() ) {
$target = $redirect_to ? $redirect_to : ( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/' );
wp_safe_redirect( home_url( '/auth?redirect_to=' . rawurlencode( $target ) ) );
exit;
}
if ( function_exists( 'peptide_starter_user_is_verified' ) && ! peptide_starter_user_is_verified( get_current_user_id() ) ) {
wp_safe_redirect( home_url( '/profile?verify_required=1' ) );
exit;
}
}
/**
* Render a honeypot input block for a given form.
*
* Pair with peptide_starter_honeypot_triggered() in the handler.
*
* @param string $form_name Short slug embedded in the field name.
* @return void Echoes HTML.
*/
function peptide_starter_render_honeypot( $form_name ) {
$field = 'ps_hp_' . preg_replace( '/[^a-z0-9_]/', '', $form_name );
?>
<div class="ps-hp-wrap" aria-hidden="true">
<label>
<?php esc_html_e( 'Leave this field empty', 'peptide-starter' ); ?>
<input type="text" name="<?php echo esc_attr( $field ); ?>" tabindex="-1" autocomplete="off" value="">
</label>
</div>
<?php
}
/**
* CSV-injection-safe value for fputcsv output.
*
* Prefixes dangerous leading characters (=, +, -, @, tab, CR) with a
* single quote so Excel/Sheets won't interpret the cell as a formula.
*
* @param string $value Raw cell value.
* @return string Safe cell value.
*/
function peptide_starter_csv_safe( $value ) {
$value = (string) $value;
if ( '' === $value ) {
return $value;
}
$dangerous = array( '=', '+', '-', '@', "\t", "\r" );
if ( in_array( $value[0], $dangerous, true ) ) {
return "'" . $value;
}
return $value;
}

175
inc/mail-diagnostic.php Normal file
View File

@@ -0,0 +1,175 @@
<?php
/**
* wp_mail Deliverability Diagnostic
*
* Admin-only tool under Tools → Mail Test. Sends a test message via
* wp_mail and reports whether delivery was accepted by the MTA,
* timing, and any PHPMailer error. Useful every time Hostinger (or any
* shared host) gets flaky on outbound SMTP.
*
* @see inc/config.php — no config dependency; uses hardcoded test body
* @see functions.php — includes this file
*
* What: Admin diagnostic page that exercises wp_mail().
* Who calls it: WordPress admin_menu + admin_post_*.
* Dependencies: wp_mail, phpmailer_init hook capture.
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Register the Mail Test admin page under Tools.
*
* @return void
*/
function peptide_starter_mail_diagnostic_menu() {
add_management_page(
__( 'Mail Deliverability Test', 'peptide-starter' ),
__( 'Mail Test', 'peptide-starter' ),
'manage_options',
'ps-mail-test',
'peptide_starter_mail_diagnostic_page'
);
}
add_action( 'admin_menu', 'peptide_starter_mail_diagnostic_menu' );
/**
* Render the Mail Test admin page.
*
* @return void
*/
function peptide_starter_mail_diagnostic_page() {
$result = get_transient( 'ps_mail_test_result' );
if ( $result ) {
delete_transient( 'ps_mail_test_result' );
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Mail Deliverability Test', 'peptide-starter' ); ?></h1>
<p>
<?php esc_html_e( 'Sends a test message via wp_mail() to the address you specify and reports whether the local MTA accepted it. Use this after every host-side change to verify transactional mail (registration, verification, password reset) still works.', 'peptide-starter' ); ?>
</p>
<?php if ( is_array( $result ) ) : ?>
<div class="notice <?php echo $result['sent'] ? 'notice-success' : 'notice-error'; ?>">
<p>
<strong>
<?php
echo esc_html(
$result['sent']
? __( 'wp_mail returned true (local MTA accepted).', 'peptide-starter' )
: __( 'wp_mail returned false (local MTA rejected or plugin error).', 'peptide-starter' )
);
?>
</strong>
</p>
<p>
<?php
echo esc_html(
sprintf(
/* translators: 1: recipient, 2: duration in ms, 3: phpmailer error */
__( 'Recipient: %1$s — Duration: %2$s ms — PHPMailer: %3$s', 'peptide-starter' ),
$result['to'],
number_format( $result['ms'] ),
'' === $result['error'] ? '—' : $result['error']
)
);
?>
</p>
<p>
<?php esc_html_e( 'Note: wp_mail returning true only means the outbound MTA accepted the message. Spam placement can only be verified by checking the recipient inbox.', 'peptide-starter' ); ?>
</p>
</div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'ps_mail_test', 'ps_mail_test_nonce' ); ?>
<input type="hidden" name="action" value="ps_mail_test">
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="ps_mail_to"><?php esc_html_e( 'Recipient', 'peptide-starter' ); ?></label></th>
<td>
<input
type="email"
id="ps_mail_to"
name="to"
class="regular-text"
value="<?php echo esc_attr( get_option( 'admin_email' ) ); ?>"
required
>
</td>
</tr>
</table>
<?php submit_button( __( 'Send Test Email', 'peptide-starter' ) ); ?>
</form>
</div>
<?php
}
/**
* Handle the test-email POST.
*
* Side effects: calls wp_mail, stores a short-lived transient with the
* result, redirects back to the admin page.
*
* @return void
*/
function peptide_starter_mail_diagnostic_send() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient privileges.', 'peptide-starter' ), '', array( 'response' => 403 ) );
}
if ( ! isset( $_POST['ps_mail_test_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['ps_mail_test_nonce'] ) ), 'ps_mail_test' ) ) {
wp_die( esc_html__( 'Security check failed.', 'peptide-starter' ), '', array( 'response' => 403 ) );
}
$to = isset( $_POST['to'] ) ? sanitize_email( wp_unslash( $_POST['to'] ) ) : '';
if ( ! is_email( $to ) ) {
wp_safe_redirect( admin_url( 'tools.php?page=ps-mail-test' ) );
exit;
}
$captured_error = '';
$capture = function ( $wp_error ) use ( &$captured_error ) {
if ( is_wp_error( $wp_error ) ) {
$captured_error = $wp_error->get_error_message();
}
};
add_action( 'wp_mail_failed', $capture );
$start = microtime( true );
$subject = sprintf(
/* translators: %s: site name */
__( '[%s] Mail deliverability test', 'peptide-starter' ),
wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES )
);
$body = sprintf(
/* translators: %s: site url */
__( "This is a wp_mail deliverability test from %s.\n\nIf you received this, wp_mail is working.", 'peptide-starter' ),
home_url( '/' )
);
$sent = wp_mail( $to, $subject, $body );
$elapsed = ( microtime( true ) - $start ) * 1000;
remove_action( 'wp_mail_failed', $capture );
set_transient(
'ps_mail_test_result',
array(
'sent' => (bool) $sent,
'to' => $to,
'ms' => (int) $elapsed,
'error' => (string) $captured_error,
),
60
);
wp_safe_redirect( admin_url( 'tools.php?page=ps-mail-test' ) );
exit;
}
add_action( 'admin_post_ps_mail_test', 'peptide_starter_mail_diagnostic_send' );

View File

@@ -2,16 +2,19 @@
/**
* Newsletter Admin Page
*
* Simple admin interface to view and export collected newsletter emails.
* Emails are stored in wp_options as 'ps_newsletter_emails' until a
* proper mailing service (Mailchimp, etc.) is integrated.
* 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 — newsletter signup handler (stores emails)
* @see template-parts/newsletter-signup.php — frontend form
* @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 page under Tools for viewing/exporting subscriber emails.
* Who calls it: WordPress admin_menu action.
* Dependencies: None — reads from wp_options.
* 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
*/
@@ -40,13 +43,12 @@ add_action( 'admin_menu', 'peptide_starter_newsletter_admin_menu' );
/**
* Render the newsletter admin page.
*
* Displays a simple table of subscriber emails with date added.
* Includes a CSV export button.
*
* @return void
*/
function peptide_starter_newsletter_admin_page() {
$emails = get_option( 'ps_newsletter_emails', array() );
$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>
@@ -55,13 +57,29 @@ function peptide_starter_newsletter_admin_page() {
echo esc_html(
sprintf(
/* translators: %d: number of subscribers */
_n( '%d subscriber', '%d subscribers', count( $emails ), 'peptide-starter' ),
count( $emails )
_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' ); ?>
@@ -74,13 +92,20 @@ function peptide_starter_newsletter_admin_page() {
<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( $entry['date'] ); ?></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>
@@ -95,6 +120,11 @@ function peptide_starter_newsletter_admin_page() {
/**
* 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() {
@@ -117,13 +147,79 @@ function peptide_starter_newsletter_export_csv() {
header( 'Content-Disposition: attachment; filename=newsletter-subscribers-' . gmdate( 'Y-m-d' ) . '.csv' );
$output = fopen( 'php://output', 'w' );
fputcsv( $output, array( 'Email', 'Date Added' ) );
fputcsv( $output, array( 'Email', 'Date Added', 'Status' ) );
foreach ( $emails as $entry ) {
fputcsv( $output, array( $entry['email'], $entry['date'] ) );
$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' );

View File

@@ -1,16 +1,18 @@
<?php
/**
* Auto-Create Pages on Theme Activation
* Auto-Create Pages + v1.5.0 User Migration
*
* Creates the required WordPress pages (with correct templates assigned)
* when the theme is activated, if they don't already exist.
* On theme activation, creates the required WordPress pages with the
* correct templates assigned, and runs a one-time migration that enrols
* pre-v1.5.1 users into the email-verification flow.
*
* @see functions.php — includes this file
* @see ARCHITECTURE.md — documents the page/template mapping
* @see inc/email-verification.php — peptide_starter_send_verification_email()
* @see ARCHITECTURE.md — page/template map
*
* What: Theme activation hook that auto-creates pages for all new templates.
* Who calls it: WordPress after_switch_theme action.
* Dependencies: None — uses WordPress core functions only.
* What: Theme-activation hooks for page provisioning and user enrolment.
* Who calls it: WordPress after_switch_theme.
* Dependencies: wp_insert_post, update_post_meta, user meta.
*
* @package peptide-starter
*/
@@ -23,8 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
/**
* Create required pages on theme activation.
*
* Checks if each page slug exists; creates it if missing with the
* correct page template assigned. Skips existing pages to avoid duplicates.
* Idempotent: re-assigns the template on existing pages.
*
* @return void
*/
@@ -81,7 +82,6 @@ function peptide_starter_create_pages() {
$existing = get_page_by_path( $page_data['slug'] );
if ( $existing ) {
// Page exists — ensure the template is assigned.
update_post_meta( $existing->ID, '_wp_page_template', $page_data['template'] );
continue;
}
@@ -103,3 +103,52 @@ function peptide_starter_create_pages() {
}
}
add_action( 'after_switch_theme', 'peptide_starter_create_pages' );
/**
* One-time migration: enrol v1.5.0 subscribers into email verification.
*
* Runs once per site — guarded by the ps_verify_migration_version option.
* Iterates subscribers in batches of 50 via wp_mail(). Users created by
* admin (any role above subscriber) are skipped — we assume admin-created
* accounts are trusted.
*
* Side effects: writes user meta + sends email per enrolled user.
*
* Cost note: worst case N emails on first admin page load after deploy.
* If the site has many users, move this to WP-Cron before shipping a
* large-user release (not a concern for the current zero-user site).
*
* @return void
*/
function peptide_starter_migrate_existing_users_to_verification() {
if ( ! is_admin() ) {
return;
}
$done = get_option( 'ps_verify_migration_version' );
if ( PEPTIDE_STARTER_VERSION === $done ) {
return;
}
$subscribers = get_users(
array(
'role' => 'subscriber',
'fields' => array( 'ID' ),
'number' => 500,
'orderby' => 'ID',
'order' => 'ASC',
)
);
foreach ( $subscribers as $user ) {
$already_enrolled = get_user_meta( $user->ID, 'ps_pending_verification', true );
if ( '' !== (string) $already_enrolled ) {
continue;
}
if ( function_exists( 'peptide_starter_send_verification_email' ) ) {
peptide_starter_send_verification_email( $user->ID );
}
}
update_option( 'ps_verify_migration_version', PEPTIDE_STARTER_VERSION, false );
}
add_action( 'admin_init', 'peptide_starter_migrate_existing_users_to_verification' );

159
inc/rate-limiter.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
/**
* Transient-backed Rate Limiter
*
* Generic check/record/reset limiter used by every public endpoint
* (login, register, contact, newsletter, verify-resend). State lives
* in WordPress transients — auto-expires, no cleanup required.
*
* Storage key format: ps_rl_{action}_{hash} where
* hash = substr( wp_hash( $ip . '|' . $identifier ), 0, 16 ).
* Raw IPs are never stored.
*
* @see inc/config.php — supplies per-action limit + window.
* @see inc/auth-handlers.php, inc/contact-handler.php — primary callers.
* @see functions.php — includes this file.
*
* What: Rate limit check/record/reset for any action+identifier pair.
* Who calls it: Every public handler after nonce verification.
* Dependencies: WordPress transients, inc/config.php, peptide_starter_get_client_ip().
*
* @package peptide-starter
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Static rate limiter using WordPress transients.
*
* What: Tracks attempt counts per (action, identifier) tuple.
* Who calls it: Public handlers after nonce verification.
* Dependencies: Transient storage, peptide_starter_security_config().
*/
class Peptide_Starter_Rate_Limiter {
/**
* Build the transient key for a given action + identifier.
*
* Uses a truncated wp_hash so the stored key never exposes the raw IP
* or raw identifier. 16 hex chars ≈ 64 bits of collision resistance —
* sufficient for the cardinality of a WP site.
*
* @param string $action Action slug (e.g. 'login').
* @param string $identifier Extra identifier (email hash, form slug, etc.).
* @return string Transient key, always prefixed ps_rl_.
*/
protected static function build_key( $action, $identifier ) {
$ip = peptide_starter_get_client_ip();
$hash = substr( wp_hash( $ip . '|' . $identifier ), 0, 16 );
return 'ps_rl_' . preg_replace( '/[^a-z0-9_]/i', '', $action ) . '_' . $hash;
}
/**
* Check whether the caller is still within their budget.
*
* Does not mutate state — call record() after a failed attempt (or at the
* start of every attempt if you want to meter successes too).
*
* @param string $action Action slug configured in security config.
* @param string $identifier Caller-supplied identifier (e.g. sha256(email)).
* @return bool True if allowed, false if over budget.
*/
public static function check( $action, $identifier ) {
$limits = peptide_starter_config_rate_limit( $action );
$key = self::build_key( $action, $identifier );
$current = (int) get_transient( $key );
return $current < $limits['limit'];
}
/**
* Record an attempt — increments the counter, sets TTL on first write.
*
* Side effects: writes a transient.
*
* @param string $action Action slug.
* @param string $identifier Caller-supplied identifier.
* @return void
*/
public static function record( $action, $identifier ) {
$limits = peptide_starter_config_rate_limit( $action );
$key = self::build_key( $action, $identifier );
$current = (int) get_transient( $key );
set_transient( $key, $current + 1, $limits['window'] );
}
/**
* Clear the counter for an action + identifier.
*
* Call on successful login so a legit user isn't locked out by their
* own earlier typos.
*
* Side effects: deletes a transient.
*
* @param string $action Action slug.
* @param string $identifier Caller-supplied identifier.
* @return void
*/
public static function reset( $action, $identifier ) {
$key = self::build_key( $action, $identifier );
delete_transient( $key );
}
}
/**
* Best-effort client IP resolver.
*
* Order of trust (first match wins):
* 1. HTTP_CF_CONNECTING_IP — set by Cloudflare edge (peptiderepo.com uses CF).
* 2. HTTP_X_FORWARDED_FOR — first entry; only trusted on hosts that set it.
* 3. REMOTE_ADDR — direct peer.
*
* Note: the hosting environment (Hostinger + Cloudflare) reliably populates
* HTTP_CF_CONNECTING_IP. If deployment moves off CF, the filter below can be
* used to override.
*
* @return string IPv4/IPv6 string or '0.0.0.0' when nothing parseable.
*/
function peptide_starter_get_client_ip() {
$candidates = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' );
$ip = '';
foreach ( $candidates as $key ) {
if ( empty( $_SERVER[ $key ] ) ) {
continue;
}
$raw = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
$first = trim( explode( ',', $raw )[0] );
if ( filter_var( $first, FILTER_VALIDATE_IP ) ) {
$ip = $first;
break;
}
}
if ( '' === $ip ) {
$ip = '0.0.0.0';
}
/**
* Filter the resolved client IP — for custom infra overrides.
*
* @param string $ip Resolved IP.
*/
return apply_filters( 'peptide_starter_client_ip', $ip );
}
/**
* Hash an identifier for rate-limit keying without storing plaintext.
*
* Use this for any identifier you don't want to hash in-line at every call
* site (typically the lowercased email address on login).
*
* @param string $value Raw identifier.
* @return string 32-char hex substring of a sha256.
*/
function peptide_starter_hash_identifier( $value ) {
return substr( hash( 'sha256', (string) $value ), 0, 32 );
}