diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4091257..635c5b2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,91 +1,96 @@
-# Peptide Starter Theme - Architecture
+# Peptide Starter Theme — Architecture
## Overview
-Peptide Starter (peptide-starter) is a premium, accessible WordPress theme for peptiderepo.com — a scientific peptide reference database. The theme is a classic (non-block) WordPress theme that assembles a modern, responsive frontend by combining a monolithic CSS architecture with vanilla JavaScript. It integrates seamlessly with five companion plugins (Peptide Search AI, Peptide News, PRAutoBlogger, Peptide Tools, Peptide Tracker, Peptide Community) via shortcodes, coordinates dark mode across the ecosystem via CSS custom properties and the DOM `data-theme` attribute, and provides customizable hero, navigation, authentication, and footer sections.
+Peptide Starter (slug: `peptide-starter`) is a premium classic WordPress
+theme for peptiderepo.com — a scientific peptide reference database. It
+assembles a modern, accessible frontend from a monolithic stylesheet, a
+handful of vanilla-JS modules, and a collection of PHP modules under
+`inc/`. The theme integrates with six companion plugins through
+shortcodes (graceful degradation via `shortcode_exists`), coordinates
+dark mode via CSS custom properties + a `data-theme` attribute, and — as
+of v1.5.1 — runs a self-contained abuse-control layer (rate limiting,
+honeypots, email verification) over its frontend auth surface.
## Directory Structure
```
peptide-starter/
-├── style.css # All theme CSS. Architecture: reset → tokens → typography →
-│ # globals → components → regions → pages → animations →
-│ # a11y → dark mode → responsive → new feature sections
-│
-├── functions.php # Theme setup entry point (~206 lines)
-│ # - Constants, feature module loading
-│ # - Theme support registration
-│ # - Script/style enqueueing (incl conditional)
-│ # - Widget areas, newsletter handler, dark mode inline script
+├── style.css # All theme CSS. Section 26 = v1.5.1 additions.
+├── functions.php # Theme setup entry point (<250 lines).
+│ # - Constants, feature module loading (order matters)
+│ # - Theme support + nav menus
+│ # - Script/style enqueue (conditional per template)
+│ # - Newsletter signup POST handler
+│ # - Inline dark mode flash preventer in wp_head
│
├── inc/
-│ ├── helpers.php # Nav walker, custom logo, hero/footer getters, pagination
-│ ├── customizer.php # Customizer sections, settings, sanitize callbacks
-│ ├── auth-handlers.php # AJAX login + registration handlers
-│ ├── contact-handler.php # AJAX contact form handler (settings panel)
-│ ├── page-setup.php # Auto-create pages on theme activation
-│ └── newsletter-admin.php # Admin page to view/export subscriber emails
+│ ├── config.php # [v1.5.1] Security config — single source of truth.
+│ │ # Every threshold lives here; filterable.
+│ ├── helpers.php # Nav walker + menu fallback, logo, getters,
+│ │ # pagination, honeypot renderer, csv_safe,
+│ │ # require_login gate, safe referer.
+│ ├── rate-limiter.php # [v1.5.1] Peptide_Starter_Rate_Limiter +
+│ │ # peptide_starter_get_client_ip (CF-aware).
+│ ├── email-verification.php # [v1.5.1] Token send + verify route + resend
+│ │ # endpoint + user_is_verified().
+│ ├── customizer.php # Customizer sections/settings/controls.
+│ ├── auth-handlers.php # AJAX login + register — unified errors,
+│ │ # honeypot, rate limit, no auto-login.
+│ ├── contact-handler.php # AJAX contact — header-injection guard,
+│ │ # honeypot, rate limit, request IDs in mail.
+│ ├── newsletter-admin.php # Subscriber viewer + CSV-safe export +
+│ │ # /newsletter-unsubscribe route.
+│ ├── mail-diagnostic.php # [v1.5.1] Tools → Mail Test — wp_mail probe.
+│ └── page-setup.php # Auto-create pages on activation +
+│ # v1.5.0 user migration (enrol into verify).
│
-├── header.php # Global header (~190 lines)
-│ # - Site logo, primary navigation with dropdowns
-│ # - Sign In / User menu (state-aware)
-│ # - Settings icon, language placeholder
-│ # - Search, dark mode toggle, cart, mobile menu
-│ # - Search overlay, fallback nav
+├── header.php # Site header, nav, icons, search overlay.
+├── footer.php # Widget grid + copyright + settings panel.
+├── front-page.php # Hero → module cards → plugin feeds.
│
-├── footer.php # Global footer (~100 lines)
-│ # - Newsletter signup (front page via template part)
-│ # - 4-column widget grid with updated defaults
-│ # - Copyright, disclaimer, language links
-│ # - Settings panel include
-│
-├── front-page.php # Home page template (~100 lines)
-│ # - Hero section (title, subtitle, search, CTAs)
-│ # - Research Modules grid (6 cards)
-│ # - [prautoblogger_posts] shortcode
-│ # - [peptide_news] shortcode
-│
-├── page-calculator.php # Calculator page template → [peptide_tools_calculator]
-├── page-protocol-builder.php # Protocol Builder template → [peptide_tools_protocol_builder]
-├── page-tracker.php # Tracker template → [peptide_tracker]
-├── page-subject-log.php # Subject Log template → [peptide_tracker_subject_log]
-├── page-documentation.php # Documentation/SOP page — two-column layout with ToC sidebar
-├── page-directory.php # Peptide Directory template → [peptide_directory]
-├── page-science-feed.php # Science Feed template → [peptide_news] + newsletter
-├── page-profile.php # User Profile template → [peptide_community_profile]
-├── page-auth.php # Sign In / Register — branded auth forms with AJAX
-│
-├── page.php # Generic page template
-├── index.php # Fallback template
-├── single-peptide.php # Peptide detail page (CPT)
-├── archive-peptide.php # Peptide archive (CPT grid)
-├── 404.php # 404 error page
-├── searchform.php # Search form template
+├── page-calculator.php # OPEN — reconstitution calculator (no PII).
+├── page-protocol-builder.php # GATED — require_login (builds saved protocols).
+├── page-tracker.php # GATED — require_login (per-user tracking).
+├── page-subject-log.php # GATED — require_login (user lab data).
+├── page-directory.php # Peptide directory.
+├── page-documentation.php # Two-column docs with ToC sidebar.
+├── page-science-feed.php # Science feed + newsletter signup.
+├── page-profile.php # Profile + verify-required banner + resend.
+├── page-auth.php # Sign In / Register forms (honeypotted).
+├── page.php, index.php, 404.php # Standard fallbacks.
+├── single-peptide.php # Peptide CPT detail.
+├── archive-peptide.php # Peptide CPT archive.
│
├── template-parts/
-│ ├── content.php # Default post/page loop item
-│ ├── content-peptide.php # Peptide CPT card template
-│ ├── content-none.php # Empty state template
-│ ├── module-cards.php # Research Modules 6-card grid (front page)
-│ ├── newsletter-signup.php # Reusable newsletter signup section
-│ └── settings-panel.php # Slide-out support/contact panel
+│ ├── content.php, content-*.php # Loop / CPT / empty state.
+│ ├── module-cards.php # Front-page 6-card grid.
+│ ├── newsletter-signup.php # [v1.5.1] Signup + consent + honeypot.
+│ └── settings-panel.php # Slide-out support/contact panel + honeypot.
│
├── assets/js/
-│ ├── navigation.js # Mobile menu toggle (85 lines)
-│ ├── theme.js # Dark mode + search overlay + utilities (210 lines)
-│ ├── documentation.js # ToC generation + scroll spy for docs page (145 lines)
-│ ├── auth.js # Auth form toggle, validation, AJAX (192 lines)
-│ └── settings-panel.js # Settings panel open/close + contact form (163 lines)
+│ ├── navigation.js # Mobile menu + body-overflow save/restore.
+│ ├── theme.js # Dark mode + search overlay + plugin API.
+│ ├── documentation.js # ToC builder (text-slug IDs) + scroll spy.
+│ ├── auth.js # Form toggle + AJAX + XSS-safe status.
+│ └── settings-panel.js # Open/close + focus trap + AJAX contact.
│
-├── languages/ # Translation files (.pot, .po, .mo)
-├── tests/ # PHPUnit tests
-├── .phpcs.xml.dist # PHPCS config
-├── phpunit.xml # PHPUnit config
-├── composer.json # Dev dependencies
-├── README.md # User-facing docs
-├── CLAUDE.md # Developer context
-├── CHANGELOG.md # Version history
-└── screenshot.png # Theme preview image
+├── tests/
+│ ├── bootstrap.php # PHPUnit bootstrap.
+│ ├── test-functions.php # Legacy helper tests.
+│ ├── test-theme-setup.php # Theme setup tests.
+│ ├── test-rate-limiter.php # [v1.5.1] Rate limiter lifecycle + IP resolver.
+│ ├── test-auth-handlers.php # [v1.5.1] Login + register unified errors.
+│ ├── test-email-verification.php# [v1.5.1] Token send/verify/reject.
+│ ├── test-contact-handler.php # [v1.5.1] Header injection + rate limit + honeypot.
+│ ├── test-newsletter.php # [v1.5.1] Signup + consent + CSV-safe.
+│ └── test-auth-gate.php # [v1.5.1] require_login redirects.
+│
+├── .phpcs.xml.dist # PHPCS config (WordPress-Core).
+├── phpunit.xml # PHPUnit config (theme suite).
+├── composer.json # Dev-only dependencies.
+├── CHANGELOG.md, CONVENTIONS.md, README.md, CLAUDE.md
+└── screenshot.png
```
## Navigation Structure
@@ -93,115 +98,141 @@ peptide-starter/
```
Home | Tools ▾ | My Data ▾ | Resources ▾ | [Sign In / Username ▾] ⚙ 🌐 🔍 🌙
-Tools dropdown:
- ├── Calculator → /calculator
- ├── Protocol Builder → /protocol-builder
- └── Tracker → /tracker
-
-My Data dropdown:
- ├── Peptides → /peptides
- └── Subject Log → /subject-log
-
-Resources dropdown:
- ├── Documentation → /documentation
- └── Science Feed → /news
-
-User menu (logged in):
- ├── My Profile → /profile
- ├── Tracker → /tracker
- ├── Subject Log → /subject-log
- └── Sign Out
+Tools: Calculator (open) · Protocol Builder · Tracker
+My Data: Peptides · Subject Log
+Resources: Documentation · Science Feed
+User menu: My Profile · Tracker · Subject Log · Sign Out
```
## Page Template Map
-| Page Title | Slug | Template File | Plugin Shortcode |
-|------------|------|---------------|-----------------|
-| Calculator | calculator | page-calculator.php | [peptide_tools_calculator] |
-| Protocol Builder | protocol-builder | page-protocol-builder.php | [peptide_tools_protocol_builder] |
-| Tracker | tracker | page-tracker.php | [peptide_tracker] |
-| Subject Log | subject-log | page-subject-log.php | [peptide_tracker_subject_log] |
-| Documentation | documentation | page-documentation.php | (WordPress content + JS ToC) |
-| Peptide Directory | peptides | page-directory.php | [peptide_directory] |
-| Science Feed | news | page-science-feed.php | [peptide_news] |
-| Profile | profile | page-profile.php | [peptide_community_profile] |
-| Sign In | auth | page-auth.php | (Custom auth forms) |
+| Page Title | Slug | Template | Gated | Shortcode |
+|-------------------|------------------|-----------------------------|-----------|-------------------------------------|
+| Calculator | calculator | page-calculator.php | open | [peptide_tools_calculator] |
+| Protocol Builder | protocol-builder | page-protocol-builder.php | login+verify | [peptide_tools_protocol_builder] |
+| Tracker | tracker | page-tracker.php | login+verify | [peptide_tracker] |
+| Subject Log | subject-log | page-subject-log.php | login+verify | [peptide_tracker_subject_log] |
+| Documentation | documentation | page-documentation.php | open | — |
+| Peptide Directory | peptides | page-directory.php | open | [peptide_directory] |
+| Science Feed | news | page-science-feed.php | open | [peptide_news] |
+| Profile | profile | page-profile.php | login | [peptide_community_profile] |
+| Sign In | auth | page-auth.php | open | (custom forms) |
-Pages are auto-created on theme activation via `inc/page-setup.php`.
+Pages auto-created via `inc/page-setup.php` on theme activation.
+"Gated" templates call `peptide_starter_require_login()` before `get_header()`.
-## Data Flow & Architecture Diagrams
+## Data Flow
-### Front Page Assembly
-
-```
-front-page.php
- ├─→ Hero Section (title, subtitle, search, CTAs)
- │ └─→ [peptide_search] shortcode (Peptide Search AI plugin)
- │
- ├─→ Research Modules Grid (template-parts/module-cards.php)
- │ └─→ 6 cards linking to tool/section pages
- │ └─→ Checks shortcode_exists() for graceful degradation
- │
- ├─→ [prautoblogger_posts] shortcode (PRAutoBlogger plugin)
- │
- └─→ [peptide_news] shortcode (Peptide News plugin)
-```
-
-### Authentication Flow
+### Authentication (v1.5.1)
```
page-auth.php
- ├─→ Sign In form → auth.js AJAX → inc/auth-handlers.php
- │ └─→ wp_signon() → redirect to original page
- │
- └─→ Register form → auth.js AJAX → inc/auth-handlers.php
- └─→ wp_create_user() → auto-login → redirect to /profile
+ ├─ Sign In form → auth.js AJAX → inc/auth-handlers.php::peptide_starter_ajax_login
+ │ ├─ Nonce check
+ │ ├─ Honeypot check (fake success on trip)
+ │ ├─ Rate-limit check (login bucket, IP+email hash)
+ │ ├─ wp_signon (all failures → unified message)
+ │ └─ Success → reset limiter → redirect
+ │ (unverified users → /profile?verify_required=1)
+ │
+ └─ Register form → auth.js AJAX → inc/auth-handlers.php::peptide_starter_ajax_register
+ ├─ Nonce check
+ ├─ Honeypot check (fake success on trip)
+ ├─ Rate-limit check (register bucket, IP only)
+ ├─ Validation (all failures → unified message)
+ ├─ wp_create_user
+ ├─ peptide_starter_send_verification_email
+ │ ├─ Generate 43-char token
+ │ ├─ Set ps_pending_verification=1,
+ │ │ ps_verify_token, ps_verify_expires
+ │ └─ wp_mail with /verify?uid=&token= link
+ └─ Respond with "check your inbox" (no redirect, no auto-login)
+
+/verify?uid=&token=
+ → inc/email-verification.php::peptide_starter_handle_verify_request
+ ├─ hash_equals() token match + TTL check
+ ├─ Clear verify meta on success
+ ├─ wp_set_auth_cookie
+ └─ Redirect /profile?verified=1 (failure → /auth?verify_error=1)
```
-### Dark Mode Coordination
+### Abuse controls (v1.5.1)
```
-functions.php (wp_head inline script)
- └─→ Sets html[data-theme] from localStorage → system pref → customizer default
- ├─→ style.css reads data-theme for color overrides
- └─→ theme.js dispatches 'themechange' event to plugins
+Every public form submit:
+ 1. Nonce verify → 403-equivalent security error if missing/invalid
+ 2. Honeypot check → Fake-success drop on non-empty hidden field
+ 3. Peptide_Starter_Rate_Limiter → Silent generic error on budget exhaustion
+ 4. Business logic → Unified error on any validation failure
+
+Rate-limit state:
+ Storage: wp_options transients, key ps_rl_{action}_{hash}
+ Hash: substr( wp_hash( ip . '|' . identifier ), 0, 16 )
+ Never stores raw IPs or identifiers.
+```
+
+### Front page assembly
+
+```
+front-page.php
+ ├─ Hero (title, subtitle, search CTA)
+ │ └─ [peptide_search] (Peptide Search AI)
+ ├─ Research Modules grid (template-parts/module-cards.php)
+ ├─ [prautoblogger_posts] (PRAutoBlogger)
+ └─ [peptide_news] (Peptide News)
+```
+
+### Dark mode coordination
+
+```
+wp_head inline script sets html[data-theme] from localStorage → system pref → customizer default
+CSS :root vs html[data-theme="dark"] colour tokens swap
+theme.js toggle writes localStorage, dispatches 'themechange' DOM event
```
## External Integrations
-### Plugin Integrations (via Shortcodes)
+Shortcodes (plugins), all wrapped in `shortcode_exists()`:
-1. **Peptide Search AI** (`[peptide_search]`) — Hero + search overlay. Namespace: `.psa-*`
-2. **Peptide News** (`[peptide_news]`) — Front page + science feed. Namespace: `.pn-*`
-3. **PRAutoBlogger** (`[prautoblogger_posts]`) — Front page articles. Namespace: `.prab-*`
-4. **Peptide Tools** (`[peptide_tools_calculator]`, `[peptide_tools_protocol_builder]`) — Tool pages
-5. **Peptide Tracker** (`[peptide_tracker]`, `[peptide_tracker_subject_log]`) — Tracker pages
-6. **Peptide Community** (`[peptide_community_profile]`, `[peptide_directory]`) — Profile + directory
-
-All plugins checked with `shortcode_exists()` for graceful degradation.
+1. **Peptide Search AI** (`[peptide_search]`) — `.psa-*`
+2. **Peptide News** (`[peptide_news]`) — `.pn-*`
+3. **PRAutoBlogger** (`[prautoblogger_posts]`) — `.prab-*`
+4. **Peptide Tools** (`[peptide_tools_calculator]`, `[peptide_tools_protocol_builder]`)
+5. **Peptide Tracker** (`[peptide_tracker]`, `[peptide_tracker_subject_log]`)
+6. **Peptide Community** (`[peptide_community_profile]`, `[peptide_directory]`)
## Key Architectural Decisions
-1. **Classic Theme (Not FSE)** — Stability, control, plugin compatibility
-2. **Monolithic CSS** — No build step, CSS custom properties for abstraction
-3. **Vanilla JavaScript** — No bundler, direct browser ES6+, `node --check` validation
-4. **Shortcode Integration** — Decouples plugins from theme via `shortcode_exists()`
-5. **CSS Custom Properties for Dark Mode** — Single source of truth for colors
-6. **No `!important` Policy** — Explicit specificity over cascade tricks
-7. **Inline Dark Mode Script** — Prevents flash of wrong theme on load
-8. **Split functions.php into inc/** — Keeps all files under 300 lines
+1. **Classic theme (not FSE)** — stability, plugin compatibility.
+2. **Monolithic CSS** — no build step, CSS custom properties for tokens.
+3. **Vanilla JavaScript** — no bundler; `node --check` in CI.
+4. **Shortcode integration** — plugins decoupled; theme degrades gracefully.
+5. **Split `functions.php` into `inc/` modules** — every file < 300 lines.
+6. **Self-contained auth hardening (v1.5.1 / ADR-0001)** — rate limiter,
+ honeypots, email verification all live in the theme. No plugin
+ dependencies, no paid services. Swappable via two small interfaces
+ (`Peptide_Starter_Rate_Limiter`, `peptide_starter_require_login`).
+7. **Transient-backed rate limits** — auto-expire, no custom table, no
+ uninstall cleanup. If the site ever goes multi-server, swap to the
+ shared object cache without caller changes.
-## Security
+## Security Summary
-- CSRF nonces on all forms (auth, newsletter, contact)
-- All input sanitized at boundary (`sanitize_text_field`, `sanitize_email`, `sanitize_textarea_field`)
-- All output escaped (`esc_html`, `esc_attr`, `esc_url`, `wp_kses_post`)
-- Login redirect validated with `wp_validate_redirect()` to prevent open redirects
-- Auth form uses AJAX to avoid exposing credentials in URL
-- Contact form topics restricted to allowlist
+- CSRF nonces on every form (auth, newsletter, contact, mail-test, unsubscribe).
+- Unified error messages on login + registration (no enumeration).
+- Rate limiting on login / register / contact / newsletter / verify-resend.
+- Honeypots on the four public forms (login, register, contact, newsletter).
+- `require_login()` gate on every template that owns user data.
+- Email verification required for write access to user-data tools.
+- CSV export formula-injection safe.
+- All input sanitized at the boundary; all output escaped.
+- Login redirect validated with `wp_validate_redirect`.
+- Rate-limit storage keys are hashed — no raw IPs persisted.
+- Contact sender names reject header-injection characters before `wp_mail`.
## Version
-**Current:** v1.5.0
+**Current:** v1.5.1 (2026-04-14)
-When releasing a new version, update `style.css` header (line 7) and `PEPTIDE_STARTER_VERSION` in `functions.php`.
+Bump `style.css` header line 7 and `PEPTIDE_STARTER_VERSION` in
+`functions.php` together on release.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2fcb74..206fa09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,129 @@
All notable changes to the Peptide Starter Theme are documented in this file.
+## [1.5.1] - 2026-04-14 — Security
+
+Security-only release hardening the frontend authentication surface introduced
+in v1.5.0. Governed by ADR-0001 (Frontend authentication and abuse-control
+strategy). No breaking changes to public routes, AJAX action names, or nonce
+action strings.
+
+### Security fixes
+
+- **PSEC-001** — Rate limiting on login (5/min per IP+email), registration
+ (3/hour per IP), contact (5/hour per IP), newsletter (3/hour per IP), and
+ verification resend (2/hour per IP+email). Transient-backed, auto-expiring.
+ Storage never holds raw IPs — keys are truncated `wp_hash` digests.
+- **PSEC-002** — Unified error messages on login and registration. Removed
+ separate "username exists" / "email exists" / "user not found" branches
+ that enabled account enumeration via the error-message oracle. All login
+ failures now return `Invalid email or password.`; all registration
+ validation failures return `Unable to create account. Please check your
+ entries and try again.`
+- **PSEC-003** — Removed auto-login from the registration handler. New
+ registrations now go through an email verification flow: a 43-char random
+ token (stored in user meta with a 24h TTL) is emailed via `wp_mail` and
+ must be clicked to activate the account. Verification route is `/verify`.
+ `wp_mail` deliverability can be validated any time via the new admin tool
+ at Tools → Mail Test.
+- **PSEC-004** — `peptide_starter_require_login()` gate applied at the top
+ of `page-subject-log.php`, `page-tracker.php`, `page-protocol-builder.php`.
+ Unauthenticated users bounce to `/auth?redirect_to=…`; unverified users
+ bounce to `/profile?verify_required=1` which surfaces a resend button.
+ `page-calculator.php` stays open — it holds no PII and does no writes.
+- **PSEC-005** — CSV injection fix in the newsletter admin export. Every
+ cell passed to `fputcsv` routes through `peptide_starter_csv_safe()`,
+ which prefixes values starting with `=`, `+`, `-`, `@`, `\t`, `\r` with
+ a single quote so spreadsheet apps won't interpret them as formulas.
+- **PSEC-006** — Honeypot fields on all four public forms (login, register,
+ contact, newsletter). Any non-empty value fakes a success response and
+ drops the submission. No PII is ever logged — only a sha256-truncated
+ IP hash so admins can correlate if they need to.
+
+### Added
+
+- `inc/config.php` — single source of truth for every security threshold;
+ filterable via `peptide_starter_security_config`.
+- `inc/rate-limiter.php` — `Peptide_Starter_Rate_Limiter` class with
+ `check`/`record`/`reset` lifecycle + `peptide_starter_get_client_ip()`
+ helper (Cloudflare-aware).
+- `inc/email-verification.php` — token generation, verification route
+ handler, user-verified check, and rate-limited AJAX resend endpoint
+ (`ps_resend_verify`).
+- `inc/mail-diagnostic.php` — permanent admin tool under Tools → Mail Test
+ that exercises `wp_mail` and surfaces duration + PHPMailer error.
+- v1.5.0 user migration in `inc/page-setup.php`: existing subscribers are
+ enrolled into the email verification flow on first admin load after the
+ upgrade (guarded by `ps_verify_migration_version` option).
+- Newsletter consent checkbox + privacy policy link on the signup form.
+- Newsletter unsubscribe flow: every subscription now stores a token;
+ `/newsletter-unsubscribe?token=…` flips the `unsubscribed` flag without
+ requiring a login.
+- Profile page verify-required banner with resend button (AJAX).
+- `peptide_starter_render_honeypot()` and `peptide_starter_csv_safe()`
+ helpers in `inc/helpers.php`.
+- PHPUnit suites: `test-rate-limiter.php`, `test-auth-handlers.php`,
+ `test-email-verification.php`, `test-contact-handler.php`,
+ `test-newsletter.php`, `test-auth-gate.php`.
+
+### Changed
+
+- `functions.php` loads the five new modules in dependency order.
+- Newsletter handler stores entries with `autoload=false` so a large
+ subscriber list no longer bloats every page load. Admin notice surfaces
+ once subscribers exceed 1000 (threshold is configurable).
+- Newsletter handler collapses `success` and `duplicate` response states
+ into a single `ok` state — no longer leaks subscription status.
+- Contact handler rejects sender names containing `\r`, `\n`, `,`, `<`,
+ `>` up front (header-injection defence) and tags mail failures with a
+ request ID so admins can correlate without storing message content.
+- Fallback primary-menu function moved from `header.php` into
+ `inc/helpers.php` to eliminate redeclaration risk.
+- Footer placeholder language links (`href="#"`) replaced with a
+ "Translations coming soon" note. Will render localized links once
+ per-language blogs exist.
+- Footer copyright now renders through `peptide_starter_get_footer_copyright()`
+ so the customizer control is live (was dead before).
+- Settings panel now implements a proper Tab / Shift+Tab focus trap and
+ saves + restores `document.body.style.overflow` so nested overlays
+ don't corrupt scroll state.
+- Documentation heading IDs are slugified from heading text with a
+ collision counter so deep links survive reorders.
+- `assets/js/auth.js`, `settings-panel.js`, `documentation.js`,
+ `theme.js`, `navigation.js` standardized on `const` / `let`.
+
+### Removed
+
+- Auto-login on registration (see PSEC-003).
+- Placeholder-link spam in the footer (see Changed).
+
+### Documentation
+
+- `ARCHITECTURE.md` updated with the new `inc/` module list, authentication
+ data flow, security abuse-control overview, and verification route.
+- `CONVENTIONS.md` documents the rate-limiter usage pattern, the honeypot
+ pattern, the `require_login` gate, and CSV-safe export.
+- ADR-0001 (Frontend authentication and abuse-control strategy) governs
+ the trade-offs behind this release.
+
+### Known follow-ups (out of scope; tracked as future ADRs)
+
+- ADR-0003 — consolidate the six duplicated tool templates.
+- ADR-0004 — migrate newsletter storage from `wp_options` to a dedicated
+ table once subscriber count exceeds ~1000.
+
+---
+
+## [1.5.0] - 2026-04-14
+
+Theme extension release: navigation dropdowns, 9 page templates, branded
+frontend auth UI, newsletter signup, contact/support panel, footer update,
+and Research Modules cards on the front page. Superseded by v1.5.1 on the
+same day — v1.5.0 shipped with the auth-surface issues captured in
+ADR-0001.
+
+---
+
## [1.3.2] - 2026-04-12
### Security
diff --git a/CONVENTIONS.md b/CONVENTIONS.md
index 76200bd..80d140e 100644
--- a/CONVENTIONS.md
+++ b/CONVENTIONS.md
@@ -592,6 +592,84 @@ echo get_theme_mod( 'hero_title' );
echo get_theme_mod( 'dark_mode_default', false );
```
+## Security Patterns (v1.5.1)
+
+### Rate-limit a handler
+
+Every new public POST handler must be rate-limited.
+
+```php
+// After nonce + honeypot checks.
+$identifier = peptide_starter_hash_identifier( strtolower( $email ) ); // or 'ip' for IP-only bucket
+if ( ! Peptide_Starter_Rate_Limiter::check( 'login', $identifier ) ) {
+ // Respond with the SAME message the real-validation-failure path uses.
+ wp_send_json_error( array( 'message' => peptide_starter_login_failure_message() ) );
+}
+Peptide_Starter_Rate_Limiter::record( 'login', $identifier ); // on failure
+// ...successful operation...
+Peptide_Starter_Rate_Limiter::reset( 'login', $identifier ); // on success
+```
+
+Pick a short action slug. Register its limit in `inc/config.php` under
+`rate_limits`. Never hardcode numbers in the handler.
+
+### Honeypot a form
+
+1. Render the trap in the form template part:
+
+```php
+
+```
+
+2. In the handler, right after nonce verification:
+
+```php
+if ( peptide_starter_honeypot_triggered( 'ps_hp_myform' ) ) {
+ // Fake-success response — do NOT return an error. Bots learn from errors.
+ wp_send_json_success( array( 'message' => __( 'OK', 'peptide-starter' ) ) );
+}
+```
+
+### Gate a template
+
+At the very top of the template, after the `ABSPATH` guard, before
+`get_header()`:
+
+```php
+peptide_starter_require_login();
+```
+
+This redirects unauthenticated users to `/auth?redirect_to=current-uri`
+and unverified users to `/profile?verify_required=1`. Do not reinvent
+this per-template.
+
+### CSV-safe export
+
+Route every cell through the helper before `fputcsv`:
+
+```php
+fputcsv( $output, array(
+ peptide_starter_csv_safe( $email ),
+ peptide_starter_csv_safe( $date ),
+) );
+```
+
+### Error unification
+
+Login + registration failures must emit the same message regardless of
+which validation check failed. Use:
+
+- `peptide_starter_login_failure_message()` — "Invalid email or password."
+- `peptide_starter_register_failure_message()` — "Unable to create account..."
+
+Branching on failure reason is an enumeration oracle. Don't do it.
+
+### Adding a new security threshold
+
+Add a key to the defaults array in `inc/config.php` and always read it
+via `peptide_starter_config_get()` or the shortcut helpers. No magic
+numbers in handler files.
+
## Related Documentation
- **ARCHITECTURE.md** — System design, data flows, integrations
diff --git a/assets/js/auth.js b/assets/js/auth.js
index b393e93..006730c 100644
--- a/assets/js/auth.js
+++ b/assets/js/auth.js
@@ -1,36 +1,37 @@
/**
- * Authentication Page — Form Toggle & Validation
+ * Authentication Page — Form Toggle & AJAX
* Peptide Starter Theme
*
* Handles:
* - Toggle between sign-in and register forms
- * - Client-side form validation
* - Password strength indicator
- * - AJAX form submission to avoid full page reload
+ * - AJAX form submission (no redirect on register — success message only)
+ * - XSS-safe message rendering
*
- * @see page-auth.php — renders the auth forms
- * @see functions.php — AJAX handlers (ps_auth_login, ps_auth_register)
+ * @see page-auth.php — renders the forms
+ * @see inc/auth-handlers.php — ps_auth_login / ps_auth_register
*
- * Conditionally enqueued only on pages using the Sign In / Register template.
+ * Conditionally enqueued on pages using the Sign In / Register template.
*/
-(function() {
+(function () {
'use strict';
- var loginWrap = document.getElementById('ps-auth-login');
- var registerWrap = document.getElementById('ps-auth-register');
- var showRegister = document.getElementById('ps-show-register');
- var showLogin = document.getElementById('ps-show-login');
- var messages = document.getElementById('ps-auth-messages');
- var loginForm = document.getElementById('ps-login-form');
- var registerForm = document.getElementById('ps-register-form');
+ const loginWrap = document.getElementById('ps-auth-login');
+ const registerWrap = document.getElementById('ps-auth-register');
+ const showRegister = document.getElementById('ps-show-register');
+ const showLogin = document.getElementById('ps-show-login');
+ const messages = document.getElementById('ps-auth-messages');
+ const loginForm = document.getElementById('ps-login-form');
+ const registerForm = document.getElementById('ps-register-form');
if (!loginWrap || !registerWrap) {
return;
}
/**
- * Toggle between login and register views.
+ * Swap which form is visible. Uses a class, not inline style, so the
+ * stylesheet is the single source of truth.
* @param {string} view - 'login' or 'register'.
*/
function showView(view) {
@@ -45,121 +46,128 @@
}
if (showRegister) {
- showRegister.addEventListener('click', function(e) {
+ showRegister.addEventListener('click', function (e) {
e.preventDefault();
showView('register');
});
}
if (showLogin) {
- showLogin.addEventListener('click', function(e) {
+ showLogin.addEventListener('click', function (e) {
e.preventDefault();
showView('login');
});
}
- // Check URL hash on load for deep linking.
if (window.location.hash === '#register') {
showView('register');
}
/**
- * Display a message in the auth messages area.
- * @param {string} text - Message text.
- * @param {string} type - 'success', 'error', or 'info'.
+ * Render a message in the messages region. Text is escaped.
+ * @param {string} text
+ * @param {string} type - 'success' | 'error' | 'info'.
*/
function showMessage(text, type) {
- if (!messages) {
- return;
- }
- var alertClass = 'ps-alert ps-alert-' + (type || 'info');
- messages.innerHTML = '
';
+ if (!messages) return;
+ const wrap = document.createElement('div');
+ wrap.className = 'ps-alert ps-alert-' + (type || 'info');
+ const p = document.createElement('p');
+ p.textContent = text;
+ wrap.appendChild(p);
+ messages.innerHTML = '';
+ messages.appendChild(wrap);
}
function clearMessages() {
- if (messages) {
- messages.innerHTML = '';
- }
+ if (messages) { messages.innerHTML = ''; }
}
- /**
- * Escape HTML entities to prevent XSS in message display.
- * @param {string} str - Raw string.
- * @returns {string} Escaped string.
- */
- function escapeHtml(str) {
- var div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
- }
-
- /**
- * Password strength indicator.
- * Updates the #ps-password-strength element with a rating.
- */
- var passwordInput = document.getElementById('ps-reg-password');
- var strengthEl = document.getElementById('ps-password-strength');
+ // Password strength indicator (registration form).
+ const passwordInput = document.getElementById('ps-reg-password');
+ const strengthEl = document.getElementById('ps-password-strength');
if (passwordInput && strengthEl) {
- passwordInput.addEventListener('input', function() {
- var val = passwordInput.value;
- var score = 0;
- var label = '';
+ passwordInput.addEventListener('input', function () {
+ const val = passwordInput.value;
+ let score = 0;
- if (val.length >= 8) { score++; }
- if (val.length >= 12) { score++; }
- if (/[a-z]/.test(val) && /[A-Z]/.test(val)) { score++; }
- if (/\d/.test(val)) { score++; }
- if (/[^a-zA-Z0-9]/.test(val)) { score++; }
+ if (val.length >= 8) { score++; }
+ if (val.length >= 12) { score++; }
+ if (/[a-z]/.test(val) && /[A-Z]/.test(val)) { score++; }
+ if (/\d/.test(val)) { score++; }
+ if (/[^a-zA-Z0-9]/.test(val)) { score++; }
- if (val.length === 0) {
- label = '';
- } else if (score <= 1) {
- label = 'Weak';
- } else if (score <= 3) {
- label = 'Fair';
- } else {
- label = 'Strong';
+ let className = '';
+ let label = '';
+ if (val.length === 0) { className = ''; }
+ else if (score <= 1) { className = 'ps-strength-weak'; label = 'Weak'; }
+ else if (score <= 3) { className = 'ps-strength-fair'; label = 'Fair'; }
+ else { className = 'ps-strength-strong'; label = 'Strong'; }
+
+ strengthEl.innerHTML = '';
+ if (className) {
+ const span = document.createElement('span');
+ span.className = className;
+ span.textContent = label;
+ strengthEl.appendChild(span);
}
-
- strengthEl.innerHTML = label;
});
}
/**
- * AJAX form submission handler.
- * @param {HTMLFormElement} form - The form to submit.
+ * Submit a form via AJAX to admin-ajax. Login success redirects; register
+ * success shows an inbox-check message (no redirect — email verification
+ * is the next step).
+ * @param {HTMLFormElement} form
+ * @param {string} kind - 'login' or 'register'.
*/
- function submitForm(form) {
- var formData = new FormData(form);
- var btn = form.querySelector('button[type="submit"]');
- var origText = btn.textContent;
+ function submitForm(form, kind) {
+ const formData = new FormData(form);
+ const btn = form.querySelector('button[type="submit"]');
+ const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
clearMessages();
- var xhr = new XMLHttpRequest();
- xhr.open('POST', (typeof peptideStarterData !== 'undefined' ? peptideStarterData.ajaxUrl : '/wp-admin/admin-ajax.php'));
+ const xhr = new XMLHttpRequest();
+ const url = (typeof window.peptideStarterData !== 'undefined' && window.peptideStarterData.ajaxUrl)
+ ? window.peptideStarterData.ajaxUrl
+ : '/wp-admin/admin-ajax.php';
+ xhr.open('POST', url);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
- xhr.onload = function() {
+ xhr.onload = function () {
btn.disabled = false;
btn.textContent = origText;
- try {
- var resp = JSON.parse(xhr.responseText);
- if (resp.success && resp.data && resp.data.redirect) {
- window.location.href = resp.data.redirect;
- } else {
- showMessage(resp.data && resp.data.message ? resp.data.message : 'An error occurred.', 'error');
- }
- } catch (e) {
+ let resp;
+ try { resp = JSON.parse(xhr.responseText); } catch (e) {
showMessage('An unexpected error occurred. Please try again.', 'error');
+ return;
}
+
+ if (resp && resp.success) {
+ if (kind === 'login' && resp.data && resp.data.redirect) {
+ window.location.href = resp.data.redirect;
+ return;
+ }
+ if (kind === 'register' && resp.data && resp.data.message) {
+ showMessage(resp.data.message, 'success');
+ form.reset();
+ if (strengthEl) { strengthEl.innerHTML = ''; }
+ return;
+ }
+ showMessage('Request completed.', 'success');
+ return;
+ }
+
+ const msg = (resp && resp.data && resp.data.message) ? resp.data.message : 'An error occurred.';
+ showMessage(msg, 'error');
};
- xhr.onerror = function() {
+ xhr.onerror = function () {
btn.disabled = false;
btn.textContent = origText;
showMessage('Network error. Please check your connection.', 'error');
@@ -169,24 +177,29 @@
}
if (loginForm) {
- loginForm.addEventListener('submit', function(e) {
+ loginForm.addEventListener('submit', function (e) {
e.preventDefault();
if (!loginForm.checkValidity()) {
loginForm.reportValidity();
return;
}
- submitForm(loginForm);
+ submitForm(loginForm, 'login');
});
}
if (registerForm) {
- registerForm.addEventListener('submit', function(e) {
+ registerForm.addEventListener('submit', function (e) {
e.preventDefault();
if (!registerForm.checkValidity()) {
registerForm.reportValidity();
return;
}
- submitForm(registerForm);
+ submitForm(registerForm, 'register');
});
}
+
+ // Surface /auth?verify_error=1.
+ if (messages && /[?&]verify_error=1/.test(window.location.search)) {
+ showMessage('That verification link is invalid or has expired. Sign in and request a new one.', 'error');
+ }
})();
diff --git a/assets/js/documentation.js b/assets/js/documentation.js
index d866fd1..0e7a037 100644
--- a/assets/js/documentation.js
+++ b/assets/js/documentation.js
@@ -3,23 +3,22 @@
* Peptide Starter Theme
*
* Handles:
- * - Auto-generates ToC from h2 headings in the documentation body
- * - Scroll spy highlights the active section in the sidebar
- * - Smooth scrolling on ToC link click
+ * - Auto-generates ToC from h2 headings
+ * - Slugifies heading IDs from the heading text (with collision counter)
+ * so deep links survive edits that change the author's order
+ * - IntersectionObserver scroll spy highlights the active section
+ * - Smooth scroll with header offset on ToC click
* - Collapses sidebar to dropdown on mobile
*
* @see page-documentation.php — renders the two-column layout
- * @see style.css — .ps-docs-* classes
- *
- * Conditionally enqueued only on pages using the Documentation template.
*/
-(function() {
+(function () {
'use strict';
- const docsBody = document.getElementById('ps-docs-body');
- const tocList = document.getElementById('ps-docs-toc-list');
- const sidebar = document.querySelector('.ps-docs-sidebar');
+ const docsBody = document.getElementById('ps-docs-body');
+ const tocList = document.getElementById('ps-docs-toc-list');
+ const sidebar = document.querySelector('.ps-docs-sidebar');
if (!docsBody || !tocList) {
return;
@@ -28,32 +27,51 @@
const headings = docsBody.querySelectorAll('h2');
if (headings.length === 0) {
- // Hide the sidebar if there are no headings to navigate.
- if (sidebar) {
- sidebar.style.display = 'none';
- }
+ if (sidebar) { sidebar.style.display = 'none'; }
return;
}
/**
- * Build the Table of Contents from h2 headings.
- * Assigns IDs to headings that lack one for anchor linking.
+ * Deterministic slug derived from heading text. Collisions resolved
+ * with a numeric suffix so every h2 gets a unique ID even when authors
+ * repeat titles ("Examples", "Examples", ...).
+ * @param {string} text
+ * @returns {string}
*/
+ function slugify(text) {
+ return String(text)
+ .toLowerCase()
+ .trim()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '') || 'section';
+ }
+
function buildToc() {
- headings.forEach(function(heading, index) {
- // Ensure every heading has an ID for anchor links.
+ const used = Object.create(null);
+
+ headings.forEach(function (heading) {
if (!heading.id) {
- heading.id = 'section-' + (index + 1);
+ let base = slugify(heading.textContent || '');
+ let slug = base;
+ let n = 2;
+ while (document.getElementById(slug) || used[slug]) {
+ slug = base + '-' + n;
+ n++;
+ }
+ heading.id = slug;
+ used[slug] = true;
}
- var li = document.createElement('li');
- var link = document.createElement('a');
+ const li = document.createElement('li');
+ const link = document.createElement('a');
link.href = '#' + heading.id;
link.textContent = heading.textContent;
link.className = 'ps-docs-toc-link';
- link.addEventListener('click', function(e) {
+ link.addEventListener('click', function (e) {
e.preventDefault();
smoothScrollTo(heading);
});
@@ -63,82 +81,53 @@
});
}
- /**
- * Smooth scroll to a target element, accounting for the sticky header.
- * @param {HTMLElement} target The element to scroll to.
- */
function smoothScrollTo(target) {
- var headerHeight = document.querySelector('.site-header')
- ? document.querySelector('.site-header').offsetHeight
- : 0;
- var offset = 24; // Extra breathing room below header.
- var top = target.getBoundingClientRect().top + window.pageYOffset - headerHeight - offset;
+ const headerEl = document.querySelector('.site-header');
+ const headerHeight = headerEl ? headerEl.offsetHeight : 0;
+ const offset = 24;
+ const top = target.getBoundingClientRect().top + window.pageYOffset - headerHeight - offset;
- window.scrollTo({
- top: top,
- behavior: 'smooth'
- });
-
- // Update URL hash without jumping.
+ window.scrollTo({ top: top, behavior: 'smooth' });
history.replaceState(null, '', '#' + target.id);
}
- /**
- * Scroll spy — highlights the ToC link corresponding to the
- * currently visible section. Uses IntersectionObserver for performance.
- */
function initScrollSpy() {
- var tocLinks = tocList.querySelectorAll('.ps-docs-toc-link');
+ const tocLinks = tocList.querySelectorAll('.ps-docs-toc-link');
+ if (tocLinks.length === 0) return;
- if (tocLinks.length === 0) {
- return;
- }
+ const headerEl = document.querySelector('.site-header');
+ const headerHeight = headerEl ? headerEl.offsetHeight : 0;
- var headerHeight = document.querySelector('.site-header')
- ? document.querySelector('.site-header').offsetHeight
- : 0;
-
- var observer = new IntersectionObserver(function(entries) {
- entries.forEach(function(entry) {
- if (entry.isIntersecting) {
- var id = entry.target.id;
- tocLinks.forEach(function(link) {
- link.classList.remove('ps-docs-toc-link--active');
- if (link.getAttribute('href') === '#' + id) {
- link.classList.add('ps-docs-toc-link--active');
- }
- });
- }
+ const observer = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (!entry.isIntersecting) return;
+ const id = entry.target.id;
+ tocLinks.forEach(function (link) {
+ link.classList.remove('ps-docs-toc-link--active');
+ if (link.getAttribute('href') === '#' + id) {
+ link.classList.add('ps-docs-toc-link--active');
+ }
+ });
});
}, {
rootMargin: '-' + (headerHeight + 32) + 'px 0px -60% 0px',
threshold: 0
});
- headings.forEach(function(heading) {
- observer.observe(heading);
- });
+ headings.forEach(function (heading) { observer.observe(heading); });
}
- /**
- * Mobile dropdown toggle for the sidebar ToC.
- */
function initMobileToggle() {
- var tocTitle = document.querySelector('.ps-docs-toc-title');
+ const tocTitle = document.querySelector('.ps-docs-toc-title');
+ if (!tocTitle || !sidebar) return;
- if (!tocTitle) {
- return;
- }
-
- tocTitle.addEventListener('click', function() {
- // Only toggle on mobile (sidebar is not sticky).
+ tocTitle.addEventListener('click', function () {
if (window.innerWidth < 1024) {
sidebar.classList.toggle('ps-docs-sidebar--open');
}
});
}
- // Initialize.
buildToc();
initScrollSpy();
initMobileToggle();
diff --git a/assets/js/navigation.js b/assets/js/navigation.js
index 20b9392..43572fb 100644
--- a/assets/js/navigation.js
+++ b/assets/js/navigation.js
@@ -1,84 +1,66 @@
-/**
- * Navigation and Mobile Menu Toggle
- * Peptide Starter Theme
- *
- * Handles:
- * - Mobile hamburger menu toggle
- * - Navigation overlay click handling
- * - Menu state persistence
- */
-
-(function() {
- 'use strict';
-
- const menuToggle = document.querySelector('.menu-toggle');
- const primaryNav = document.querySelector('.primary-navigation');
- const navOverlay = document.querySelector('.nav-overlay');
-
- if (!menuToggle || !primaryNav || !navOverlay) {
- return;
- }
-
- /**
- * Toggle mobile menu visibility
- */
- function toggleMenu() {
- const isActive = primaryNav.classList.contains('active');
-
- if (isActive) {
- closeMenu();
- } else {
- openMenu();
- }
- }
-
- /**
- * Open mobile menu
- */
- function openMenu() {
- primaryNav.classList.add('active');
- navOverlay.classList.add('active');
- document.body.style.overflow = 'hidden';
- menuToggle.setAttribute('aria-expanded', 'true');
- }
-
- /**
- * Close mobile menu
- */
- function closeMenu() {
- primaryNav.classList.remove('active');
- navOverlay.classList.remove('active');
- document.body.style.overflow = '';
- menuToggle.setAttribute('aria-expanded', 'false');
- }
-
- // Menu toggle button click
- menuToggle.addEventListener('click', toggleMenu);
-
- // Overlay click
- navOverlay.addEventListener('click', closeMenu);
-
- // Close menu when a nav link is clicked
- const navLinks = primaryNav.querySelectorAll('a');
- navLinks.forEach(link => {
- link.addEventListener('click', closeMenu);
- });
-
- // Close menu on Escape key
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') {
- closeMenu();
- }
- });
-
- // Set initial aria-expanded state
- menuToggle.setAttribute('aria-expanded', 'false');
-
- // Close menu when window is resized above mobile breakpoint
- const mediaQuery = window.matchMedia('(min-width: 768px)');
- mediaQuery.addEventListener('change', (e) => {
- if (e.matches) {
- closeMenu();
- }
- });
-})();
+/**
+ * Navigation and Mobile Menu Toggle
+ * Peptide Starter Theme
+ *
+ * Handles:
+ * - Mobile hamburger menu toggle
+ * - Navigation overlay click handling
+ * - Menu state persistence
+ */
+
+(function () {
+ 'use strict';
+
+ const menuToggle = document.querySelector('.menu-toggle');
+ const primaryNav = document.querySelector('.primary-navigation');
+ const navOverlay = document.querySelector('.nav-overlay');
+
+ if (!menuToggle || !primaryNav || !navOverlay) {
+ return;
+ }
+
+ let previousOverflow = '';
+
+ function openMenu() {
+ primaryNav.classList.add('active');
+ navOverlay.classList.add('active');
+ previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ menuToggle.setAttribute('aria-expanded', 'true');
+ }
+
+ function closeMenu() {
+ primaryNav.classList.remove('active');
+ navOverlay.classList.remove('active');
+ document.body.style.overflow = previousOverflow;
+ previousOverflow = '';
+ menuToggle.setAttribute('aria-expanded', 'false');
+ }
+
+ function toggleMenu() {
+ if (primaryNav.classList.contains('active')) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ }
+
+ menuToggle.addEventListener('click', toggleMenu);
+ navOverlay.addEventListener('click', closeMenu);
+
+ const navLinks = primaryNav.querySelectorAll('a');
+ navLinks.forEach(function (link) {
+ link.addEventListener('click', closeMenu);
+ });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') { closeMenu(); }
+ });
+
+ menuToggle.setAttribute('aria-expanded', 'false');
+
+ const mediaQuery = window.matchMedia('(min-width: 768px)');
+ mediaQuery.addEventListener('change', function (e) {
+ if (e.matches) { closeMenu(); }
+ });
+})();
diff --git a/assets/js/settings-panel.js b/assets/js/settings-panel.js
index a797d7d..370bccd 100644
--- a/assets/js/settings-panel.js
+++ b/assets/js/settings-panel.js
@@ -1,163 +1,164 @@
/**
- * Settings Panel — Open/Close & Contact Form
+ * Settings Panel — Open/Close, Focus Trap, Contact Form AJAX
* Peptide Starter Theme
*
* Handles:
- * - Toggle settings slide-out panel visibility
- * - AJAX submission of the contact form
- * - Overlay backdrop click to close
- * - Escape key to close
- * - Focus trap inside the panel for accessibility
+ * - Open / close the slide-out panel (button, overlay click, Escape)
+ * - Save and restore document.body.style.overflow so other panels
+ * opening the overlay don't stomp on the caller's original value
+ * - Focus trap: Tab / Shift+Tab wrap within the panel while it's open
+ * - AJAX submission of the contact form with XSS-safe status rendering
*
* @see template-parts/settings-panel.php — panel HTML
- * @see header.php — settings icon trigger button
- * @see functions.php — AJAX handler for contact form
- *
- * Enqueued globally (lightweight — all listeners are passive until panel opens).
+ * @see header.php — trigger button
+ * @see inc/contact-handler.php — AJAX endpoint
*/
-(function() {
+(function () {
'use strict';
- var panel = document.getElementById('ps-settings-panel');
- var overlay = document.getElementById('ps-settings-overlay');
- var closeBtn = document.getElementById('ps-settings-close');
- var openBtn = document.getElementById('ps-settings-toggle');
- var contactForm = document.getElementById('ps-contact-form');
- var statusEl = document.getElementById('ps-contact-status');
+ const panel = document.getElementById('ps-settings-panel');
+ const overlay = document.getElementById('ps-settings-overlay');
+ const closeBtn = document.getElementById('ps-settings-close');
+ const openBtn = document.getElementById('ps-settings-toggle');
+ const contactForm = document.getElementById('ps-contact-form');
+ const statusEl = document.getElementById('ps-contact-status');
if (!panel || !overlay) {
return;
}
+ const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+ let previousOverflow = '';
+ let firstFocusable = null;
+ let lastFocusable = null;
+
/**
- * Open the settings panel.
+ * Snapshot and refresh the focusable boundary elements inside the panel.
+ * Called on open so dynamically-rendered fields are included.
*/
+ function refreshFocusables() {
+ const list = panel.querySelectorAll(FOCUSABLE);
+ if (!list.length) {
+ firstFocusable = null;
+ lastFocusable = null;
+ return;
+ }
+ firstFocusable = list[0];
+ lastFocusable = list[list.length - 1];
+ }
+
function openPanel() {
panel.classList.add('ps-settings-panel--open');
panel.setAttribute('aria-hidden', 'false');
overlay.classList.add('ps-settings-overlay--active');
overlay.setAttribute('aria-hidden', 'false');
+ previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
- // Focus the close button for keyboard users.
+ refreshFocusables();
if (closeBtn) {
- setTimeout(function() { closeBtn.focus(); }, 100);
+ setTimeout(function () { closeBtn.focus(); }, 100);
}
}
- /**
- * Close the settings panel.
- */
function closePanel() {
panel.classList.remove('ps-settings-panel--open');
panel.setAttribute('aria-hidden', 'true');
overlay.classList.remove('ps-settings-overlay--active');
overlay.setAttribute('aria-hidden', 'true');
- document.body.style.overflow = '';
+ document.body.style.overflow = previousOverflow;
+ previousOverflow = '';
- // Return focus to the trigger button.
if (openBtn) {
openBtn.focus();
}
}
- // Bind open trigger.
- if (openBtn) {
- openBtn.addEventListener('click', openPanel);
+ function trapFocus(e) {
+ if (!panel.classList.contains('ps-settings-panel--open')) return;
+ if (e.key !== 'Tab') return;
+ if (!firstFocusable || !lastFocusable) return;
+
+ if (e.shiftKey && document.activeElement === firstFocusable) {
+ e.preventDefault();
+ lastFocusable.focus();
+ } else if (!e.shiftKey && document.activeElement === lastFocusable) {
+ e.preventDefault();
+ firstFocusable.focus();
+ }
}
- // Bind close button.
- if (closeBtn) {
- closeBtn.addEventListener('click', closePanel);
- }
-
- // Bind overlay backdrop click.
+ if (openBtn) { openBtn.addEventListener('click', openPanel); }
+ if (closeBtn) { closeBtn.addEventListener('click', closePanel); }
overlay.addEventListener('click', closePanel);
- // Close on Escape key.
- document.addEventListener('keydown', function(e) {
+ document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && panel.classList.contains('ps-settings-panel--open')) {
closePanel();
}
});
+ document.addEventListener('keydown', trapFocus);
- /**
- * Contact form AJAX submission.
- */
+ // Contact form AJAX.
if (contactForm) {
- contactForm.addEventListener('submit', function(e) {
+ contactForm.addEventListener('submit', function (e) {
e.preventDefault();
-
if (!contactForm.checkValidity()) {
contactForm.reportValidity();
return;
}
- var formData = new FormData(contactForm);
- var btn = contactForm.querySelector('button[type="submit"]');
- var origText = btn.textContent;
+ const formData = new FormData(contactForm);
+ const btn = contactForm.querySelector('button[type="submit"]');
+ const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
+ if (statusEl) { statusEl.innerHTML = ''; }
- if (statusEl) {
- statusEl.innerHTML = '';
- }
-
- var xhr = new XMLHttpRequest();
- var ajaxUrl = (typeof peptideStarterData !== 'undefined' && peptideStarterData.ajaxUrl)
- ? peptideStarterData.ajaxUrl
+ const xhr = new XMLHttpRequest();
+ const url = (typeof window.peptideStarterData !== 'undefined' && window.peptideStarterData.ajaxUrl)
+ ? window.peptideStarterData.ajaxUrl
: '/wp-admin/admin-ajax.php';
-
- xhr.open('POST', ajaxUrl);
+ xhr.open('POST', url);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
- xhr.onload = function() {
+ xhr.onload = function () {
btn.disabled = false;
btn.textContent = origText;
- try {
- var resp = JSON.parse(xhr.responseText);
- if (resp.success) {
- if (statusEl) {
- statusEl.innerHTML = '' +
- escapeHtml(resp.data.message || 'Message sent.') + '
';
- }
- contactForm.reset();
- } else {
- if (statusEl) {
- statusEl.innerHTML = '' +
- escapeHtml(resp.data.message || 'Failed to send.') + '
';
- }
- }
- } catch (ex) {
- if (statusEl) {
- statusEl.innerHTML = 'An unexpected error occurred.
';
- }
+ let resp;
+ try { resp = JSON.parse(xhr.responseText); } catch (ex) {
+ renderStatus('An unexpected error occurred.', 'error');
+ return;
+ }
+ if (resp && resp.success) {
+ renderStatus((resp.data && resp.data.message) || 'Message sent.', 'success');
+ contactForm.reset();
+ } else {
+ renderStatus((resp && resp.data && resp.data.message) || 'Failed to send.', 'error');
}
};
- xhr.onerror = function() {
+ xhr.onerror = function () {
btn.disabled = false;
btn.textContent = origText;
- if (statusEl) {
- statusEl.innerHTML = 'Network error. Please try again.
';
- }
+ renderStatus('Network error. Please try again.', 'error');
};
xhr.send(formData);
});
}
- /**
- * Escape HTML for safe insertion.
- * @param {string} str - Raw text.
- * @returns {string} Escaped HTML.
- */
- function escapeHtml(str) {
- var div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
+ function renderStatus(text, type) {
+ if (!statusEl) return;
+ const wrap = document.createElement('div');
+ wrap.className = 'ps-alert ps-alert-' + (type || 'info');
+ const p = document.createElement('p');
+ p.textContent = text;
+ wrap.appendChild(p);
+ statusEl.innerHTML = '';
+ statusEl.appendChild(wrap);
}
})();
diff --git a/footer.php b/footer.php
index fe439a5..2ff1809 100644
--- a/footer.php
+++ b/footer.php
@@ -62,15 +62,7 @@
diff --git a/functions.php b/functions.php
index 125baa3..e72fab0 100644
--- a/functions.php
+++ b/functions.php
@@ -2,13 +2,19 @@
/**
* Peptide Starter Theme Functions
*
- * Core theme setup: supports, menus, scripts, customizer, helpers.
- * Feature-specific logic is split into inc/ files to keep this under 300 lines.
+ * Core theme setup: supports, menus, scripts, customizer, security modules.
+ * Feature-specific logic is split across inc/ files to keep this under
+ * 300 lines and to make responsibilities discoverable.
*
- * @see inc/auth-handlers.php — AJAX login/register handlers
- * @see inc/contact-handler.php — AJAX contact form handler
- * @see inc/page-setup.php — auto-create pages on theme activation
- * @see inc/newsletter-admin.php — admin page for subscriber export
+ * @see inc/config.php — security configuration
+ * @see inc/rate-limiter.php — transient-backed rate limiter
+ * @see inc/email-verification.php — token-gated email verification
+ * @see inc/auth-handlers.php — AJAX login + registration
+ * @see inc/contact-handler.php — AJAX contact form
+ * @see inc/newsletter-admin.php — admin subscriber viewer + unsubscribe
+ * @see inc/page-setup.php — auto-create pages + v1.5.0 user migration
+ * @see inc/mail-diagnostic.php — admin deliverability test tool
+ * @see inc/helpers.php — nav walker, menu fallback, gates, utilities
*
* @package peptide-starter
*/
@@ -18,21 +24,26 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-// Define constants.
-define( 'PEPTIDE_STARTER_VERSION', '1.5.0' );
+// Theme constants.
+define( 'PEPTIDE_STARTER_VERSION', '1.5.1' );
define( 'PEPTIDE_STARTER_DIR', get_template_directory() );
define( 'PEPTIDE_STARTER_URI', get_template_directory_uri() );
-// Load feature modules.
+// Load feature modules. Order matters — config must load before anything
+// that calls peptide_starter_config_*; rate limiter before handlers.
+require_once PEPTIDE_STARTER_DIR . '/inc/config.php';
require_once PEPTIDE_STARTER_DIR . '/inc/helpers.php';
+require_once PEPTIDE_STARTER_DIR . '/inc/rate-limiter.php';
+require_once PEPTIDE_STARTER_DIR . '/inc/email-verification.php';
require_once PEPTIDE_STARTER_DIR . '/inc/customizer.php';
require_once PEPTIDE_STARTER_DIR . '/inc/auth-handlers.php';
require_once PEPTIDE_STARTER_DIR . '/inc/contact-handler.php';
require_once PEPTIDE_STARTER_DIR . '/inc/page-setup.php';
require_once PEPTIDE_STARTER_DIR . '/inc/newsletter-admin.php';
+require_once PEPTIDE_STARTER_DIR . '/inc/mail-diagnostic.php';
/**
- * Set up theme defaults and register support for various WordPress features.
+ * Set up theme defaults and register support for WordPress features.
*
* @return void
*/
@@ -80,7 +91,6 @@ function peptide_starter_scripts() {
)
);
- // Conditional scripts for specific page templates.
if ( is_page_template( 'page-documentation.php' ) ) {
wp_enqueue_script( 'peptide-starter-docs', PEPTIDE_STARTER_URI . '/assets/js/documentation.js', array(), PEPTIDE_STARTER_VERSION, true );
}
@@ -118,7 +128,7 @@ function peptide_starter_plugin_overrides() {
add_action( 'wp_enqueue_scripts', 'peptide_starter_plugin_overrides', 99 );
/**
- * Register widget areas.
+ * Register footer widget areas.
*
* @return void
*/
@@ -142,10 +152,16 @@ function peptide_starter_widgets_init() {
add_action( 'widgets_init', 'peptide_starter_widgets_init' );
/**
- * Handle newsletter signup form submission.
- * Stores email in wp_options with duplicate check.
+ * Handle newsletter signup.
*
- * @return void Redirects back with status parameter.
+ * Applies: nonce, honeypot, rate limit, consent, email validity. Stores
+ * entries with an unsubscribe token; collapses success + duplicate into
+ * a single "ok" state server-side so the response doesn't leak whether
+ * the address was already subscribed.
+ *
+ * Side effects: writes a wp_options array with autoload=false.
+ *
+ * @return void
*/
function peptide_starter_handle_newsletter_signup() {
if ( ! isset( $_POST['ps_newsletter_nonce'] ) ||
@@ -153,31 +169,48 @@ function peptide_starter_handle_newsletter_signup() {
wp_die( esc_html__( 'Security check failed.', 'peptide-starter' ), esc_html__( 'Error', 'peptide-starter' ), array( 'response' => 403 ) );
}
- $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
-
- if ( ! is_email( $email ) ) {
- wp_safe_redirect( add_query_arg( 'ps_newsletter', 'invalid', wp_get_referer() ) );
+ // Honeypot: fake-ok redirect.
+ if ( peptide_starter_honeypot_triggered( 'ps_hp_newsletter' ) ) {
+ wp_safe_redirect( add_query_arg( 'ps_newsletter', 'ok', peptide_starter_safe_referer() ) );
exit;
}
- // Store in wp_options with duplicate check.
- $emails = get_option( 'ps_newsletter_emails', array() );
+ if ( ! Peptide_Starter_Rate_Limiter::check( 'newsletter', 'ip' ) ) {
+ wp_safe_redirect( add_query_arg( 'ps_newsletter', 'ok', peptide_starter_safe_referer() ) );
+ exit;
+ }
+ Peptide_Starter_Rate_Limiter::record( 'newsletter', 'ip' );
+
+ $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
+ $consent = ! empty( $_POST['ps_consent'] );
+
+ if ( ! is_email( $email ) || ! $consent ) {
+ wp_safe_redirect( add_query_arg( 'ps_newsletter', 'invalid', peptide_starter_safe_referer() ) );
+ exit;
+ }
+
+ $emails = get_option( 'ps_newsletter_emails', array() );
$existing_emails = array_column( $emails, 'email' );
- if ( in_array( $email, $existing_emails, true ) ) {
- wp_safe_redirect( add_query_arg( 'ps_newsletter', 'duplicate', wp_get_referer() ) );
- exit;
+ // Collapse success + duplicate into a single 'ok' state — no enumeration.
+ if ( ! in_array( $email, $existing_emails, true ) ) {
+ $emails[] = array(
+ 'email' => $email,
+ 'date' => current_time( 'Y-m-d H:i:s' ),
+ 'unsub_token' => wp_generate_password( 32, false, false ),
+ );
+ // autoload=false so a large subscriber list doesn't bloat every page load.
+ update_option( 'ps_newsletter_emails', $emails, false );
+
+ /**
+ * Fires when a new subscriber is added to the newsletter list.
+ *
+ * @param string $email Subscriber email.
+ */
+ do_action( 'peptide_starter_newsletter_subscribe', $email );
}
- $emails[] = array(
- 'email' => $email,
- 'date' => current_time( 'Y-m-d H:i:s' ),
- );
- update_option( 'ps_newsletter_emails', $emails );
-
- do_action( 'peptide_starter_newsletter_subscribe', $email );
-
- wp_safe_redirect( add_query_arg( 'ps_newsletter', 'success', wp_get_referer() ) );
+ wp_safe_redirect( add_query_arg( 'ps_newsletter', 'ok', peptide_starter_safe_referer() ) );
exit;
}
add_action( 'admin_post_peptide_starter_newsletter_signup', 'peptide_starter_handle_newsletter_signup' );
diff --git a/header.php b/header.php
index 1d194dd..f234a65 100644
--- a/header.php
+++ b/header.php
@@ -155,36 +155,6 @@
-
-
diff --git a/inc/auth-handlers.php b/inc/auth-handlers.php
index b475514..00d165b 100644
--- a/inc/auth-handlers.php
+++ b/inc/auth-handlers.php
@@ -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;
+}
diff --git a/inc/config.php b/inc/config.php
new file mode 100644
index 0000000..ee0e53b
--- /dev/null
+++ b/inc/config.php
@@ -0,0 +1,122 @@
+ 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,
+ );
+}
diff --git a/inc/contact-handler.php b/inc/contact-handler.php
index a7e3a0a..71040ae 100644
--- a/inc/contact-handler.php
+++ b/inc/contact-handler.php
@@ -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 ) );
+}
diff --git a/inc/email-verification.php b/inc/email-verification.php
new file mode 100644
index 0000000..f962a6b
--- /dev/null
+++ b/inc/email-verification.php
@@ -0,0 +1,240 @@
+ $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 );
diff --git a/inc/helpers.php b/inc/helpers.php
index 5435744..6fdbeaa 100644
--- a/inc/helpers.php
+++ b/inc/helpers.php
@@ -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 .
+ */
+function peptide_starter_primary_menu_fallback() {
+ ?>
+
+ ' . esc_html( $site_name ) . '';
+ return;
}
+ $site_name = get_bloginfo( 'name' );
+ echo '' . esc_html( $site_name ) . '';
}
/**
@@ -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 '