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

Security-only release per ADR-0001. Closes all six ship-blockers from the
post-v1.5.0 audit: PSEC-001 (no rate limiting), PSEC-002 (account
enumeration via separate error messages), PSEC-003 (registration auto-login
with no email verification), PSEC-004 (ungated "My Data" templates),
PSEC-005 (CSV formula injection on subscriber export), PSEC-006 (no
honeypot/CAPTCHA on public forms).

Adds inc/config.php (single source of truth for all security thresholds,
filterable via peptide_starter_security_config), inc/rate-limiter.php
(Peptide_Starter_Rate_Limiter + Cloudflare-aware client IP resolver,
transient-backed, never stores raw IPs), and inc/email-verification.php
(43-char token, 24h TTL, /verify rewrite, rate-limited resend endpoint).

Auth handlers rewritten to: unify all failure messages, rate-limit before
any expensive check, drop registrations with a filled honeypot silently,
stop auto-login on register, send a verification email instead. The
require_login() helper lives in inc/helpers.php and is applied at the top
of page-tracker.php, page-subject-log.php, and page-protocol-builder.php;
page-calculator.php stays open (no PII, no writes).

Newsletter signup gains: consent checkbox, privacy link, honeypot,
autoload=false on the wp_options blob, unsubscribe token stored per row,
/newsletter-unsubscribe route, merged success+duplicate into a single "ok"
state. Admin export runs every cell through peptide_starter_csv_safe()
which prefixes leading =/+/-/@/tab/CR with a quote.

Contact handler hardens against email-header injection (rejects name
values with \r \n , < >), rate-limits, honors honeypot, tags mail
failures with a request ID for correlation without logging message
content.

Adds Tools → Mail Test (inc/mail-diagnostic.php): a permanent wp_mail
probe so we can check Hostinger outbound deliverability any time it looks
flaky. Adds a one-time admin_init migration that enrols pre-v1.5.1
subscribers into the verification flow (currently a no-op — zero users —
but guarded for the future).

Other cleanup from the audit: fallback primary menu moved out of
header.php into inc/helpers.php (redeclaration risk), placeholder footer
language links replaced with "Translations coming soon", footer copyright
now reads through the customizer getter, settings-panel gains a proper
Tab/Shift+Tab focus trap and saves/restores body overflow, documentation
heading IDs slugified from heading text with collision counters, JS files
standardized on const/let.

Six new PHPUnit suites cover rate limiter lifecycle, auth handler error
unification and rate limits, email verification token generation and
expiry, contact handler header injection + rate limit + honeypot,
newsletter signup consent + CSV safety, and require_login gate behavior.

ARCHITECTURE.md and CONVENTIONS.md updated in the same commit — new
module layout, data flows, security patterns for rate-limiting,
honeypotting, gating, and CSV-safe export.

Version bumped to 1.5.1 in style.css and functions.php.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-14 13:01:22 +07:00
parent d28d5f45d5
commit cb8b73f4ab
34 changed files with 2816 additions and 745 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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
<?php peptide_starter_render_honeypot( 'myform' ); ?>
```
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

View File

@@ -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 = '<div class="' + alertClass + '"><p>' + escapeHtml(text) + '</p></div>';
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 = '<span class="ps-strength-weak">Weak</span>';
} else if (score <= 3) {
label = '<span class="ps-strength-fair">Fair</span>';
} else {
label = '<span class="ps-strength-strong">Strong</span>';
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');
}
})();

View File

@@ -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();

View File

@@ -8,7 +8,7 @@
* - Menu state persistence
*/
(function() {
(function () {
'use strict';
const menuToggle = document.querySelector('.menu-toggle');
@@ -19,66 +19,48 @@
return;
}
/**
* Toggle mobile menu visibility
*/
function toggleMenu() {
const isActive = primaryNav.classList.contains('active');
let previousOverflow = '';
if (isActive) {
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();
}
}
/**
* 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 => {
navLinks.forEach(function (link) {
link.addEventListener('click', closeMenu);
});
// Close menu on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeMenu();
}
document.addEventListener('keydown', function (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();
}
mediaQuery.addEventListener('change', function (e) {
if (e.matches) { closeMenu(); }
});
})();

View File

@@ -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 = '<div class="ps-alert ps-alert-success"><p>' +
escapeHtml(resp.data.message || 'Message sent.') + '</p></div>';
}
contactForm.reset();
} else {
if (statusEl) {
statusEl.innerHTML = '<div class="ps-alert ps-alert-error"><p>' +
escapeHtml(resp.data.message || 'Failed to send.') + '</p></div>';
}
}
} catch (ex) {
if (statusEl) {
statusEl.innerHTML = '<div class="ps-alert ps-alert-error"><p>An unexpected error occurred.</p></div>';
}
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 = '<div class="ps-alert ps-alert-error"><p>Network error. Please try again.</p></div>';
}
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);
}
})();

View File

@@ -62,15 +62,7 @@
<!-- Footer Bottom -->
<div class="footer-bottom">
<p class="footer-copyright">
<?php
echo wp_kses_post(
sprintf(
/* translators: %s: current year */
__( '&copy; %s Research Directory | Laboratory Standards', 'peptide-starter' ),
gmdate( 'Y' )
)
);
?>
<?php echo wp_kses_post( peptide_starter_get_footer_copyright() ); ?>
</p>
<!-- Disclaimer -->
@@ -78,14 +70,11 @@
<?php esc_html_e( 'All data is provided for informational and educational purposes only. This platform does not provide medical advice, diagnoses, or treatment recommendations. Always consult qualified professionals.', 'peptide-starter' ); ?>
</p>
<!-- Language Links (placeholder) -->
<div class="footer-languages">
<span class="footer-languages-label"><?php esc_html_e( 'Our Blogs:', 'peptide-starter' ); ?></span>
<a href="#" hreflang="en"><?php esc_html_e( 'English', 'peptide-starter' ); ?></a>
<a href="#" hreflang="el"><?php esc_html_e( 'Greek', 'peptide-starter' ); ?></a>
<a href="#" hreflang="es"><?php esc_html_e( 'Spanish', 'peptide-starter' ); ?></a>
<a href="#" hreflang="nl"><?php esc_html_e( 'Dutch', 'peptide-starter' ); ?></a>
</div>
<!-- Translations note — placeholder links removed in v1.5.1;
renders only when localized blog URLs are wired up. -->
<p class="footer-languages-note">
<?php esc_html_e( 'Translations coming soon.', 'peptide-starter' ); ?>
</p>
</div>
</div>
</footer>

View File

@@ -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' );

View File

@@ -155,36 +155,6 @@
</div>
<?php
/**
* Fallback menu if none is set — provides the new nav structure.
*/
function peptide_starter_primary_menu_fallback() {
?>
<ul>
<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php esc_html_e( 'Home', 'peptide-starter' ); ?></a></li>
<li class="menu-item-has-children">
<a href="#"><?php esc_html_e( 'Tools', 'peptide-starter' ); ?></a>
<ul class="sub-menu">
<li><a href="<?php echo esc_url( home_url( '/calculator' ) ); ?>"><?php esc_html_e( 'Calculator', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/protocol-builder' ) ); ?>"><?php esc_html_e( 'Protocol Builder', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/tracker' ) ); ?>"><?php esc_html_e( 'Tracker', 'peptide-starter' ); ?></a></li>
</ul>
</li>
<li class="menu-item-has-children">
<a href="#"><?php esc_html_e( 'My Data', 'peptide-starter' ); ?></a>
<ul class="sub-menu">
<li><a href="<?php echo esc_url( home_url( '/peptides' ) ); ?>"><?php esc_html_e( 'Peptides', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/subject-log' ) ); ?>"><?php esc_html_e( 'Subject Log', 'peptide-starter' ); ?></a></li>
</ul>
</li>
<li class="menu-item-has-children">
<a href="#"><?php esc_html_e( 'Resources', 'peptide-starter' ); ?></a>
<ul class="sub-menu">
<li><a href="<?php echo esc_url( home_url( '/documentation' ) ); ?>"><?php esc_html_e( 'Documentation', 'peptide-starter' ); ?></a></li>
<li><a href="<?php echo esc_url( home_url( '/news' ) ); ?>"><?php esc_html_e( 'Science Feed', 'peptide-starter' ); ?></a></li>
</ul>
</li>
</ul>
<?php
}
// Nav fallback lives in inc/helpers.php as of v1.5.1 to prevent redeclaration
// if header.php is ever included more than once in a request.
?>

View File

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

122
inc/config.php Normal file
View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -2,16 +2,19 @@
/**
* Newsletter Admin Page
*
* Simple admin interface to view and export collected newsletter emails.
* Emails are stored in wp_options as 'ps_newsletter_emails' until a
* proper mailing service (Mailchimp, etc.) is integrated.
* Admin interface under Tools for viewing / exporting newsletter
* subscribers. Exports apply CSV-injection safety to every cell. A
* migration-advisory notice is emitted once the subscriber count
* exceeds the configured threshold.
*
* @see functions.php — newsletter signup handler (stores emails)
* @see template-parts/newsletter-signup.php — frontend form
* @see functions.php — peptide_starter_handle_newsletter_signup() writes the option
* @see template-parts/newsletter-signup.php — the frontend form
* @see inc/config.php — newsletter_autoload_threshold
* @see inc/helpers.php — peptide_starter_csv_safe()
*
* What: Admin page under Tools for viewing/exporting subscriber emails.
* Who calls it: WordPress admin_menu action.
* Dependencies: None — reads from wp_options.
* What: Admin subscriber viewer + CSV export.
* Who calls it: WordPress admin_menu + admin_init actions.
* Dependencies: wp_options storage, CSV-safe helper.
*
* @package peptide-starter
*/
@@ -40,13 +43,12 @@ add_action( 'admin_menu', 'peptide_starter_newsletter_admin_menu' );
/**
* Render the newsletter admin page.
*
* Displays a simple table of subscriber emails with date added.
* Includes a CSV export button.
*
* @return void
*/
function peptide_starter_newsletter_admin_page() {
$emails = get_option( 'ps_newsletter_emails', array() );
$emails = get_option( 'ps_newsletter_emails', array() );
$threshold = (int) peptide_starter_config_get( 'newsletter_autoload_threshold', 1000 );
$count = count( $emails );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Newsletter Subscribers', 'peptide-starter' ); ?></h1>
@@ -55,13 +57,29 @@ function peptide_starter_newsletter_admin_page() {
echo esc_html(
sprintf(
/* translators: %d: number of subscribers */
_n( '%d subscriber', '%d subscribers', count( $emails ), 'peptide-starter' ),
count( $emails )
_n( '%d subscriber', '%d subscribers', $count, 'peptide-starter' ),
$count
)
);
?>
</p>
<?php if ( $count > $threshold ) : ?>
<div class="notice notice-warning">
<p>
<?php
echo esc_html(
sprintf(
/* translators: %d: subscriber threshold */
__( 'Subscriber count exceeds %d — consider migrating newsletter storage from wp_options to a dedicated table for performance (tracked as ADR-0004).', 'peptide-starter' ),
$threshold
)
);
?>
</p>
</div>
<?php endif; ?>
<?php if ( ! empty( $emails ) ) : ?>
<form method="post">
<?php wp_nonce_field( 'ps_newsletter_export', 'ps_export_nonce' ); ?>
@@ -74,13 +92,20 @@ function peptide_starter_newsletter_admin_page() {
<tr>
<th><?php esc_html_e( 'Email', 'peptide-starter' ); ?></th>
<th><?php esc_html_e( 'Date Added', 'peptide-starter' ); ?></th>
<th><?php esc_html_e( 'Status', 'peptide-starter' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $emails as $entry ) : ?>
<tr>
<td><?php echo esc_html( $entry['email'] ); ?></td>
<td><?php echo esc_html( $entry['date'] ); ?></td>
<td><?php echo esc_html( isset( $entry['date'] ) ? $entry['date'] : '' ); ?></td>
<td>
<?php
$status = isset( $entry['unsubscribed'] ) && $entry['unsubscribed'] ? 'unsubscribed' : 'active';
echo esc_html( $status );
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -95,6 +120,11 @@ function peptide_starter_newsletter_admin_page() {
/**
* Handle CSV export of newsletter subscribers.
*
* Every cell is routed through peptide_starter_csv_safe() to neutralise
* formula-injection attacks (values starting with =, +, -, @, tab, CR).
*
* Side effects: emits CSV response and exits.
*
* @return void
*/
function peptide_starter_newsletter_export_csv() {
@@ -117,13 +147,79 @@ function peptide_starter_newsletter_export_csv() {
header( 'Content-Disposition: attachment; filename=newsletter-subscribers-' . gmdate( 'Y-m-d' ) . '.csv' );
$output = fopen( 'php://output', 'w' );
fputcsv( $output, array( 'Email', 'Date Added' ) );
fputcsv( $output, array( 'Email', 'Date Added', 'Status' ) );
foreach ( $emails as $entry ) {
fputcsv( $output, array( $entry['email'], $entry['date'] ) );
$status = isset( $entry['unsubscribed'] ) && $entry['unsubscribed'] ? 'unsubscribed' : 'active';
fputcsv(
$output,
array(
peptide_starter_csv_safe( isset( $entry['email'] ) ? $entry['email'] : '' ),
peptide_starter_csv_safe( isset( $entry['date'] ) ? $entry['date'] : '' ),
peptide_starter_csv_safe( $status ),
)
);
}
fclose( $output );
exit;
}
add_action( 'admin_init', 'peptide_starter_newsletter_export_csv' );
/**
* Handle /newsletter-unsubscribe?token=... — token-gated, no login required.
*
* Side effects: flips the unsubscribed flag on the matching entry.
*
* @return void
*/
function peptide_starter_handle_unsubscribe() {
if ( ! is_main_query() || ! isset( $_GET['ps_unsubscribe'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '' === $token ) {
return;
}
$emails = get_option( 'ps_newsletter_emails', array() );
$changed = false;
foreach ( $emails as $idx => $entry ) {
if ( isset( $entry['unsub_token'] ) && hash_equals( (string) $entry['unsub_token'], $token ) ) {
$emails[ $idx ]['unsubscribed'] = true;
$changed = true;
break;
}
}
if ( $changed ) {
update_option( 'ps_newsletter_emails', $emails, false );
}
wp_safe_redirect( home_url( '/?ps_newsletter=unsubscribed' ) );
exit;
}
/**
* Register the /newsletter-unsubscribe rewrite.
*
* @return void
*/
function peptide_starter_register_unsubscribe_rewrite() {
add_rewrite_rule( '^newsletter-unsubscribe/?$', 'index.php?ps_unsubscribe=1', 'top' );
}
add_action( 'init', 'peptide_starter_register_unsubscribe_rewrite' );
/**
* Register the ps_unsubscribe query var.
*
* @param array $vars Existing query vars.
* @return array
*/
function peptide_starter_register_unsubscribe_query_var( $vars ) {
$vars[] = 'ps_unsubscribe';
return $vars;
}
add_filter( 'query_vars', 'peptide_starter_register_unsubscribe_query_var' );
add_action( 'template_redirect', 'peptide_starter_handle_unsubscribe' );

View File

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

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

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

View File

@@ -55,6 +55,7 @@ get_header();
<?php wp_nonce_field( 'ps_auth_login', 'ps_login_nonce' ); ?>
<input type="hidden" name="action" value="ps_auth_login">
<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect_to ); ?>">
<?php peptide_starter_render_honeypot( 'login' ); ?>
<div class="ps-form-group">
<label for="ps-login-email"><?php esc_html_e( 'Email', 'peptide-starter' ); ?></label>
@@ -104,6 +105,7 @@ get_header();
<?php wp_nonce_field( 'ps_auth_register', 'ps_register_nonce' ); ?>
<input type="hidden" name="action" value="ps_auth_register">
<input type="hidden" name="redirect_to" value="<?php echo esc_attr( $redirect_to ); ?>">
<?php peptide_starter_render_honeypot( 'register' ); ?>
<div class="ps-form-group">
<label for="ps-reg-username"><?php esc_html_e( 'Username', 'peptide-starter' ); ?></label>

View File

@@ -20,6 +20,12 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/*
* Intentionally open to anonymous users — the reconstitution calculator
* holds no PII and performs no writes. Gating it would add friction with
* no security benefit.
*/
get_header();
?>

View File

@@ -2,15 +2,12 @@
/**
* Template Name: User Profile
*
* Page template for the user profile page.
* Loads the Peptide Community plugin profile via shortcode.
* Renders the logged-in user's profile. Shows a verify-required banner
* when the account has ps_pending_verification=1 and an inline "verified"
* confirmation after a successful /verify click-through.
*
* @see page-auth.php — authentication template
* @see functions.php — enqueue and page auto-creation
*
* What: Renders the user profile page with plugin shortcode integration.
* Who calls it: WordPress template hierarchy when a page uses this template.
* Dependencies: Peptide Community plugin (optional — graceful fallback).
* @see inc/email-verification.php — verification flow + resend endpoint
*
* @package peptide-starter
*/
@@ -20,26 +17,69 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Redirect unauthenticated users to the auth page.
// Redirect unauthenticated users to the auth page. Intentionally does not
// use peptide_starter_require_login() — that would redirect unverified
// users away from profile, but profile is where we surface the verify UI.
if ( ! is_user_logged_in() ) {
wp_safe_redirect( home_url( '/auth?redirect_to=' . rawurlencode( get_permalink() ) ) );
exit;
}
$current_user = wp_get_current_user();
$needs_verify = ! peptide_starter_user_is_verified( $current_user->ID );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$show_verified = isset( $_GET['verified'] ) && '1' === $_GET['verified'];
get_header();
?>
<main id="main" class="site-main">
<section class="ps-tool-page">
<div class="ps-container">
<?php if ( $show_verified ) : ?>
<div class="ps-alert ps-alert-success" role="alert">
<p><?php esc_html_e( 'Your email address has been verified. Welcome aboard.', 'peptide-starter' ); ?></p>
</div>
<?php endif; ?>
<?php if ( $needs_verify ) : ?>
<div class="ps-alert ps-alert-warning ps-verify-required" role="alert">
<h3><?php esc_html_e( 'Verify your email to unlock data tools', 'peptide-starter' ); ?></h3>
<p>
<?php
echo esc_html(
sprintf(
/* translators: %s: user email */
__( 'We sent a verification link to %s. Click it to activate Subject Log, Tracker, and Protocol Builder. Links expire after 24 hours.', 'peptide-starter' ),
$current_user->user_email
)
);
?>
</p>
<form class="ps-verify-resend" id="ps-verify-resend" method="post" novalidate>
<?php wp_nonce_field( 'ps_resend_verify', 'ps_resend_nonce' ); ?>
<input type="hidden" name="action" value="ps_resend_verify">
<input type="hidden" name="email" value="<?php echo esc_attr( $current_user->user_email ); ?>">
<button type="submit" class="ps-btn ps-btn-secondary">
<?php esc_html_e( 'Resend verification email', 'peptide-starter' ); ?>
</button>
<span class="ps-verify-resend-status" aria-live="polite"></span>
</form>
</div>
<?php endif; ?>
<?php
if ( shortcode_exists( 'peptide_community_profile' ) ) {
echo do_shortcode( '[peptide_community_profile]' );
} else {
$current_user = wp_get_current_user();
?>
<div class="ps-profile-basic">
<h1><?php echo esc_html( sprintf( __( 'Welcome, %s', 'peptide-starter' ), $current_user->display_name ) ); ?></h1>
<h1>
<?php
/* translators: %s: user display name */
echo esc_html( sprintf( __( 'Welcome, %s', 'peptide-starter' ), $current_user->display_name ) );
?>
</h1>
<p><?php esc_html_e( 'Your profile page is being set up. Check back soon for full profile features.', 'peptide-starter' ); ?></p>
<div class="ps-profile-links">
<a href="<?php echo esc_url( home_url( '/tracker' ) ); ?>" class="ps-btn ps-btn-secondary">
@@ -60,4 +100,33 @@ get_header();
</section>
</main>
<script>
(function() {
const form = document.getElementById('ps-verify-resend');
if (!form) return;
const status = form.querySelector('.ps-verify-resend-status');
form.addEventListener('submit', function(e) {
e.preventDefault();
const data = new FormData(form);
const xhr = new XMLHttpRequest();
xhr.open('POST', '<?php echo esc_js( admin_url( 'admin-ajax.php' ) ); ?>');
xhr.onload = function() {
try {
const resp = JSON.parse(xhr.responseText);
if (status) {
const msg = (resp && resp.data && resp.data.message) ? resp.data.message : '';
const div = document.createElement('span');
div.textContent = ' ' + msg;
status.innerHTML = '';
status.appendChild(div);
}
} catch (ex) {
if (status) { status.textContent = ''; }
}
};
xhr.send(data);
});
})();
</script>
<?php get_footer(); ?>

View File

@@ -2,15 +2,11 @@
/**
* Template Name: Protocol Builder
*
* Page template for the interactive protocol builder wizard.
* Loads the Peptide Tools plugin protocol builder via shortcode.
* Interactive protocol builder wizard. Requires authenticated + email-verified
* users — protocols are user-owned saved state.
*
* @see page-calculator.php — sibling tool template
* @see functions.php — enqueue and page auto-creation
*
* What: Renders the protocol builder page with plugin shortcode integration.
* Who calls it: WordPress template hierarchy when a page uses this template.
* Dependencies: Peptide Tools plugin (optional — graceful fallback).
* @see inc/helpers.php — peptide_starter_require_login()
* @see page-calculator.php — sibling tool (intentionally open to anon — no PII).
*
* @package peptide-starter
*/
@@ -20,6 +16,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
peptide_starter_require_login();
get_header();
?>

View File

@@ -2,15 +2,11 @@
/**
* Template Name: Subject Log
*
* Page template for the longitudinal subject tracking log.
* Loads the Peptide Tracker plugin subject log via shortcode.
* Longitudinal subject-tracking log. Requires authenticated + email-verified
* users — contains user-owned PII (lab / assay results).
*
* @see inc/helpers.php — peptide_starter_require_login()
* @see page-tracker.php — related tracker template
* @see functions.php — enqueue and page auto-creation
*
* What: Renders the subject log page with plugin shortcode integration.
* Who calls it: WordPress template hierarchy when a page uses this template.
* Dependencies: Peptide Tracker plugin (optional — graceful fallback).
*
* @package peptide-starter
*/
@@ -20,6 +16,10 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Auth gate — redirects unauthenticated users to /auth with return path,
// and unverified users to /profile with verify-required banner.
peptide_starter_require_login();
get_header();
?>

View File

@@ -2,15 +2,11 @@
/**
* Template Name: Tracker
*
* Page template for the compound tracker tool.
* Loads the Peptide Tracker plugin via shortcode.
* Compound tracker UI. Requires authenticated + email-verified users —
* stores per-user compound tracking state.
*
* @see page-subject-log.php — related tracker template
* @see functions.php — enqueue and page auto-creation
*
* What: Renders the tracker page with plugin shortcode integration.
* Who calls it: WordPress template hierarchy when a page uses this template.
* Dependencies: Peptide Tracker plugin (optional — graceful fallback).
* @see inc/helpers.php — peptide_starter_require_login()
* @see page-subject-log.php — related template
*
* @package peptide-starter
*/
@@ -20,6 +16,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
peptide_starter_require_login();
get_header();
?>

View File

@@ -4,7 +4,7 @@ Theme URI: https://github.com/peptiderepo/peptide-starter
Description: Premium, accessible WordPress theme for scientific peptide reference database
Author: Peptide Repo
Author URI: https://peptiderepo.com
Version: 1.5.0
Version: 1.5.1
License: GPL v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: peptide-starter
@@ -3077,3 +3077,79 @@ html[data-theme="dark"] .ps-modules-section {
/* ============================================================================
END OF STYLESHEET
============================================================================ */
/* =====================================================================
Section 26 — v1.5.1 security/UX additions
===================================================================== */
/* Visually hidden honeypot wrapper — off-screen, keyboard-skipped, bot-visible. */
.ps-hp-wrap {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
pointer-events: none;
}
/* Email verification banner on /profile */
.ps-verify-required {
padding: var(--spacing-md);
margin-bottom: var(--spacing-lg);
border-left: 4px solid #F59E0B;
background-color: #FEF3C7;
color: #78350F;
border-radius: 6px;
}
html[data-theme="dark"] .ps-verify-required {
background-color: #451A03;
color: #FEF3C7;
border-left-color: #F59E0B;
}
.ps-verify-required h3 {
margin: 0 0 var(--spacing-xs) 0;
font-size: 1.05rem;
}
.ps-verify-resend {
margin-top: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.ps-verify-resend-status {
font-size: 0.9rem;
color: inherit;
}
/* Newsletter consent + privacy link */
.ps-newsletter-consent {
display: flex;
align-items: flex-start;
gap: var(--spacing-xs);
margin-top: var(--spacing-sm);
font-size: 0.9rem;
line-height: 1.5;
}
.ps-newsletter-consent input[type="checkbox"] {
margin-top: 0.25em;
flex-shrink: 0;
}
.ps-newsletter-privacy {
margin-top: var(--spacing-xs);
font-size: 0.85rem;
opacity: 0.85;
}
/* Footer translations note */
.footer-languages-note {
margin-top: var(--spacing-sm);
font-size: 0.85rem;
opacity: 0.7;
}

View File

@@ -2,16 +2,16 @@
/**
* Template Part: Newsletter Signup
*
* Reusable newsletter signup section displayed on front page and science feed.
* Stores emails in wp_options as a placeholder until Mailchimp/similar is integrated.
* Reusable "Monthly Research Dispatch" CTA with consent checkbox and honeypot.
* Success and duplicate states are merged into a single "ok" message to
* avoid leaking subscription status of any address.
*
* @see functions.php — peptide_starter_handle_newsletter_signup() for form processing
* @see front-page.php — includes this template part
* @see page-science-feed.php — includes this template part
* @see functions.php — peptide_starter_handle_newsletter_signup()
* @see inc/helpers.php — peptide_starter_render_honeypot()
*
* What: Renders the "Monthly Research Dispatch" email signup form.
* What: Renders the newsletter signup form.
* Who calls it: get_template_part( 'template-parts/newsletter', 'signup' ).
* Dependencies: None — self-contained HTML form.
* Dependencies: None — self-contained form.
*
* @package peptide-starter
*/
@@ -21,21 +21,21 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Check for newsletter submission status messages.
$newsletter_status = isset( $_GET['ps_newsletter'] ) ? sanitize_text_field( wp_unslash( $_GET['ps_newsletter'] ) ) : '';
$status_message = '';
$status_class = '';
$newsletter_status = isset( $_GET['ps_newsletter'] ) ? sanitize_text_field( wp_unslash( $_GET['ps_newsletter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$status_message = '';
$status_class = '';
if ( 'success' === $newsletter_status ) {
$status_message = __( 'Thanks for subscribing! You will receive our next dispatch.', 'peptide-starter' );
// Unified "ok" state merges the old success + duplicate — do not leak
// whether the address was already subscribed (abuse signal).
if ( 'ok' === $newsletter_status ) {
$status_message = __( 'Thanks — if this is your first subscription, you will receive our next dispatch.', 'peptide-starter' );
$status_class = 'ps-alert-success';
} elseif ( 'invalid' === $newsletter_status ) {
$status_message = __( 'Please enter a valid email address.', 'peptide-starter' );
$status_message = __( 'Please enter a valid email address and agree to receive updates.', 'peptide-starter' );
$status_class = 'ps-alert-error';
} elseif ( 'duplicate' === $newsletter_status ) {
$status_message = __( 'This email is already subscribed.', 'peptide-starter' );
$status_class = 'ps-alert-info';
}
$privacy_url = function_exists( 'get_privacy_policy_url' ) ? get_privacy_policy_url() : home_url( '/privacy' );
?>
<section class="ps-newsletter-section">
@@ -57,6 +57,8 @@ if ( 'success' === $newsletter_status ) {
<form class="ps-newsletter-form" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
<?php wp_nonce_field( 'peptide_starter_newsletter', 'ps_newsletter_nonce' ); ?>
<input type="hidden" name="action" value="peptide_starter_newsletter_signup">
<?php peptide_starter_render_honeypot( 'newsletter' ); ?>
<div class="ps-newsletter-form-row">
<input
type="email"
@@ -70,6 +72,19 @@ if ( 'success' === $newsletter_status ) {
<?php esc_html_e( 'Subscribe', 'peptide-starter' ); ?>
</button>
</div>
<label class="ps-newsletter-consent">
<input type="checkbox" name="ps_consent" value="1" required>
<span>
<?php esc_html_e( 'I agree to receive occasional research updates. Unsubscribe anytime.', 'peptide-starter' ); ?>
</span>
</label>
<p class="ps-newsletter-privacy">
<a href="<?php echo esc_url( $privacy_url ); ?>" rel="noopener">
<?php esc_html_e( 'Privacy policy', 'peptide-starter' ); ?>
</a>
</p>
</form>
<p class="ps-newsletter-learn-more">

View File

@@ -47,6 +47,7 @@ $user_email = is_user_logged_in() ? $current_user->user_email : '';
<form id="ps-contact-form" class="ps-contact-form" novalidate>
<?php wp_nonce_field( 'ps_contact_form', 'ps_contact_nonce' ); ?>
<input type="hidden" name="action" value="ps_contact_submit">
<?php peptide_starter_render_honeypot( 'contact' ); ?>
<div class="ps-form-group">
<label for="ps-contact-name"><?php esc_html_e( 'Name', 'peptide-starter' ); ?></label>

64
tests/test-auth-gate.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
/**
* Tests for peptide_starter_require_login() helper.
*
* @package peptide-starter
*/
class Test_Auth_Gate extends WP_UnitTestCase {
/**
* @var string|null Captured redirect target.
*/
protected $redirect = null;
public function set_up() {
parent::set_up();
$this->redirect = null;
add_filter(
'wp_redirect',
function ( $url ) {
$this->redirect = $url;
return false;
}
);
}
public function tear_down() {
remove_all_filters( 'wp_redirect' );
wp_set_current_user( 0 );
parent::tear_down();
}
public function test_anonymous_user_redirected_to_auth() {
$_SERVER['REQUEST_URI'] = '/tracker';
try { peptide_starter_require_login(); } catch ( Exception $e ) { /* exit */ }
$this->assertNotNull( $this->redirect );
$this->assertStringContainsString( '/auth', (string) $this->redirect );
$this->assertStringContainsString( 'redirect_to=', (string) $this->redirect );
}
public function test_unverified_user_redirected_to_profile_banner() {
$user_id = self::factory()->user->create();
update_user_meta( $user_id, 'ps_pending_verification', 1 );
wp_set_current_user( $user_id );
try { peptide_starter_require_login(); } catch ( Exception $e ) { /* exit */ }
$this->assertNotNull( $this->redirect );
$this->assertStringContainsString( 'verify_required=1', (string) $this->redirect );
}
public function test_verified_logged_in_user_passes_through() {
$user_id = self::factory()->user->create();
wp_set_current_user( $user_id );
$reached = false;
try {
peptide_starter_require_login();
$reached = true;
} catch ( Exception $e ) { /* would be redirect */ }
$this->assertTrue( $reached );
$this->assertNull( $this->redirect );
}
}

View File

@@ -0,0 +1,152 @@
<?php
/**
* Tests for inc/auth-handlers.php.
*
* Covers: unified error messages, rate limiting, nonce failure,
* honeypot drop, enumeration resistance.
*
* @package peptide-starter
*/
/**
* @runTestsInSeparateProcesses disabled — WP_UnitTestCase handles isolation.
*/
class Test_Auth_Handlers extends WP_UnitTestCase {
/**
* Capture wp_send_json_{success,error} via wp_die_handler so tests can
* assert on the response without ending the PHP process.
*
* @var array|null
*/
protected $captured = null;
public function set_up() {
parent::set_up();
$this->captured = null;
// Swap wp_die into a throwable we can catch.
add_filter( 'wp_die_ajax_handler', array( $this, 'get_die_handler' ) );
add_filter( 'wp_die_handler', array( $this, 'get_die_handler' ) );
// Capture the JSON body via the wp_json_encode filter path.
add_filter(
'wp_json_encode_data',
function ( $data ) {
$this->captured = $data;
return $data;
}
);
}
public function tear_down() {
remove_all_filters( 'wp_die_ajax_handler' );
remove_all_filters( 'wp_die_handler' );
remove_all_filters( 'wp_json_encode_data' );
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_ps_rl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_ps_rl_%'" );
$_POST = array();
parent::tear_down();
}
public function get_die_handler() {
return array( $this, 'throwing_die' );
}
public function throwing_die( $message = '', $title = '', $args = array() ) {
throw new WPAjaxDieStopException( is_scalar( $message ) ? (string) $message : wp_json_encode( $message ) );
}
/**
* Run the login handler and return the captured response, if any.
*/
protected function invoke_login() {
try {
peptide_starter_ajax_login();
} catch ( WPAjaxDieStopException $e ) {
// Expected — JSON was sent.
return;
}
}
public function test_login_missing_nonce_rejects_with_security_message() {
$_POST = array( 'email' => 'a@b.com', 'password' => 'whatever' );
$this->invoke_login();
$this->assertIsArray( $this->captured );
$this->assertFalse( $this->captured['success'] );
$this->assertStringContainsString( 'Security check', $this->captured['data']['message'] );
}
public function test_login_unknown_email_returns_unified_message() {
$_POST = array(
'ps_login_nonce' => wp_create_nonce( 'ps_auth_login' ),
'email' => 'nobody-' . wp_generate_password( 8, false ) . '@example.com',
'password' => 'wrong-password',
);
$this->invoke_login();
$this->assertSame( peptide_starter_login_failure_message(), $this->captured['data']['message'] );
}
public function test_login_wrong_password_returns_unified_message() {
$user = self::factory()->user->create_and_get( array( 'user_pass' => 'correct-horse-battery' ) );
$_POST = array(
'ps_login_nonce' => wp_create_nonce( 'ps_auth_login' ),
'email' => $user->user_email,
'password' => 'definitely-wrong',
);
$this->invoke_login();
$this->assertSame( peptide_starter_login_failure_message(), $this->captured['data']['message'] );
}
public function test_login_honeypot_tripped_returns_fake_success() {
$_POST = array(
'ps_login_nonce' => wp_create_nonce( 'ps_auth_login' ),
'email' => 'a@b.com',
'password' => 'x',
'ps_hp_login' => 'botfill',
);
$this->invoke_login();
$this->assertTrue( $this->captured['success'], 'Honeypot must silently fake success.' );
}
public function test_login_rate_limit_exceeded_still_returns_unified_message() {
// Exhaust the budget.
$config = peptide_starter_config_rate_limit( 'login' );
for ( $i = 0; $i < $config['limit']; $i++ ) {
Peptide_Starter_Rate_Limiter::record( 'login', peptide_starter_hash_identifier( 'same@same.com' ) );
}
$_POST = array(
'ps_login_nonce' => wp_create_nonce( 'ps_auth_login' ),
'email' => 'same@same.com',
'password' => 'anything',
);
$this->invoke_login();
$this->assertSame( peptide_starter_login_failure_message(), $this->captured['data']['message'] );
}
public function test_register_invalid_username_returns_unified_message() {
$_POST = array(
'ps_register_nonce' => wp_create_nonce( 'ps_auth_register' ),
'username' => 'ab',
'email' => 'newuser@example.com',
'password' => 'longenoughpassword',
);
update_option( 'users_can_register', 1 );
try { peptide_starter_ajax_register(); } catch ( WPAjaxDieStopException $e ) { /* ok */ }
$this->assertSame( peptide_starter_register_failure_message(), $this->captured['data']['message'] );
}
public function test_register_duplicate_email_returns_unified_message() {
self::factory()->user->create( array( 'user_email' => 'dup@example.com' ) );
update_option( 'users_can_register', 1 );
$_POST = array(
'ps_register_nonce' => wp_create_nonce( 'ps_auth_register' ),
'username' => 'newusername',
'email' => 'dup@example.com',
'password' => 'longenoughpassword',
);
try { peptide_starter_ajax_register(); } catch ( WPAjaxDieStopException $e ) { /* ok */ }
$this->assertSame( peptide_starter_register_failure_message(), $this->captured['data']['message'] );
}
}

View File

@@ -0,0 +1,110 @@
<?php
/**
* Tests for inc/contact-handler.php.
*
* @package peptide-starter
*/
class Test_Contact_Handler extends WP_UnitTestCase {
protected $captured = null;
public function set_up() {
parent::set_up();
$this->captured = null;
add_filter(
'wp_die_ajax_handler',
function () {
return array( $this, 'throwing_die' );
}
);
add_filter(
'wp_die_handler',
function () {
return array( $this, 'throwing_die' );
}
);
add_filter(
'wp_json_encode_data',
function ( $data ) {
$this->captured = $data;
return $data;
}
);
add_filter(
'pre_wp_mail',
function () {
return true;
}
);
}
public function tear_down() {
remove_all_filters( 'wp_die_ajax_handler' );
remove_all_filters( 'wp_die_handler' );
remove_all_filters( 'wp_json_encode_data' );
remove_all_filters( 'pre_wp_mail' );
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_ps_rl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_ps_rl_%'" );
$_POST = array();
parent::tear_down();
}
public function throwing_die( $message = '' ) {
throw new WPAjaxDieStopException( is_scalar( $message ) ? (string) $message : wp_json_encode( $message ) );
}
protected function post( $overrides = array() ) {
$defaults = array(
'ps_contact_nonce' => wp_create_nonce( 'ps_contact_form' ),
'name' => 'Dr Valid',
'email' => 'valid@example.com',
'topic' => 'bug',
'message' => 'There is a bug in the tracker.',
);
$_POST = array_merge( $defaults, $overrides );
try { peptide_starter_ajax_contact(); } catch ( WPAjaxDieStopException $e ) { /* ok */ }
}
public function test_valid_submission_succeeds() {
$this->post();
$this->assertTrue( $this->captured['success'] );
}
public function test_nonce_failure_rejects() {
$this->post( array( 'ps_contact_nonce' => 'bogus' ) );
$this->assertFalse( $this->captured['success'] );
$this->assertStringContainsString( 'Security', $this->captured['data']['message'] );
}
public function test_name_with_newline_rejected_for_header_injection() {
$this->post( array( 'name' => "Mallory\r\nBcc: attacker@x.com" ) );
$this->assertFalse( $this->captured['success'] );
}
public function test_name_with_angle_brackets_rejected() {
$this->post( array( 'name' => 'Alice <injected@x.com>' ) );
$this->assertFalse( $this->captured['success'] );
}
public function test_invalid_topic_rejected() {
$this->post( array( 'topic' => 'not-an-allowlisted-topic' ) );
$this->assertFalse( $this->captured['success'] );
}
public function test_honeypot_returns_fake_success() {
$this->post( array( 'ps_hp_contact' => 'filled-by-bot' ) );
$this->assertTrue( $this->captured['success'] );
}
public function test_rate_limit_exceeded_rejects() {
$config = peptide_starter_config_rate_limit( 'contact' );
for ( $i = 0; $i < $config['limit']; $i++ ) {
Peptide_Starter_Rate_Limiter::record( 'contact', 'ip' );
}
$this->post();
$this->assertFalse( $this->captured['success'] );
$this->assertStringContainsString( 'Too many', $this->captured['data']['message'] );
}
}

View File

@@ -0,0 +1,103 @@
<?php
/**
* Tests for inc/email-verification.php.
*
* @package peptide-starter
*/
class Test_Email_Verification extends WP_UnitTestCase {
public function set_up() {
parent::set_up();
// Capture wp_mail without sending.
add_filter(
'pre_wp_mail',
function () {
return true;
}
);
}
public function tear_down() {
remove_all_filters( 'pre_wp_mail' );
parent::tear_down();
}
public function test_send_sets_meta_and_returns_true() {
$user_id = self::factory()->user->create();
$sent = peptide_starter_send_verification_email( $user_id );
$this->assertTrue( $sent );
$this->assertSame( '1', (string) get_user_meta( $user_id, 'ps_pending_verification', true ) );
$this->assertNotEmpty( get_user_meta( $user_id, 'ps_verify_token', true ) );
$this->assertGreaterThan( time(), (int) get_user_meta( $user_id, 'ps_verify_expires', true ) );
}
public function test_user_is_verified_default_without_meta() {
$user_id = self::factory()->user->create();
$this->assertTrue( peptide_starter_user_is_verified( $user_id ) );
}
public function test_user_is_unverified_with_pending_meta() {
$user_id = self::factory()->user->create();
update_user_meta( $user_id, 'ps_pending_verification', 1 );
$this->assertFalse( peptide_starter_user_is_verified( $user_id ) );
}
public function test_verification_handler_rejects_bad_token() {
$user_id = self::factory()->user->create();
peptide_starter_send_verification_email( $user_id );
$_GET['uid'] = $user_id;
$_GET['token'] = 'obviously-wrong';
set_query_var( 'ps_verify', 1 );
$redirected = null;
add_filter(
'wp_redirect',
function ( $url ) use ( &$redirected ) {
$redirected = $url;
return false;
}
);
try {
peptide_starter_handle_verify_request();
} catch ( Exception $e ) { /* wp_safe_redirect + exit */ }
$this->assertNotNull( $redirected );
$this->assertStringContainsString( 'verify_error=1', (string) $redirected );
remove_all_filters( 'wp_redirect' );
set_query_var( 'ps_verify', 0 );
}
public function test_verification_handler_rejects_expired_token() {
$user_id = self::factory()->user->create();
$token = wp_generate_password( 43, false, false );
update_user_meta( $user_id, 'ps_pending_verification', 1 );
update_user_meta( $user_id, 'ps_verify_token', $token );
update_user_meta( $user_id, 'ps_verify_expires', time() - 10 );
$_GET['uid'] = $user_id;
$_GET['token'] = $token;
set_query_var( 'ps_verify', 1 );
$redirected = null;
add_filter(
'wp_redirect',
function ( $url ) use ( &$redirected ) {
$redirected = $url;
return false;
}
);
try {
peptide_starter_handle_verify_request();
} catch ( Exception $e ) { /* ok */ }
$this->assertNotNull( $redirected );
$this->assertStringContainsString( 'verify_error=1', (string) $redirected );
remove_all_filters( 'wp_redirect' );
set_query_var( 'ps_verify', 0 );
}
}

96
tests/test-newsletter.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
/**
* Tests for newsletter signup handler + CSV-safe export helper.
*
* @package peptide-starter
*/
class Test_Newsletter extends WP_UnitTestCase {
public function tear_down() {
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_ps_rl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_ps_rl_%'" );
delete_option( 'ps_newsletter_emails' );
$_POST = array();
parent::tear_down();
}
public function test_csv_safe_prefixes_dangerous_leading_chars() {
$this->assertSame( "'=cmd|' /C calc'!A0", peptide_starter_csv_safe( "=cmd|' /C calc'!A0" ) );
$this->assertSame( "'+1234", peptide_starter_csv_safe( '+1234' ) );
$this->assertSame( "'-1234", peptide_starter_csv_safe( '-1234' ) );
$this->assertSame( "'@evil", peptide_starter_csv_safe( '@evil' ) );
$this->assertSame( "'\tTab", peptide_starter_csv_safe( "\tTab" ) );
$this->assertSame( "'\rCR", peptide_starter_csv_safe( "\rCR" ) );
}
public function test_csv_safe_passes_safe_values_through() {
$this->assertSame( 'alice@example.com', peptide_starter_csv_safe( 'alice@example.com' ) );
$this->assertSame( '2026-04-14 10:30:00', peptide_starter_csv_safe( '2026-04-14 10:30:00' ) );
$this->assertSame( '', peptide_starter_csv_safe( '' ) );
}
public function test_signup_stores_email_with_unsub_token() {
$_POST = array(
'ps_newsletter_nonce' => wp_create_nonce( 'peptide_starter_newsletter' ),
'email' => 'sub@example.com',
'ps_consent' => '1',
);
$this->invoke_signup();
$emails = get_option( 'ps_newsletter_emails', array() );
$this->assertCount( 1, $emails );
$this->assertSame( 'sub@example.com', $emails[0]['email'] );
$this->assertNotEmpty( $emails[0]['unsub_token'] );
}
public function test_signup_without_consent_rejected() {
$_POST = array(
'ps_newsletter_nonce' => wp_create_nonce( 'peptide_starter_newsletter' ),
'email' => 'nope@example.com',
);
$this->invoke_signup();
$emails = get_option( 'ps_newsletter_emails', array() );
$this->assertCount( 0, $emails );
}
public function test_signup_duplicate_does_not_add_second_row() {
$initial = array(
array(
'email' => 'dup@example.com',
'date' => '2026-04-10 00:00:00',
'unsub_token' => 'abc',
),
);
update_option( 'ps_newsletter_emails', $initial, false );
$_POST = array(
'ps_newsletter_nonce' => wp_create_nonce( 'peptide_starter_newsletter' ),
'email' => 'dup@example.com',
'ps_consent' => '1',
);
$this->invoke_signup();
$emails = get_option( 'ps_newsletter_emails', array() );
$this->assertCount( 1, $emails );
}
/**
* Invoke the handler and catch the wp_safe_redirect exit.
*/
protected function invoke_signup() {
$captured = null;
$filter = function ( $url ) use ( &$captured ) {
$captured = $url;
return false;
};
add_filter( 'wp_redirect', $filter );
try {
peptide_starter_handle_newsletter_signup();
} catch ( Exception $e ) { /* exit path */ }
remove_filter( 'wp_redirect', $filter );
}
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* Tests for inc/rate-limiter.php and inc/config.php.
*
* @package peptide-starter
*/
/**
* Rate limiter check / record / reset lifecycle and filterability.
*/
class Test_Rate_Limiter extends WP_UnitTestCase {
/**
* Reset all transients we touched between tests.
*/
public function tear_down() {
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_ps_rl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_ps_rl_%'" );
remove_all_filters( 'peptide_starter_security_config' );
parent::tear_down();
}
public function test_check_allows_under_limit() {
$this->assertTrue( Peptide_Starter_Rate_Limiter::check( 'login', 'user-a' ) );
}
public function test_record_increments_and_eventually_blocks() {
$config = peptide_starter_config_rate_limit( 'login' );
for ( $i = 0; $i < $config['limit']; $i++ ) {
Peptide_Starter_Rate_Limiter::record( 'login', 'user-b' );
}
$this->assertFalse(
Peptide_Starter_Rate_Limiter::check( 'login', 'user-b' ),
'check() must return false after recording limit-many attempts.'
);
}
public function test_reset_clears_counter() {
$config = peptide_starter_config_rate_limit( 'login' );
for ( $i = 0; $i < $config['limit']; $i++ ) {
Peptide_Starter_Rate_Limiter::record( 'login', 'user-c' );
}
Peptide_Starter_Rate_Limiter::reset( 'login', 'user-c' );
$this->assertTrue( Peptide_Starter_Rate_Limiter::check( 'login', 'user-c' ) );
}
public function test_identifiers_are_isolated() {
$config = peptide_starter_config_rate_limit( 'login' );
for ( $i = 0; $i < $config['limit']; $i++ ) {
Peptide_Starter_Rate_Limiter::record( 'login', 'user-d' );
}
$this->assertTrue(
Peptide_Starter_Rate_Limiter::check( 'login', 'user-e' ),
'Recording against user-d must not affect user-e.'
);
}
public function test_filter_override_changes_limit() {
add_filter(
'peptide_starter_security_config',
function ( $c ) {
$c['rate_limits']['login']['limit'] = 1;
return $c;
}
);
Peptide_Starter_Rate_Limiter::record( 'login', 'user-f' );
$this->assertFalse( Peptide_Starter_Rate_Limiter::check( 'login', 'user-f' ) );
}
public function test_hash_identifier_is_stable_and_non_reversible() {
$a = peptide_starter_hash_identifier( 'alice@example.com' );
$b = peptide_starter_hash_identifier( 'alice@example.com' );
$c = peptide_starter_hash_identifier( 'bob@example.com' );
$this->assertSame( $a, $b );
$this->assertNotSame( $a, $c );
$this->assertSame( 32, strlen( $a ) );
$this->assertStringNotContainsString( 'alice', $a );
}
public function test_client_ip_prefers_cf_header() {
$_SERVER['HTTP_CF_CONNECTING_IP'] = '203.0.113.5';
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
$this->assertSame( '203.0.113.5', peptide_starter_get_client_ip() );
unset( $_SERVER['HTTP_CF_CONNECTING_IP'] );
}
public function test_client_ip_rejects_garbage() {
$_SERVER['HTTP_CF_CONNECTING_IP'] = 'not-an-ip';
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
$this->assertSame( '10.0.0.1', peptide_starter_get_client_ip() );
unset( $_SERVER['HTTP_CF_CONNECTING_IP'] );
}
}