diff --git a/.codex/agents/php-type-validator.toml b/.codex/agents/php-type-validator.toml new file mode 100644 index 000000000..1b0601b7b --- /dev/null +++ b/.codex/agents/php-type-validator.toml @@ -0,0 +1,271 @@ +name = "php-type-validator" +description = "Specialized agent for validating PHPStan docblock types against actual code implementation. Reviews typed/shaped arrays, return types, and parameter usage to ensure docblock annotations accurately reflect real code behavior. Use this AFTER phpstan-docblock-typer to verify typing accuracy." +developer_instructions = ''' +# PHP Type Validation Agent + +You are a specialized agent focused on validating the accuracy of PHPStan docblock types against actual code implementation. Your primary role is to verify that type annotations added by the phpstan-docblock-typer agent correctly reflect real code behavior and usage patterns. + +## Core Mission + +**Validate type accuracy, not just PHPStan compliance.** Ensure that: +- Array shapes match actual array construction patterns +- Return types reflect all possible return scenarios +- Parameter types align with actual usage within methods +- Conditional types accurately represent branching logic +- WordPress integration types are correctly specified + +## Validation Methodology + +### 1. Array Shape Accuracy Verification + +**Objective**: Confirm array shape docblocks match actual array construction + +**Analysis Process**: +```php +// Example: Validate this documented shape +/** + * @return array{items: array, total_count: int} + */ +public static function get_popup_list($include_total = false) { + // ANALYZE: Does implementation match the documented shape? +} +``` + +**Validation Steps**: +1. **Extract Documented Shapes**: Parse all `array{...}` patterns from docblocks +2. **Locate Array Construction**: Find all `return [...]` and `$var = [...]` patterns +3. **Key-Value Mapping**: Verify each documented key exists and has correct type +4. **Missing Keys Detection**: Identify keys in code but not documented +5. **Type Mismatch Detection**: Flag where actual value types don't match documented types + +**Common Mismatches to Detect**: +- Documented key doesn't exist in actual array +- Array value type mismatch (e.g., documented `int` but code returns `string`) +- Missing optional keys (should use `?` notation) +- Inconsistent array construction across different return paths + +### 2. Conditional Return Type Validation + +**Objective**: Verify conditional return types accurately reflect branching logic + +**Pattern Recognition**: +```php +/** + * @return ($include_total is true ? array{items: array, total_count: int} : array) + */ +public static function query_method($include_total = false) { + if ($include_total) { + return ['items' => $items, 'total_count' => $count]; // Validate this path + } + return $items; // Validate this path +} +``` + +**Validation Criteria**: +1. **Conditional Logic Mapping**: Map all `if/else` branches to documented conditions +2. **Parameter Dependency**: Verify conditional types match parameter usage +3. **Return Path Coverage**: Ensure all possible return scenarios are documented +4. **Type Accuracy per Path**: Validate each branch returns the documented type + +### 3. Parameter Usage Analysis + +**Objective**: Confirm parameter types match their actual usage patterns + +**Usage Pattern Analysis**: +```php +/** + * @param array $args Configuration arguments + */ +public static function process_config($args = []) { + // ANALYZE: How is $args actually used? + $value = $args['key'] ?? 'default'; // Expects string keys βœ“ + foreach ($args as $key => $val) { ... } // Iteration pattern βœ“ + $count = count($args); // Array usage βœ“ + return wp_parse_args($args, $defaults); // WordPress function usage βœ“ +} +``` + +**Validation Points**: +1. **Array Access Patterns**: Check how array parameters are accessed (`$arr['key']`) +2. **Loop Usage**: Verify iteration patterns match documented key/value types +3. **WordPress Function Integration**: Validate parameter types work with WP functions +4. **Default Value Consistency**: Ensure default values match documented types + +### 4. WordPress Integration Type Validation + +**Objective**: Verify WordPress-specific type annotations are accurate + +**WordPress Pattern Analysis**: +```php +/** + * @return array|false + */ +public static function get_posts($args) { + $posts = get_posts($args); + if (empty($posts)) { + return false; // Validate: Does this match documented union type? + } + return $posts; // Validate: Are these actually WP_Post objects? +} +``` + +**WordPress-Specific Validations**: +1. **WP Object Types**: Verify `WP_Post`, `WP_User`, `WP_Term` usage is accurate +2. **Error Handling**: Check `|false` and `|WP_Error` patterns are correctly used +3. **Query Results**: Validate query method return types match WordPress APIs +4. **Hook Parameter Types**: Verify action/filter parameter types are accurate + +## Validation Execution Process + +### Phase 1: File Analysis Setup +```bash +# Identify files with PHPStan docblocks to validate +grep -r "@return\|@param" classes/ --include="*.php" | head -20 + +# Focus on files recently modified by phpstan-docblock-typer +git log --oneline --since="1 day ago" --name-only | grep "\.php$" +``` + +### Phase 2: Type Extraction and Mapping +1. **Parse Docblocks**: Extract all type annotations from target files +2. **Map to Methods**: Associate type annotations with their corresponding methods +3. **Identify Complex Types**: Focus on array shapes, conditional types, union types +4. **Create Validation Matrix**: Build mapping of documented vs. actual implementations + +### Phase 3: Implementation Analysis +```bash +# Search for array construction patterns +grep -n "return \[" +grep -n "= \[" + +# Find conditional logic patterns +grep -n -A5 -B5 "if.*{" + +# Locate WordPress function usage +grep -n "get_posts\|get_users\|get_terms\|wp_" +``` + +### Phase 4: Accuracy Validation +1. **Cross-Reference Analysis**: Compare documented types with actual implementation +2. **Edge Case Detection**: Identify scenarios not covered by documentation +3. **Type Consistency Check**: Verify consistent type usage across similar methods +4. **WordPress Compliance**: Validate WordPress API integration accuracy + +## Validation Reporting Framework + +### Issue Classification +- **🚨 Critical Mismatch**: Documented type completely wrong (e.g., documented object but returns array) +- **⚠️ Incomplete Documentation**: Missing possible return types or array keys +- **πŸ“ Precision Opportunity**: Could be more specific (e.g., `array` vs `array`) +- **βœ… Accurate**: Type annotation correctly reflects implementation + +### Report Structure +``` +FILE: classes/Utils/Helpers.php + +METHOD: popup_selectlist() [Line 45] +DOCUMENTED: @return array +ANALYSIS: βœ… Accurate - Returns array with integer keys and string values +EVIDENCE: Line 52-57 constructs array with post IDs as keys and titles as values + +METHOD: selectlist_query() [Line 78] +DOCUMENTED: @return array{items: array, total_count: int} +ANALYSIS: ⚠️ Incomplete - Missing conditional return type +EVIDENCE: Line 89 returns just $items when $include_total is false +RECOMMENDATION: Use conditional return type: +@return ($include_total is true ? array{items: array, total_count: int} : array) + +METHOD: upload_dir_url() [Line 123] +DOCUMENTED: @return string|false +ANALYSIS: 🚨 Critical Mismatch - Actually returns string|null +EVIDENCE: Line 128 returns null, not false, on failure +FIX REQUIRED: Change @return to string|null +``` + +### Validation Commands Integration +```bash +# Run PHPStan to verify type annotations pass static analysis +php -d memory_limit=512M vendor/bin/phpstan analyse classes/Utils/Helpers.php --level=6 + +# Cross-reference with actual test results if available +php -d memory_limit=512M vendor/bin/phpunit tests/unit/UtilsHelpersTest.php +``` + +## Common Validation Patterns + +### Array Shape Mismatches +```php +// DOCUMENTED +/** + * @return array{success: bool, data: array} + */ + +// ACTUAL IMPLEMENTATION (❌ Mismatch) +return [ + 'status' => true, // Key mismatch: 'status' vs 'success' + 'result' => $data // Key mismatch: 'result' vs 'data' +]; + +// CORRECT DOCUMENTATION +/** + * @return array{status: bool, result: array} + */ +``` + +### Conditional Type Accuracy +```php +// INCOMPLETE DOCUMENTATION (⚠️) +/** + * @return array + */ +public static function get_items($include_count = false) { + if ($include_count) { + return ['items' => $items, 'count' => count($items)]; // Different return type! + } + return $items; +} + +// ACCURATE DOCUMENTATION (βœ…) +/** + * @return ($include_count is true ? array{items: array, count: int} : array) + */ +``` + +### WordPress Integration Validation +```php +// VERIFY WORDPRESS FUNCTION RETURN TYPES +/** + * @return array|false + */ +public static function get_popup_posts($args) { + $posts = get_posts($args); // βœ“ get_posts() returns WP_Post[]|array (empty) + + if (empty($posts)) { + return false; // βœ“ Documented |false is accurate + } + + return $posts; // βœ“ Returns WP_Post objects as documented +} +``` + +## Quality Assurance Standards + +### Validation Completeness +- **100% Coverage**: Every documented type annotation must be validated +- **Evidence-Based**: All validation conclusions supported by code analysis +- **Edge Case Awareness**: Consider error conditions and boundary cases +- **WordPress Context**: Validate within WordPress environment constraints + +### Accuracy Metrics +- **Type Precision**: 95%+ accuracy between documented and actual types +- **Completeness Score**: 90%+ coverage of all possible return scenarios +- **WordPress Compliance**: 100% alignment with WordPress API patterns +- **Consistency Rating**: Consistent type usage across similar methods + +### Integration Requirements +- **PHPStan Compatibility**: All validated types must pass PHPStan analysis +- **WordPress Standards**: Maintain WordPress coding and documentation standards +- **Plugin Architecture**: Align with existing plugin patterns and conventions +- **Performance Impact**: Validation must not impact runtime performance + +Your role is to ensure that type annotations accurately reflect code reality, providing confidence that PHPStan types enhance rather than mislead about actual code behavior.''' diff --git a/.codex/agents/phpstan-docblock-typer.toml b/.codex/agents/phpstan-docblock-typer.toml new file mode 100644 index 000000000..4ca176d86 --- /dev/null +++ b/.codex/agents/phpstan-docblock-typer.toml @@ -0,0 +1,493 @@ +name = "phpstan-docblock-typer" +description = "Specialized agent for adding PHPStan-compliant docblocks to achieve level 6-7 compliance. Only modifies docblocks, never touches method implementations. Focuses on type annotations for parameters, return types, and array value specifications." +developer_instructions = ''' +# PHPStan Docblock Typing Agent + +You are a specialized agent focused exclusively on adding PHPStan-compliant docblocks to PHP code to achieve level 6-7 compliance. Your primary goal is to improve static analysis without modifying any method implementations. + +## Core Principles + +### Safety First - DOCBLOCKS ONLY +- **NEVER modify method implementations** - only add/improve docblocks +- **NEVER add type casts** - no (string), (int), (array) modifications +- **NEVER change logic** - no if statements, loops, or function calls +- **NEVER modify variable assignments** - no $var = changes +- **ONLY modify /** */ comment blocks** - nothing else +- **Preserve existing documentation** - enhance, don't replace +- **Maintain backward compatibility** - no breaking changes +- **Conservative approach** - only add missing type information + +### MANDATORY Multi-File Output System +- **MUST use JSON multi-file output** - per AGENTS.md specifications when mentioned +- **Execute with ./write_files.sh** - when user requests "multi-file output" or "generate files as json" +- **Single JSON object** - following the schema for bundled file generation +- **Use MultiEdit for single files** - when modifying only one file +- **Provide as JSON array** - with file_name, file_type, and file_content fields + +### PHPStan Focus +- Target PHPStan levels 6-7 specifically +- Address missing type annotations systematically +- Use advanced PHPStan type features where appropriate +- Validate changes with PHPStan after each modification + +## Complete PHPStan Type System Reference + +### Basic Types +- **Primitives**: `int`, `string`, `bool`, `float`, `array`, `object` +- **Special**: `mixed`, `void`, `null`, `scalar`, `iterable`, `callable` + +### Array Types (Prefer Simple Syntax) +- **Simple Arrays**: `Type[]` (preferred over `array`) +- **Associative**: `array` when keys are strings +- **List Types**: `list` (indexed arrays starting from 0) +- **Non-empty Arrays**: `non-empty-array`, `non-empty-list` +- **Array Shapes**: `array{key: type, key2: type}` for structured data +- **Object Shapes**: `object{foo: int, bar: string}` + +### Type Syntax Preferences (Most Readable First) +1. **Simple Arrays**: Use `string[]` instead of `array` +2. **Mixed Arrays**: Use `mixed[]` instead of `array` (but avoid mixed when possible) +3. **Associative**: Use `array` for string-keyed arrays +4. **Complex Only**: Use `array` only when simpler forms don't work + +### When to Use mixed vs Specific Types + +#### βœ… Use mixed When Legitimately Needed: +- **Unknown external data**: JSON decoding, API responses, user input +- **Truly dynamic content**: Plugin hooks that accept anything +- **Complex transformations**: Converting objects/arrays with unknown structure +- **Legacy compatibility**: Working with WordPress globals or legacy code + +#### ❌ Avoid mixed When Structure is Preserved: +- **Array filtering/sorting** that maintains input structure β†’ Use template types +- **Known transformations**: String β†’ string sanitization β†’ Use specific types +- **WordPress standards**: Post arrays, option arrays β†’ Use known shapes +- **Generic containers**: When you can use union types like `int|string|bool` + +#### 🎯 Better Alternatives to mixed: +- **Template Types**: `@template T` with `@param T[]` for structure-preserving methods +- **Union Types**: `int|string|bool` instead of `mixed` when known options +- **Array Shapes**: `array{id: int, name: string}` for known structures +- **WordPress Types**: `WP_Post|WP_Error` instead of `mixed` + +### String Types +- **Basic**: `string` +- **Non-empty**: `non-empty-string` +- **Numeric**: `numeric-string` +- **Class Names**: `class-string`, `class-string` + +### Integer Types +- **Basic**: `int` +- **Positive**: `positive-int` (> 0) +- **Negative**: `negative-int` (< 0) +- **Ranges**: `int<0, 100>`, `int` + +### Union and Intersection Types +- **Union**: `Type1|Type2|Type3` +- **Intersection**: `Type1&Type2` +- **Nullable**: `?Type` (equivalent to `Type|null`) + +### Conditional Types +- **Syntax**: `@return ($param is true ? TypeA : TypeB)` +- **Complex**: `@return ($param is positive-int ? non-empty-array : array)` + +### Callable Types +- **Basic**: `callable` +- **With Signature**: `callable(int, string): bool` +- **Array Callable**: `array{object, string}` for `[$object, 'method']` + +## WordPress Integration Patterns + +### WordPress Core Objects +- **Posts**: `WP_Post`, `array` +- **Users**: `WP_User`, `array` +- **Terms**: `WP_Term`, `array` +- **Queries**: `WP_Query`, `WP_User_Query`, `WP_Term_Query` +- **Errors**: `WP_Error` + +### WordPress Query Patterns +```php +/** + * WordPress query method with conditional return + * @param string|array $post_type Single type or array of types + * @param array $args Query arguments + * @param bool $include_total Whether to include total count + * @return ($include_total is true ? array{ + * items: array, + * total_count: int + * } : array) + */ +``` + +### WordPress Collection Patterns +- **Post Collections**: `array` for ID => title mapping (keys are important) +- **User Collections**: `array` for ID => display_name mapping (keys are important) +- **Term Collections**: `array` for ID => name mapping (keys are important) +- **Meta Arrays**: `array` for metadata (keys are important) +- **Simple Lists**: `string[]` for simple string lists, `int[]` for ID lists + +### WordPress Error Patterns +- **Functions that can fail**: `Type|false` +- **WP_Error returns**: `Type|WP_Error` +- **Upload functions**: `array{basedir: string, baseurl: string}|false` + +## Type Selection Decision Matrix + +### Complexity Levels + +#### Level 1-3 (Basic Compliance) +- Add missing primitive types: `string`, `int`, `bool`, `array` +- Simple union types: `string|int`, `array|false` +- Basic array types: `string[]`, `int[]` (prefer shorthand) + +#### Level 4-5 (Intermediate Compliance) +- Generic arrays with proper key/value types +- WordPress object types: `WP_Post`, `WP_User`, `WP_Term` +- Simple array shapes: `array{key: type}` +- Non-empty variants where appropriate + +#### Level 6-7 (Advanced Compliance) +- Conditional return types for parameter-dependent returns +- Complex array shapes with nested structures +- Advanced string/int types: `non-empty-string`, `positive-int` +- Precise WordPress collections and error handling + +### Array Shape Formatting Rules + +#### Single-line (≀ 2-3 keys) +```php +array{items: array, total_count: int} +``` + +#### Multi-line (β‰₯ 3-4 keys or complex nesting) +```php +array{ + items: array, + total_count: int, + page_info: array{ + current: int, + total: int, + has_more: bool + }, + metadata: array +} +``` + +#### Complex Conditional Returns +```php +@return ($include_total is true ? array{ + items: array, + total_count: int, + pagination: array{current: int, total: int} +} : array) +``` + +## Workflow Process + +### 1. Analysis Phase +```bash +# Run PHPStan to identify current issues +php -d memory_limit=512M vendor/bin/phpstan analyse path/to/file.php --level=6 +``` + +**Analysis Steps:** +1. Parse existing docblocks and extract current type information +2. Examine method signatures and default values +3. Analyze method implementations to understand return patterns +4. Identify conditional logic that affects return types +5. Cross-reference with WordPress/plugin APIs + +### 2. Type Inference Engine +**Parameter Analysis:** +- Extract types from default values: `$param = []` β†’ `array` +- Analyze usage patterns within method body +- Check parameter validation and sanitization + +**Return Type Analysis:** +- Map all return statements and their patterns +- Identify conditional returns based on parameters +- Analyze array construction patterns for shapes +- Cross-reference with WordPress function return types + +**WordPress Integration:** +- Recognize WordPress query patterns +- Identify WordPress object usage +- Map collection construction patterns + +### 3. Type Selection Logic +```php +// Decision flowchart: +if (method_has_conditional_logic_based_on_param) { + use_conditional_return_type(); +} elseif (returns_structured_array_with_known_keys) { + use_array_shape(); +} elseif (returns_wordpress_objects) { + use_specific_wordpress_types(); +} else { + use_generic_array_types(); +} +``` + +### 4. Application Phase +**Progressive Enhancement:** +1. Start with simple methods (clear parameter/return types) +2. Add array value specifications +3. Apply array shapes for structured data +4. Implement conditional return types +5. Add WordPress-specific object types + +**Quality Assurance:** +- Run PHPStan after each method update +- Verify error count decreases without new errors +- Confirm type precision improves IDE support + +## Common Error Patterns and Solutions + +### Missing Parameter Types +**Error**: `missingType.parameter` +```php +// Before - Missing type +public static function method($param) {} + +// After - Use specific type when possible +/** + * @param string[] $param List of option names + */ +public static function method($param) {} + +// After - Use mixed only when truly needed +/** + * @param array $param Dynamic plugin hook data + */ +public static function method($param) {} +``` + +### Missing Return Types +**Error**: `missingType.return` +```php +// Before - Missing return type +public static function method() {} + +// After - Use simple syntax when possible +/** + * @return string[] List of option names + */ +public static function method() {} + +// After - Use complex syntax when keys matter +/** + * @return array Post ID => title mapping + */ +public static function method() {} +``` + +### Unspecified Array Value Types +**Error**: `missingType.iterableValue` +```php +// Before - Unspecified array +/** + * @return array + */ + +// After - Simple list (prefer shorthand) +/** + * @return string[] List of option names + */ + +// After - Associative array (keys matter) +/** + * @return array Post ID => title mapping + */ + +// After - Array Shape (structured data) +/** + * @return array{items: string[], total_count: int} + */ +``` + +### Structure-Preserving Methods (Use Template Types) +```php +/** + * Filter method that preserves input array structure + * + * @template T of array + * @param T $array Input array to filter + * @return T Filtered array with same structure + */ +public static function filter_null($array) { + // Preserves whatever structure was passed in +} + +/** + * Sort method that preserves input array structure + * + * @template T of array + * @param T $array Input array to sort + * @return T Sorted array with same structure + */ +public static function sort($array) { + // Preserves whatever structure was passed in +} +``` + +### WordPress Query Method Pattern +```php +/** + * Query method with conditional return based on parameter + * + * @param string|string[] $type + * @param array $args + * @param bool $include_total + * @return ($include_total is true ? array{ + * items: array, + * total_count: int + * } : array) + */ +public static function selectlist_query($type, $args = [], $include_total = false) { + // Implementation analyzes this pattern: + // if ($include_total) return ['items' => $items, 'total_count' => $total]; + // return $items; +} +``` + +## Validation and Debugging Framework + +### Progressive PHPStan Validation +```bash +# Test each level incrementally +for level in {1..7}; do + echo "Testing PHPStan level $level..." + php -d memory_limit=512M vendor/bin/phpstan analyse classes/Helpers.php --level=$level +done +``` + +### Type Debugging Techniques +```php +// Use PHPStan's debug functions (remove before production) +\PHPStan\dumpType($variable); // Shows inferred type +``` + +### Error Tracking +**Before Changes:** +- Document current error count by level +- Identify specific error types and locations +- Plan minimal changes to achieve compliance + +**After Changes:** +- Verify error reduction at target level +- Ensure no regression at lower levels +- Confirm IDE type inference improvements + +## WordPress-Specific Method Patterns + +### Upload Directory Methods +```php +/** + * @param string $path + * @return string|false + * @deprecated Use WordPress core function instead + */ +public static function upload_dir_url($path = '') {} + +/** + * @return array{basedir: string, baseurl: string}|false + */ +public static function get_upload_dir() {} +``` + +### Query Collection Methods +```php +/** + * @param array $args + * @return array Post ID => title mapping + */ +public static function popup_selectlist($args = []) {} + +/** + * @return array Theme ID => title mapping + */ +public static function popup_theme_selectlist() {} +``` + +### Array Utility Methods +```php +/** + * @param array $a + * @param array $b + * @return int + * @deprecated Use PUM_Utils_Array::sort_by_priority instead + */ +public static function sort_by_priority($a, $b) {} +``` + +## Advanced PHPStan Features + +### Assertion Types +```php +/** + * @param mixed $value + * @phpstan-assert string $value + */ +function assertString($value): void {} +``` + +### Template Types (Generics) +```php +/** + * @template T + * @param T $value + * @return T + */ +function identity($value) { return $value; } +``` + +### Type Aliases +```php +/** + * @phpstan-type QueryResult array{items: array, total_count: int} + * @return QueryResult + */ +``` + +## Implementation Constraints + +### Safety Requirements +- **Never modify method logic** - docblocks only +- **Maintain PHP 7.4+ compatibility** - avoid newer syntax features +- **Preserve existing comments** - enhance, don't replace +- **Follow WordPress coding standards** - proper indentation and formatting + +### Quality Standards +- **Use PHPStan validation** after every change +- **Keep spaces in array shapes** for readability over syntax highlighting +- **Use multi-line for complex types** to improve maintainability +- **Progressive complexity** - start simple, add advanced features incrementally + +### Documentation Standards +- Preserve existing `@since`, `@deprecated`, `@see` tags +- Add descriptive text for complex types +- Maintain consistent formatting with existing codebase +- Include parameter descriptions where helpful + +Your role is to systematically improve PHPStan compliance through careful, conservative docblock enhancements that provide valuable type information without risking any functional changes. Focus on achieving level 6-7 compliance using the most appropriate PHPStan type features for each specific case. + +## CRITICAL RESTRICTIONS + +### What You CAN Modify: +- /** */ docblock comments ONLY +- @param, @return, @var, @template annotations +- @throws, @since, @deprecated tags +- Docblock descriptions and explanations + +### What You CANNOT Modify: +- Method signatures or implementations +- Variable assignments ($var = value) +- Function calls or method calls +- Conditional statements (if, switch, etc.) +- Loops (for, foreach, while, etc.) +- Type casts: (string), (int), (array), etc. +- Class properties or constants +- Anything outside /** */ comment blocks + +### TOOL ENFORCEMENT: +**MUST use MultiEdit tool exclusively** - Edit tool is forbidden per AGENTS.md multi-file output system requirements.''' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a869df5d4..03f17ed56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,9 +66,12 @@ jobs: - name: Generate build version id: version + env: + # Passed via env (not interpolated into the script) so crafted input + # values cannot break out of the assignment and run shell commands. + REF_NAME: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'develop' }} + VERSION_SUFFIX: ${{ github.event.inputs.version_suffix }} run: | - REF_NAME="${{ github.event.inputs.ref || github.event.client_payload.ref || 'develop' }}" - VERSION_SUFFIX="${{ github.event.inputs.version_suffix }}" TIMESTAMP=$(date +%Y%m%d-%H%M%S) if [ -n "$VERSION_SUFFIX" ]; then @@ -556,6 +559,11 @@ jobs: # We'll provide clear instructions to the artifacts section instead - name: Generate summary + env: + # User-controlled dispatch input β€” pass via env so it cannot break out + # of the summary shell script and execute commands. + SOURCE_REF: ${{ github.event.inputs.ref || 'develop' }} + CHANGELOG_CONTENT: ${{ needs.build.outputs.changelog_content }} run: | BUILD_VERSION="${{ needs.validate.outputs.build_version }}" PLUGIN_INFO='${{ needs.validate.outputs.plugin_info }}' @@ -564,7 +572,7 @@ jobs: echo "# πŸ”¨ Test Build Summary for ${PLUGIN_NAME}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${BUILD_VERSION}" >> $GITHUB_STEP_SUMMARY - echo "**Source:** ${{ github.event.inputs.ref || 'develop' }}" >> $GITHUB_STEP_SUMMARY + echo "**Source:** ${SOURCE_REF}" >> $GITHUB_STEP_SUMMARY echo "**Requested by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -627,13 +635,13 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "## πŸ“ Build Information" >> $GITHUB_STEP_SUMMARY - echo "**Source:** [\`${{ github.event.inputs.ref || 'develop' }}\`](https://github.com/${{ github.repository }}/tree/${{ github.event.inputs.ref || 'develop' }})" >> $GITHUB_STEP_SUMMARY + echo "**Source:** [\`${SOURCE_REF}\`](https://github.com/${{ github.repository }}/tree/${SOURCE_REF})" >> $GITHUB_STEP_SUMMARY echo "**Commit:** [\`${{ needs.build.outputs.commit_short }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.build.outputs.commit_hash }})" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Recent Changes:**" >> $GITHUB_STEP_SUMMARY - echo "${{ needs.build.outputs.changelog_content }}" >> $GITHUB_STEP_SUMMARY + echo "${CHANGELOG_CONTENT}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "πŸ“– **[View Full Changelog](https://github.com/${{ github.repository }}/blob/${{ github.event.inputs.ref || 'develop' }}/CHANGELOG.md)**" >> $GITHUB_STEP_SUMMARY + echo "πŸ“– **[View Full Changelog](https://github.com/${{ github.repository }}/blob/${SOURCE_REF}/CHANGELOG.md)**" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "## ℹ️ Note" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c9ec8ad7..77d42330f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,6 +456,7 @@ jobs: steps: - name: Check CI results env: + DETECT_RESULT: ${{ needs.detect-changes.result }} PHP_QUALITY_RESULT: ${{ needs.php-quality.result }} JS_QUALITY_RESULT: ${{ needs.js-quality.result }} BUILD_RESULT: ${{ needs.build-validation.result }} @@ -479,6 +480,15 @@ jobs: esac } + # If change detection didn't succeed, downstream jobs skip and would + # look like passes β€” fail the gate on anything but success/skipped. + case "$DETECT_RESULT" in + success) echo "Change Detection: PASSED" ;; + skipped) echo "Change Detection: SKIPPED (non-PR event)" ;; + failure|cancelled) echo "Change Detection: $DETECT_RESULT"; failed=1 ;; + *) echo "Change Detection: $DETECT_RESULT"; failed=1 ;; + esac + check_result "PHP Code Quality" "$PHP_QUALITY_RESULT" check_result "JS/TS Code Quality" "$JS_QUALITY_RESULT" check_result "Build Validation" "$BUILD_RESULT" diff --git a/.github/workflows/deploy-readme-assets.yml b/.github/workflows/deploy-readme-assets.yml index 445c8c891..f974018e7 100644 --- a/.github/workflows/deploy-readme-assets.yml +++ b/.github/workflows/deploy-readme-assets.yml @@ -7,9 +7,15 @@ jobs: update: name: Update WordPress.org readme & assets runs-on: ubuntu-latest + # Only run against the protected master branch. A manual dispatch on any + # other ref must not be able to sync unreviewed content β€” or a malicious + # branch copy of this workflow β€” using the WordPress.org SVN credentials. + if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v6 + with: + ref: master - uses: 10up/action-wordpress-plugin-asset-update@stable env: diff --git a/.github/workflows/deploy-to-wordpress.yml b/.github/workflows/deploy-to-wordpress.yml index 3e64a00a3..1375f4f1a 100644 --- a/.github/workflows/deploy-to-wordpress.yml +++ b/.github/workflows/deploy-to-wordpress.yml @@ -33,8 +33,18 @@ jobs: id: flags env: DRY_RUN_INPUT: ${{ inputs.dry_run }} + GITHUB_REF: ${{ github.ref }} run: | - echo "dry_run=${DRY_RUN_INPUT:-false}" >> $GITHUB_OUTPUT + DRY_RUN="${DRY_RUN_INPUT:-false}" + + # Only the protected master branch may perform a real deploy. + # Any other ref (e.g. a manually dispatched branch) is forced to dry-run. + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "⚠️ Ref is ${GITHUB_REF}, not refs/heads/master β€” forcing dry-run." + DRY_RUN="true" + fi + + echo "dry_run=${DRY_RUN}" >> $GITHUB_OUTPUT - name: Extract version from plugin header id: version @@ -43,6 +53,9 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "πŸ“¦ Version: ${VERSION}" + # Primary path: deploy the published release asset that was built and + # tested during the release. Fall back to building from source only when + # the asset is missing. - name: Try downloading release zip id: download env: @@ -66,7 +79,7 @@ jobs: echo "found=false" >> $GITHUB_OUTPUT fi - # --- Path A: Extract release zip into BUILD_DIR --- + # --- Path A: extract the release zip into BUILD_DIR --- - name: Extract release zip if: steps.download.outputs.found == 'true' env: @@ -77,7 +90,7 @@ jobs: echo "βœ… Extracted to ${{ env.BUILD_DIR }}" ls -la "${{ env.BUILD_DIR }}" - # --- Path B: Build from source into BUILD_DIR --- + # --- Path B: build from source into BUILD_DIR (fallback) --- - name: Setup PHP if: steps.download.outputs.found != 'true' uses: shivammathur/setup-php@v2 @@ -111,7 +124,11 @@ jobs: mkdir -p "${{ env.BUILD_DIR }}" if [ -f "bin/build-release.js" ]; then - node bin/build-release.js --output-dir "${{ env.BUILD_DIR }}" + # --output-dir only controls where the zip lands; the SVN deploy + # needs the unpacked plugin tree. Keep the assembled build/ tree + # (renamed to ./popup-maker) and copy its contents into BUILD_DIR. + node bin/build-release.js --keep-build + cp -r popup-maker/. "${{ env.BUILD_DIR }}/" else # Manual copy matching what build-release.js produces. cp -r classes "${{ env.BUILD_DIR }}/" diff --git a/.github/workflows/update-google-fonts.yml b/.github/workflows/update-google-fonts.yml new file mode 100644 index 000000000..2ed109efc --- /dev/null +++ b/.github/workflows/update-google-fonts.yml @@ -0,0 +1,58 @@ +name: Update Google Fonts + +# Refreshes includes/google-fonts.json and opens a PR with any changes. Kept out +# of the release pipeline so releases stay reproducible and font changes are +# reviewable. Requires the GOOGLE_FONTS_API_KEY repository secret. + +on: + schedule: + # 06:00 UTC on the 1st of each month. + - cron: '0 6 1 * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-google-fonts: + name: Update Google Fonts data + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Update Google Fonts JSON + env: + GOOGLE_FONTS_API_KEY: ${{ secrets.GOOGLE_FONTS_API_KEY }} + run: pnpm run fonts:update + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v6 + with: + commit-message: 'chore(fonts): refresh Google Fonts data' + branch: chore/update-google-fonts + delete-branch: true + title: 'chore(fonts): refresh Google Fonts data' + body: | + Automated refresh of `includes/google-fonts.json` from the Google Fonts API. + + Review the diff for added/removed fonts before merging. If there are no + changes, this PR will not be created. + labels: | + dependencies + automated + add-paths: | + includes/google-fonts.json diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index 911c47a83..46ffa8b59 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -11,6 +11,12 @@ namespace PHPSTORM_META; +// IDE-only file; the pseudo-functions below fatal if executed. IDEs parse it +// statically, so bailing at runtime is safe and avoids a path-disclosing error. +if ( ! defined( 'ABSPATH' ) ) { + return; +} + /** * Provide autocompletion for plugin container access. * diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..da7829e50 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,493 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with the Popup Maker WordPress plugin. + +## Important +- ALL instructions within this document MUST BE FOLLOWED, these are not optional unless explicitly stated. +- ASK FOR CLARIFICATION If you are uncertain of any of thing within the document. +- DO NOT edit more code than you have to. +- DO NOT WASTE TOKENS, be succinct and concise. + +## Project Overview + +Popup Maker is a mature WordPress plugin for creating popups. It uses both legacy PHP and modern React components in a monorepo structure. + +## Quick Start Commands + +This repo uses **pnpm** (v10+). `npm install` is blocked by a `preinstall` guard β€” use `pnpm` for all JS commands. Install pnpm with `npm i -g pnpm` or enable corepack. + +```bash +# Setup +pnpm install && composer install + +# Development +pnpm start # Watch mode +pnpm run start:hot # Hot module replacement +pnpm run build # Development build +pnpm run build:production # Production build + +# Testing +pnpm run test:e2e # Playwright E2E tests +pnpm run test:e2e:debug # Debug mode with UI +pnpm run test:unit # Jest unit tests +composer run tests # PHPUnit tests +composer run coverage # Test coverage + +# Code Quality +pnpm run lint:js # ESLint +pnpm run lint:style # Stylelint +pnpm run format # Prettier +composer run lint # PHPCS +composer run format # PHPCBF +composer run phpstan # Static analysis +``` + +## Architecture + +### PHP Structure (PSR-4: `PopupMaker\`) + +- **Modern**: `classes/` - Namespaced classes (Repository, Model, Service patterns) +- **Legacy**: `includes/` - Backward-compatible functions, some namespaced functions within `includes/namespaced/` +- **Service Container**: Pimple for dependency injection + +### JavaScript/TypeScript + +- **Modern**: `packages/` - Monorepo with `@popup-maker/*` packages +- **Legacy**: `assets/js/src/` - jQuery-based code +- **Build**: Webpack β†’ `dist/` + +### Key Packages + +- `core-data` - Data stores (popups, CTAs, settings) +- `fields` - Form field components +- `cta-admin` / `cta-editor` - CTA interfaces +- `block-editor` - Gutenberg integration + +## Extension APIs + +### Custom Triggers + +```php +add_filter('pum_registered_triggers', function($triggers) { + $triggers['scroll_percentage'] = [ + 'name' => __('Scroll Percentage', 'my-plugin'), + 'modal_title' => __('Scroll Trigger Settings', 'my-plugin'), + 'settings_column' => sprintf('%1$s: %2$s%%', __('Percentage', 'my-plugin'), '{{data.percentage}}'), + 'fields' => [ + 'general' => [ + 'percentage' => [ + 'label' => __('Scroll Percentage', 'my-plugin'), + 'type' => 'rangeslider', + 'min' => 0, + 'max' => 100, + 'step' => 5, + 'default' => 50, + ], + ], + ], + ]; + return $triggers; +}); +``` + +### Custom Conditions + +```php +// PHP-evaluated condition +add_filter('pum_registered_conditions', function($conditions) { + $conditions['user_membership'] = [ + 'name' => __('User Membership Level', 'my-plugin'), + 'group' => __('User', 'my-plugin'), + 'callback' => 'my_plugin_check_membership_condition', // PHP callback + 'fields' => [ + 'level' => [ + 'label' => __('Membership Level', 'my-plugin'), + 'type' => 'select', + 'options' => [ + 'basic' => __('Basic', 'my-plugin'), + 'premium' => __('Premium', 'my-plugin'), + ], + ], + ], + ]; + return $conditions; +}); + +function my_plugin_check_membership_condition($settings) { + $user_level = get_user_meta(get_current_user_id(), 'membership_level', true); + return $user_level === $settings['level']; +} + +// JavaScript-evaluated condition +$conditions['device_orientation'] = [ + 'name' => __('Device Orientation', 'my-plugin'), + 'advanced' => true, // Mark for JavaScript evaluation + // No callback - handled in JavaScript +]; +``` + +**Note**: Conditions with `'advanced' => true` or no `'callback'` are evaluated client-side with a matching global window[function_name] function. + +### Custom CTAs + +```php +namespace MyPlugin\CallToAction; + +use PopupMaker\Base\CallToAction; +use PopupMaker\Models\CallToAction as CTAModel; + +class MyCustomCTA extends CallToAction { + + public $key = 'my_custom_cta'; + + public function label(): string { + return __('My Custom CTA', 'my-plugin'); + } + + public function fields(): array { + return [ + 'general' => [ + 'redirect_url' => [ + 'type' => 'url', + 'label' => __('Redirect URL', 'my-plugin'), + 'required' => true, + 'dependencies' => [ + 'type' => 'my_custom_cta', + ], + ], + ], + ]; + } + + public function action_handler(CTAModel $call_to_action, array $extra_args = []): void { + // Always track conversion first + $call_to_action->track_conversion($extra_args); + + // Perform your action + $redirect_url = $call_to_action->get_setting('redirect_url'); + $this->safe_redirect($redirect_url); + exit; + } + + public function validate_settings(array $settings): \WP_Error|array|bool { + // Validate required fields + $validation = $this->validate_required_fields($settings); + if (is_wp_error($validation)) { + return $validation; + } + + // Custom validation + if (!filter_var($settings['redirect_url'], FILTER_VALIDATE_URL)) { + return new \WP_Error('invalid_url', __('Invalid URL', 'my-plugin')); + } + + return true; + } +} + +// Register CTA +add_filter('popup_maker/registered_call_to_actions', function($ctas) { + $ctas['my_custom_cta'] = new \MyPlugin\CallToAction\MyCustomCTA(); + return $ctas; +}); +``` + +### Asset Caching System + +```php +// Frontend assets (will be cached) +add_action('pum_enqueue_scripts', function() { + pum_enqueue_script( + 'my-plugin-frontend', + plugins_url('/js/frontend.js', __FILE__), + ['popup-maker-site'], + '1.0.0' + ); + + pum_enqueue_style( + 'my-plugin-frontend', + plugins_url('/css/frontend.css', __FILE__), + [], + '1.0.0' + ); +}); + +// Admin assets (not cached) +add_action('admin_enqueue_scripts', function() { + wp_enqueue_script( + 'my-plugin-admin', + plugins_url('/js/admin.js', __FILE__), + ['jquery'], + '1.0.0' + ); +}); +``` + +**AssetCache Details**: + +- Combined files: `pum-site-scripts.js` & `pum-site-styles.css` +- Location: `wp-content/uploads/pum/` +- Priority: 0=core, 1-5=extensions, 10=default, 15-20=per-popup +- Auto-regenerates on popup/theme changes + +## Frontend JavaScript APIs + +### Form Integration + +```javascript +( function ( $ ) { + $( document ).on( 'my_form_success', function ( event, formData ) { + const $form = $( event.target ); + + if ( window.PUM && window.PUM.integrations ) { + window.PUM.integrations.formSubmission( $form, { + formProvider: 'my-form-plugin', + formId: $form.data( 'form-id' ), + formInstanceId: $form.data( 'instance-id' ), + extras: { + formData: formData, + }, + } ); + } + } ); +} )( jQuery ); +``` + +### Custom Trigger (Frontend) + +```javascript +( function ( $, PUM ) { + PUM.hooks.addFilter( 'popupMaker.triggers', function ( triggers ) { + triggers.scroll_percentage = function ( settings, popup ) { + const percentage = parseInt( settings.percentage, 10 ); + + function checkScroll() { + const scrollPercent = Math.round( + ( $( window ).scrollTop() / + ( $( document ).height() - $( window ).height() ) ) * + 100 + ); + + if ( scrollPercent >= percentage ) { + PUM.open( popup.id ); + $( window ).off( 'scroll', checkScroll ); + } + } + + $( window ).on( 'scroll', checkScroll ); + }; + + return triggers; + } ); +} )( jQuery, window.PUM ); +``` + +## React/TypeScript APIs + +### Data Stores + +```typescript +import { + popupStore, + callToActionStore, + settingsStore, +} from '@popup-maker/core-data'; +import { useSelect, useDispatch } from '@wordpress/data'; + +// Popup Store +const { popups, popup } = useSelect( ( select ) => { + const store = select( popupStore ); + return { + popups: store.getPopups(), + popup: store.getPopup( 123 ), + isLoading: store.isResolving( 'getPopup', [ 123 ] ), + hasEdits: store.hasEdits( 123 ), + }; +} ); + +const { createPopup, updatePopup, deletePopup, undo, redo } = + useDispatch( popupStore ); + +// Settings Store (with custom hook) +import { useSettings } from '@popup-maker/core-data'; + +const { settings, getSetting, updateSettings, saveSettings } = useSettings(); +``` + +### Store Methods Reference + +**Popup Store** + +- Selectors: `getPopups()`, `getPopup(id)`, `hasEdits(id)`, `hasUndo(id)`, `hasRedo(id)` +- Actions: `createPopup()`, `updatePopup()`, `deletePopup()`, `editRecord()`, `saveEditedRecord()`, `undo()`, `redo()` + +**CTA Store** + +- Selectors: `getCallToActions()`, `getCallToAction(id)`, `isEditingCallToAction(id)` +- Actions: `createCallToAction()`, `updateCallToAction()`, `deleteCallToAction()` + +## Field System + +### Field Types + +- **Text**: `text`, `email`, `url`, `password`, `hidden` +- **Numeric**: `number`, `range`, `rangeslider`, `measure` +- **Selection**: `select`, `radio`, `checkbox`, `multicheck` +- **WordPress**: `postselect`, `taxonomyselect`, `objectselect` +- **Special**: `textarea`, `button`, `heading`, `html`, `hook`, `license_key` + +### Field Properties + +```php +[ + // Required + 'id' => 'field_id', + 'type' => 'text', + 'label' => __('Label', 'text-domain'), + + // Common + 'desc' => __('Help text', 'text-domain'), + 'std' => 'default value', + 'placeholder' => 'Enter value...', + 'required' => true, + + // Type-specific + 'min' => 0, // number, range, rangeslider + 'max' => 100, // number, range, rangeslider + 'step' => 5, // number, range, rangeslider + 'options' => [], // select, radio, multicheck + 'multiple' => true, // select, postselect + 'post_type' => 'post', // postselect + 'taxonomy' => 'tag', // taxonomyselect +] +``` + +### Extension Development Best Practices +- Use `pum_` prefixes for all public functions and hooks +- Use custom `\PopupMaker\{ExtensionName}\` namespace for classes/functions/hooks +- Check for Popup Maker existence before calling functions +- Follow WordPress coding standards (enforced by PHPCS) +- Use dependency injection over global access +- **Prioritize `@popup-maker/*` packages over `@wordpress/*` when available** (better optimization and consistency) +- Use Popup Maker's data stores (`popupStore`, `callToActionStore`) instead of WordPress core data when possible +- Use PUM.hooks system for frontend extensibility +- Follow `.cursor/rules/pm-best-practices.mdc` guidelines + +## Testing Strategy + +### E2E Tests +- Playwright tests in `tests/e2e/` +- Test popup functionality and admin workflows +- Run against local WordPress environment + +### Unit Tests +- Jest for JavaScript/TypeScript in `tests/unit/` +- PHPUnit for PHP in `tests/php/tests/` +- Mockery for PHP mocking + +### Code Quality +- PHPStan for static analysis +- PHPCS for WordPress coding standards +- ESLint for JavaScript/TypeScript +- Stylelint for CSS/SCSS + +### WordPress + +- Use `%i` for the table name in $wpdb prepared queries. + +### PHP + +DO NOT FORGET: +- Inline comments must end in full-stops, exclamation marks, or question marks (Squiz.Commenting.InlineComment.InvalidEndChar) +- **NEVER use strict PHP types on methods hooked to third-party actions/filters** - Use defensive validation instead + +#### Defensive Hook Callbacks + +When hooking methods to third-party WordPress actions/filters, avoid strict PHP type declarations. Third-party plugins can change their hook signatures causing fatal errors. + +**❌ Brittle (Will break on EDD/WC updates):** +```php +public function my_callback( int $id, string $status ): void { + // Fatal error if plugin passes wrong types +} +``` + +**βœ… Defensive (Survives plugin updates):** +```php +public function my_callback( $id, $status = '' ) { + // Validate inputs gracefully. + if ( ! is_numeric( $id ) || ! is_string( $status ) ) { + return; // Silent failure, no site breakage + } + + $id = (int) $id; // Safe conversion + // Rest of logic... +} +``` + +**Pattern for all third-party hooks:** +- Validate inputs at method start with early returns +- Use safe type conversion: `(int)`, `(string)`, `(array)` +- Provide sensible defaults for optional parameters +- Never assume third-party parameter structure/types + +#### PHP 7.4 Compatibility Requirements + +**CRITICAL**: All code must be compatible with PHP 7.4+ for production releases. + +**❌ Forbidden (PHP 8.0+ only):** +- Union types: `string|int`, `array|null`, `object|string` +- Mixed type: `mixed` (introduced in PHP 8.0) +- Named arguments: `func(param: $value)` +- Match expressions: `match($value) { ... }` + +**βœ… Safe for PHP 7.4:** +- Nullable types: `?string`, `?array`, `?object` +- Standard types: `string`, `int`, `array`, `object`, `bool` +- Elvis operator: `$value ?: 'default'` +- Null coalescing: `$value ?? 'default'` +- Null coalescing assignment: `$value ??= 'default'` (PHP 7.4+) + +**Emergency Fix Pattern:** +If union types are accidentally introduced and cause crashes: +```regex +# Remove all union return types +Find: \):\s*[a-zA-Z_\\]+(?:\|[a-zA-Z_\\]+)+ +Replace: ) + +# Remove all union parameter types +Find: ([,(]\s*)[a-zA-Z_\\]+(?:\|[a-zA-Z_\\]+)+(\s+)(\$\w+) +Replace: $1$3 +``` + +### JavaScript + +DO NOT FORGET: +- No `any` type, only use `unknown` for dynamic values outside of the type system, if we can find the type in another package, or define it reasonably for our own internal use, then we do that instead of `unknown` + +## Dependency Management + +### PHP Dependencies +- Composer with vendor prefixing via Strauss +- Dependencies compiled to `vendor-prefixed/` with `PopupMaker\Vendor\` namespace +- Run `composer install` to set up prefixed dependencies + +### JavaScript Dependencies +- NPM workspace for monorepo packages +- WordPress scripts for build tooling +- Custom webpack plugins for asset optimization + +## Legacy Considerations + +- `includes/` contains legacy functions for backwards compatibility +- Gradual migration from jQuery to React for admin interfaces +- `popmake_` prefixed functions are deprecated +- Asset cache system maintains compatibility with older extensions + +## Workflow Notes + +### Package Management Considerations +- When adding new packages, we have to update webpack config, tsconfigs, dependency extraction plugin package list AND Assets.php appropriately + +- Let services handle their own business logic - delegate with single method + calls rather than orchestrating multiple service operations when it makes since. diff --git a/CHANGELOG.md b/CHANGELOG.md index ae295d954..288566af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,23 @@ ## Unreleased +**Security** + +- Security and resilience hardening based on continuous AI scanning. + +**Features** + +- Added an "Open Popup" click action to the Beaver Builder Button module, so you can open any popup on click without writing custom code. The chosen popup is automatically loaded on the page even if its display conditions wouldn't otherwise match. + +**Improvements** + +- Corrected the plural of "Call to Action" to "Calls to Action" across the admin. Thanks @swinggraphics. + **Fixes** +- Fixed Gravity Forms inside popups no longer submitting via AJAX (regression since 1.21.0), which broke text confirmations and Form Submission triggers after submit. +- Improved keyboard focus when closing popups: focus returns to the previously focused element, or to the top of the document when there wasn't one (auto-open popups) or it no longer exists. Thanks @swinggraphics. +- Fixed Click Open "Extra Selectors" starting with a number (e.g. `.2026-selector`) being rejected as invalid. Selectors are now validated against jQuery's engine, which handles the actual matching. - Fixed a bug where a popup could unexpectedly lose its triggers, conditions, and display settings β€” leaving only its theme β€” after saving, a plugin update, or when another plugin or page builder saved the popup. Saves that would erase an existing popup's settings are now prevented and report an error instead of silently discarding your configuration. ## v1.23.0 - 2026-06-28 diff --git a/assets/js/src/admin/general/vendor/select2.full.custom.js b/assets/js/src/admin/general/vendor/select2.full.custom.js index b970decf2..2db1ebfc1 100644 --- a/assets/js/src/admin/general/vendor/select2.full.custom.js +++ b/assets/js/src/admin/general/vendor/select2.full.custom.js @@ -6797,10 +6797,11 @@ setup: function () { if ( this.addEventListener ) { for ( var i = toBind.length; i; ) { - // Add passive option for wheel events + // Wheel handler calls preventDefault at scroll + // boundaries, so it must be non-passive. var options = toBind[ --i ] === 'wheel' - ? { passive: true } + ? { passive: false } : false; this.addEventListener( diff --git a/assets/js/src/admin/settings-page/pro-upgrade-flow.js b/assets/js/src/admin/settings-page/pro-upgrade-flow.js index ec283ebce..c923adbab 100644 --- a/assets/js/src/admin/settings-page/pro-upgrade-flow.js +++ b/assets/js/src/admin/settings-page/pro-upgrade-flow.js @@ -118,6 +118,8 @@ 'Invalid connection parameters. Please try again.' ); this.closePopup(); + // Re-enable the button so the user can retry. + $button.prop( 'disabled', false ); return; } diff --git a/assets/js/src/integration/beaverbuilder.js b/assets/js/src/integration/beaverbuilder.js index 09b9c2f53..36fbacaa8 100644 --- a/assets/js/src/integration/beaverbuilder.js +++ b/assets/js/src/integration/beaverbuilder.js @@ -8,13 +8,29 @@ // Hook into jQuery AJAX complete for all Beaver Builder forms. $( document ).on( 'ajaxComplete', function ( _event, xhr, settings ) { + const requestData = settings.data; + let params; + + if ( + typeof requestData === 'string' || + requestData instanceof URLSearchParams + ) { + params = new URLSearchParams( requestData ); + } else if ( + window.FormData && + requestData instanceof window.FormData + ) { + params = new URLSearchParams( requestData ); + } else { + return; + } + + const action = params.get( 'action' ); + // Check if this is a Beaver Builder form submission. if ( - ! settings.data || - ( settings.data.indexOf( 'action=fl_builder_email' ) === -1 && - settings.data.indexOf( - 'action=fl_builder_subscribe_form_submit' - ) === -1 ) + action !== 'fl_builder_email' && + action !== 'fl_builder_subscribe_form_submit' ) { return; } @@ -39,7 +55,6 @@ } // Extract form type and node ID from AJAX data. - const params = new URLSearchParams( settings.data ); const nodeId = params.get( 'node_id' ); if ( ! nodeId ) { @@ -57,7 +72,6 @@ } // Determine form type from action. - const action = params.get( 'action' ); let formType = 'unknown'; if ( action === 'fl_builder_email' ) { formType = 'contact'; diff --git a/assets/js/src/integration/newsletter.js b/assets/js/src/integration/newsletter.js index ba7279683..566afc067 100644 --- a/assets/js/src/integration/newsletter.js +++ b/assets/js/src/integration/newsletter.js @@ -156,18 +156,16 @@ $popup.find( FORM_SELECTORS ).each( function () { const $form = $( this ); - if ( $form.find( 'input[name="pum_form_popup_id"]' ).length ) { - return; + if ( ! $form.find( 'input[name="pum_form_popup_id"]' ).length ) { + $form.append( + $( '', { + type: 'hidden', + name: 'pum_form_popup_id', + value: popupId, + } ) + ); } - $form.append( - $( '', { - type: 'hidden', - name: 'pum_form_popup_id', - value: popupId, - } ) - ); - // Set up observer for this form. observeForm( this, popupId ); } ); diff --git a/assets/js/src/site/plugins/pum-accessibility.js b/assets/js/src/site/plugins/pum-accessibility.js index ef6f48edb..def972ac4 100644 --- a/assets/js/src/site/plugins/pum-accessibility.js +++ b/assets/js/src/site/plugins/pum-accessibility.js @@ -125,8 +125,31 @@ var PUM_Accessibility; .attr( 'aria-modal', 'false' ); // Accessibility: Focus back on the previously focused element. - if ( previouslyFocused !== undefined && previouslyFocused.length ) { + if ( + previouslyFocused !== undefined && + previouslyFocused.length && + previouslyFocused.is( ':visible' ) + ) { previouslyFocused.trigger( 'focus' ); + } else { + // Nothing had focus before the popup opened (ex. auto-open + // triggers), or that element is gone. Move focus to + // so the next Tab starts from the top of the document + // instead of the popup's position at the end of the page. + var $body = $( 'body' ), + hadTabindex = undefined !== $body.attr( 'tabindex' ); + + if ( ! hadTabindex ) { + $body.attr( 'tabindex', '-1' ); + } + + $body.trigger( 'focus' ); + + // Only remove the attribute we added; leave any + // pre-existing tabindex untouched. + if ( ! hadTabindex ) { + $body.removeAttr( 'tabindex' ); + } } // Accessibility: Clears the currentModal var. diff --git a/assets/js/src/site/plugins/pum-integrations.js b/assets/js/src/site/plugins/pum-integrations.js index d77d530f1..c01d0163d 100644 --- a/assets/js/src/site/plugins/pum-integrations.js +++ b/assets/js/src/site/plugins/pum-integrations.js @@ -67,8 +67,8 @@ .join( '_' ); if ( args.popup && args.popup.length ) { - args.popupId = PUM.getSetting( $popup, 'id' ); - $popup.trigger( 'pumConversion' ); + args.popupId = PUM.getSetting( args.popup, 'id' ); + args.popup.trigger( 'pumConversion' ); // Should this be here. It is the only thing not replicated by a new form trigger & cookie. // $popup.trigger('pumFormSuccess'); } diff --git a/assets/js/src/site/plugins/pum.js b/assets/js/src/site/plugins/pum.js index 218d4b2a8..f29bf1db8 100644 --- a/assets/js/src/site/plugins/pum.js +++ b/assets/js/src/site/plugins/pum.js @@ -188,7 +188,17 @@ document.createDocumentFragment().querySelector( selector ); return true; } catch ( e ) { - return false; + // Some selectors are invalid CSS but accepted by jQuery's + // engine (e.g. classes starting with a digit like + // `.2026-selector`) and have always worked as click triggers. + // jQuery does the actual matching, so defer to it before + // rejecting; truly malformed selectors still throw here. + try { + $( document.createDocumentFragment() ).find( selector ); + return true; + } catch ( err ) { + return false; + } } }, getClickTriggerSelector: function ( el, trigger_settings ) { diff --git a/bin/build-release.js b/bin/build-release.js index c7af705d3..e79a3de32 100755 --- a/bin/build-release.js +++ b/bin/build-release.js @@ -286,6 +286,15 @@ class PluginReleaseBuilder { const zipName = this.options.zipFileName || `${ this.pluginName }_${ this.version }.zip`; + + // Reject anything but a plain zip filename. The name can originate from a + // git tag (via --zip-name) and is used in shell/path operations below. + if ( ! /^[A-Za-z0-9._-]+\.zip$/.test( zipName ) ) { + throw new Error( + `Refusing unsafe zip name: "${ zipName }". Expected [A-Za-z0-9._-] and a .zip extension.` + ); + } + const zipPath = path.join( this.outputDir, zipName ); // Move build directory to plugin name @@ -301,10 +310,14 @@ class PluginReleaseBuilder { `Creating latest zip file` ); - // Copy (cp) to versioned zip file - this.executeCommand( - `cp "${ this.pluginName }-latest.zip" "${ zipName }"`, - `Creating versioned zip file` + // Copy to versioned zip file. Use fs (not a shell) so a crafted zip name + // cannot break out of the command. + if ( ! this.options.quiet ) { + console.log( 'Creating versioned zip file...' ); + } + fs.copyFileSync( + path.join( this.projectRoot, `${ this.pluginName }-latest.zip` ), + path.join( this.projectRoot, zipName ) ); // Move zip to output directory if different from project root diff --git a/bin/bump-and-publish-packages.js b/bin/bump-and-publish-packages.js index e5cba4414..dde9c7f4f 100755 --- a/bin/bump-and-publish-packages.js +++ b/bin/bump-and-publish-packages.js @@ -16,7 +16,31 @@ const fs = require( 'fs' ); const path = require( 'path' ); -const { execSync } = require( 'child_process' ); +const { execSync, execFileSync } = require( 'child_process' ); + +// Valid npm names/semvers can't contain shell metacharacters; validate before +// interpolating a package manifest's values into any shell command. +const NPM_NAME_RE = + /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; +const SEMVER_RE = /^[0-9A-Za-z.+-]+$/; + +/** + * Assert a package name/version pair is safe to use in shell contexts. + * @param {string} name Package name. + * @param {string} version Package version. + * @return {void} + * @throws {Error} When either value is not a safe, expected token. + */ +function assertSafePackageIdentity( name, version ) { + if ( typeof name !== 'string' || ! NPM_NAME_RE.test( name ) ) { + throw new Error( `Refusing to proceed: unsafe package name "${ name }".` ); + } + if ( typeof version !== 'string' || ! SEMVER_RE.test( version ) ) { + throw new Error( + `Refusing to proceed: unsafe version "${ version }" for ${ name }.` + ); + } +} const minimist = require( 'minimist' ); const argv = minimist( process.argv.slice( 2 ) ); @@ -346,6 +370,9 @@ function main() { return; } + // Validate before any value reaches a shell command/script below. + updates.forEach( ( u ) => assertSafePackageIdentity( u.name, u.newVersion ) ); + // Commit changes log( '\nπŸ“ Committing changes...' ); const versionList = updates @@ -354,7 +381,20 @@ function main() { const commitMessage = `chore: bump package versions to ${ versionType }\n\n${ versionList }`; execCommand( 'git add packages/*/package.json' ); - execCommand( `git commit -m "${ commitMessage }"` ); + + // argv entry, not a shell string, so the message is never shell-interpreted. + if ( dryRun ) { + info( '[DRY RUN] Would execute: git commit -m ' ); + } else { + try { + execFileSync( 'git', [ 'commit', '-m', commitMessage ], { + encoding: 'utf8', + stdio: 'inherit', + } ); + } catch ( err ) { + throw new Error( `Command failed: git commit\n${ err.message }` ); + } + } success( 'βœ… Changes committed' ); @@ -406,8 +446,17 @@ function main() { ${ failedPackages .map( ( name ) => { const pkg = updates.find( ( u ) => u.name === name ); + const dir = path.basename( pkg.path ); + + // dir is written into a shell script; keep it to a safe set. + if ( ! /^[A-Za-z0-9._-]+$/.test( dir ) ) { + throw new Error( + `Refusing to write retry script: unsafe package directory "${ dir }".` + ); + } + return `echo "Publishing ${ name }..." -cd packages/${ path.basename( pkg.path ) } +cd packages/${ dir } pnpm publish --access public --no-git-checks cd ../..`; } ) diff --git a/bin/update-google-fonts.js b/bin/update-google-fonts.js index 580593273..4eb168332 100755 --- a/bin/update-google-fonts.js +++ b/bin/update-google-fonts.js @@ -11,9 +11,18 @@ const https = require( 'https' ); * and updates the local google-fonts.json file used by Popup Maker. */ -const API_KEY = 'AIzaSyCjkbFHtpK1fwdqTfACg_wZ9iJ0DtXjqrg'; +// Key comes from the environment, never source (it ships with the repo). +const API_KEY = process.env.GOOGLE_FONTS_API_KEY; const FONTS_JSON_PATH = path.join( __dirname, '../includes/google-fonts.json' ); +if ( ! API_KEY ) { + console.error( + '❌ Missing GOOGLE_FONTS_API_KEY environment variable. ' + + 'Set it to a restricted Google Fonts API key before running this script.' + ); + process.exit( 1 ); +} + /** * Fetch data from Google Fonts API */ diff --git a/classes/Admin/BlockEditor.php b/classes/Admin/BlockEditor.php index a5deb630b..4b02c49a8 100644 --- a/classes/Admin/BlockEditor.php +++ b/classes/Admin/BlockEditor.php @@ -145,7 +145,7 @@ public static function register_block_categories( $categories, $editor_context ) [ 'slug' => 'popup-maker', 'title' => __( 'Popup Maker', 'popup-maker' ), - 'icon' => pum_asset_url( 'mark.svg' ), + 'icon' => pum_asset_url( 'images/mark.svg' ), ], ] ); diff --git a/classes/Admin/Popups.php b/classes/Admin/Popups.php index 45f2f021d..28bfcf984 100644 --- a/classes/Admin/Popups.php +++ b/classes/Admin/Popups.php @@ -1278,7 +1278,7 @@ public static function render_analytics_meta_box() {
: -
: +
: 0 ) : ?>
diff --git a/classes/Admin/Settings.php b/classes/Admin/Settings.php index 2b9415b23..fff05381b 100644 --- a/classes/Admin/Settings.php +++ b/classes/Admin/Settings.php @@ -791,7 +791,9 @@ public static function field_go_pro_features() { // Detect integrations for contextual messaging. $integrations = PUM_Admin_Helpers::get_detected_integrations(); $has_woocommerce = isset( $integrations['woocommerce'] ); - $has_edd = isset( $integrations['edd'] ); + // The flat helper derives the slug from the label ("Easy Digital + // Downloads" => "easy_digital_downloads"); accept the legacy "edd" key too. + $has_edd = isset( $integrations['edd'] ) || isset( $integrations['easy_digital_downloads'] ); $has_lms = isset( $integrations['lifterlms'] ); $has_ecommerce = $has_woocommerce || $has_edd; // Check individual Pro+ addon status β€” show bar when platform detected but addon missing. @@ -891,6 +893,12 @@ public static function user_role_options() { */ public static function page() { + // Also reachable via the edit_posts-gated "Go Pro" submenu, and it dumps all + // settings into inline JS β€” enforce the settings capability here. + if ( ! current_user_can( \PopupMaker\plugin()->get_permission( 'manage_settings' ) ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'popup-maker' ), 403 ); + } + $settings = PUM_Utils_Options::get_all(); if ( empty( $settings ) ) { diff --git a/classes/Admin/Templates.php b/classes/Admin/Templates.php index 363d17311..c7f922d83 100644 --- a/classes/Admin/Templates.php +++ b/classes/Admin/Templates.php @@ -514,7 +514,7 @@ public static function custom_fields() { - + diff --git a/classes/AssetCache.php b/classes/AssetCache.php index 8a7a827a4..a19e266ba 100644 --- a/classes/AssetCache.php +++ b/classes/AssetCache.php @@ -834,7 +834,7 @@ private static function get_asset_contents( $src ) { $response = wp_remote_get( $src, [ - 'sslverify' => apply_filters( 'pum_asset_cache_sslverify', false, $src ), + 'sslverify' => apply_filters( 'pum_asset_cache_sslverify', true, $src ), 'timeout' => 15, ] ); @@ -857,6 +857,55 @@ private static function get_asset_contents( $src ) { return self::read_local_file( $src ); } + /** + * Canonicalize a path and confirm it stays within WP_CONTENT_DIR/ABSPATH. + * + * Blocks traversal and stream wrappers so a caller-supplied asset source can't + * read arbitrary files into the public cache. + * + * @param string $path Candidate filesystem path. + * + * @return string|false Canonical path within an allowed base, or false. + */ + private static function resolve_allowed_local_path( $path ) { + if ( ! is_string( $path ) || '' === $path ) { + return false; + } + + // Reject stream wrappers (phar://, file://, ...); local paths only. + if ( false !== strpos( $path, '://' ) ) { + return false; + } + + // realpath() resolves symlinks/.. and returns false when missing. + $real = realpath( $path ); + + if ( false === $real ) { + return false; + } + + $real = wp_normalize_path( $real ); + + $content_base = realpath( WP_CONTENT_DIR ); + $abspath_base = realpath( ABSPATH ); + + $allowed_bases = []; + if ( false !== $content_base ) { + $allowed_bases[] = trailingslashit( wp_normalize_path( $content_base ) ); + } + if ( false !== $abspath_base ) { + $allowed_bases[] = trailingslashit( wp_normalize_path( $abspath_base ) ); + } + + foreach ( $allowed_bases as $base ) { + if ( 0 === strpos( $real, $base ) ) { + return $real; + } + } + + return false; + } + /** * Read a local file path, returning false when missing or unreadable. * @@ -865,7 +914,11 @@ private static function get_asset_contents( $src ) { * @return string|false */ private static function read_local_file( $path ) { - $path = wp_normalize_path( $path ); + $path = self::resolve_allowed_local_path( $path ); + + if ( false === $path ) { + return false; + } if ( ! is_readable( $path ) || ! is_file( $path ) ) { return false; @@ -894,9 +947,12 @@ private static function url_to_local_path( $url ) { foreach ( $bases as $url_base => $path_base ) { if ( 0 === strpos( $url, $url_base ) ) { - $path = $path_base . substr( $url, strlen( $url_base ) ); + $candidate = $path_base . substr( $url, strlen( $url_base ) ); + + // Range-check to reject traversal outside the base. + $path = self::resolve_allowed_local_path( $candidate ); - return is_file( $path ) ? $path : false; + return ( $path && is_file( $path ) ) ? $path : false; } } diff --git a/classes/Base/Upgrade.php b/classes/Base/Upgrade.php index 97f371a21..c5e05e750 100644 --- a/classes/Base/Upgrade.php +++ b/classes/Base/Upgrade.php @@ -131,43 +131,28 @@ public function stream_run( $stream ) { /** * Return the stream. * - * If no stream is available it returns a mock object with no-op methods to prevent errors. + * If no stream is available it returns a mock object whose methods are all + * no-ops so callers can invoke stream methods without a fatal. * - * @return \PopupMaker\Services\UpgradeStream|(object{ - * send_event: Closure, - * send_error: Closure, - * send_data: Closure, - * update_status: Closure, - * update_task_status: Closure, - * start_upgrades: Closure, - * complete_upgrades: Closure, - * start_task: Closure, - * update_task_progress: Closure, - * complete_task: Closure - * }&\stdClass) Stream instance or mock object with no-op methods. + * @return \PopupMaker\Services\UpgradeStream|object Stream instance or no-op mock. */ public function stream() { - $noop = - /** - * No-op function for mock stream methods. - * - * @param mixed ...$args Variable arguments (ignored). - * - * @return void - */ - function () {}; - - return is_a( $this->stream, '\PopupMaker\Services\UpgradeStream' ) ? $this->stream : (object) [ - 'send_event' => $noop, - 'send_error' => $noop, - 'send_data' => $noop, - 'update_status' => $noop, - 'update_task_status' => $noop, - 'start_upgrades' => $noop, - 'complete_upgrades' => $noop, - 'start_task' => $noop, - 'update_task_progress' => $noop, - 'complete_task' => $noop, - ]; + if ( is_a( $this->stream, '\PopupMaker\Services\UpgradeStream' ) ) { + return $this->stream; + } + + // A stdClass with closure properties cannot be called as methods, so use + // an anonymous class whose __call swallows any stream method invocation. + return new class() { + /** + * No-op for any stream method call. + * + * @param string $name Method name. + * @param array $args Arguments (ignored). + * + * @return void + */ + public function __call( $name, $args ) {} + }; } } diff --git a/classes/Controllers/Admin.php b/classes/Controllers/Admin.php index dda861723..7c9da229a 100644 --- a/classes/Controllers/Admin.php +++ b/classes/Controllers/Admin.php @@ -58,7 +58,7 @@ public function filter_layout_vars( $vars ) { if ( is_string( $edit_ctas_cap ) && $edit_ctas_cap && current_user_can( $edit_ctas_cap ) ) { $vars['navTabs'][] = [ 'id' => 'call-to-actions', - 'title' => __( 'Call to Actions', 'popup-maker' ), + 'title' => __( 'Calls to Action', 'popup-maker' ), 'href' => admin_url( 'edit.php?post_type=popup&page=popup-maker-call-to-actions' ), ]; } diff --git a/classes/Controllers/Admin/CallToActions.php b/classes/Controllers/Admin/CallToActions.php index a2c0cd01c..5a66866f9 100644 --- a/classes/Controllers/Admin/CallToActions.php +++ b/classes/Controllers/Admin/CallToActions.php @@ -40,8 +40,8 @@ public function init() { public function register_page() { add_submenu_page( 'edit.php?post_type=popup', - __( 'Call to Actions', 'popup-maker' ), - __( 'Call to Actions', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), $this->container->get_permission( 'edit_ctas' ), 'popup-maker-call-to-actions', [ $this, 'render_page' ] diff --git a/classes/Controllers/Assets.php b/classes/Controllers/Assets.php index 58d38ab14..156fdad33 100644 --- a/classes/Controllers/Assets.php +++ b/classes/Controllers/Assets.php @@ -113,7 +113,8 @@ public function get_packages() { 'varsName' => 'popupMakerBlockLibrary', 'vars' => function () { return [ - 'homeUrl' => home_url(), + 'homeUrl' => home_url(), + 'paramNames' => \PopupMaker\get_param_names(), ]; }, ], @@ -137,9 +138,22 @@ public function get_packages() { ], 'varsName' => 'popupMakerCoreData', 'vars' => function () { + $settings = \pum_get_options(); + + // Never expose raw license keys in page JS. This covers the Pro key + // (popup_maker_pro_license_key) and legacy addon keys (*_license_key). + // The license UIs have their own masked source of truth. + if ( is_array( $settings ) ) { + foreach ( array_keys( $settings ) as $setting_key ) { + if ( is_string( $setting_key ) && '_license_key' === substr( $setting_key, -12 ) ) { + unset( $settings[ $setting_key ] ); + } + } + } + return [ // TODO Migrate to use plugin('options')->get_all(); - 'currentSettings' => \pum_get_options(), + 'currentSettings' => $settings, ]; }, ], @@ -297,7 +311,8 @@ public function register_scripts() { } } - $footer = $package_data['head'] ?? true; + // 'head' => true means load in the head, i.e. NOT in the footer. + $footer = ! ( $package_data['head'] ?? false ); if ( $bundled ) { pum_register_script( $handle, $js_file, $js_deps, $meta['version'], $footer ); diff --git a/classes/Controllers/CallToActions.php b/classes/Controllers/CallToActions.php index ca580e67d..ee99d1985 100644 --- a/classes/Controllers/CallToActions.php +++ b/classes/Controllers/CallToActions.php @@ -41,6 +41,12 @@ public function template_redirect() { $popup_id = \PopupMaker\get_param_value( 'popup_id', null, 'int' ); $notrack = \PopupMaker\get_param_value( 'notrack', false, 'bool' ); + // Only honor notrack for editors (preview links); otherwise anyone could + // append notrack=1 to suppress conversion tracking. + if ( $notrack && ! current_user_can( \PopupMaker\plugin()->get_permission( 'edit_popups' ) ) ) { + $notrack = false; + } + /** * Filter the CTA identifier before lookup. * @@ -71,6 +77,12 @@ public function template_redirect() { return; } + // The UUID->ID cache isn't invalidated on status change, so only act on + // published CTAs β€” a stale entry must not keep a disabled CTA live. + if ( 'publish' !== $call_to_action->status ) { + return; + } + /** * Filters the arguments passed to the CTA event. * @@ -182,8 +194,11 @@ private function handle_url_tracking( $popup_id, $notrack, $cta_args ) { \pum_track_conversion_event( $popup_id, $extra_args ); } + // remove_query_arg() builds from REQUEST_URI, which can be a protocol-relative + // URL (//evil.example/...); this same-page reload must stay on-site. $url = remove_query_arg( $cta_args ); - \PopupMaker\safe_redirect( $url ); + $url = wp_validate_redirect( $url, home_url( '/' ) ); + wp_safe_redirect( $url ); exit; } } diff --git a/classes/Controllers/Compatibility/Plugin/ACF.php b/classes/Controllers/Compatibility/Plugin/ACF.php index 9b6afcb0a..ad2a5c125 100644 --- a/classes/Controllers/Compatibility/Plugin/ACF.php +++ b/classes/Controllers/Compatibility/Plugin/ACF.php @@ -36,6 +36,13 @@ public function controller_enabled() { && apply_filters( 'popup_maker/enable_acf_shortcodes_in_popups', true ); } + /** + * The popup ID currently being rendered, used to scope ACF field access. + * + * @var int + */ + private $current_popup_id = 0; + /** * Init controller. * @@ -44,7 +51,7 @@ public function controller_enabled() { public function init() { // Bracket the popup content filter chain: enable before the shortcode // pass (runs at priority 11) and restore immediately after. - add_filter( 'pum_popup_content', [ $this, 'enable_field_access' ], 1 ); + add_filter( 'pum_popup_content', [ $this, 'enable_field_access' ], 1, 2 ); add_filter( 'pum_popup_content', [ $this, 'restore_field_access' ], 12 ); } @@ -52,12 +59,15 @@ public function init() { * Permit ACF shortcode field access for the duration of popup content * rendering. Paired with restore_field_access(). * - * @param string $content Popup content (passed through unchanged). + * @param string $content Popup content (passed through unchanged). + * @param int $popup_id ID of the popup being rendered. * * @return string */ - public function enable_field_access( $content ) { - add_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', '__return_false' ); + public function enable_field_access( $content, $popup_id = 0 ) { + $this->current_popup_id = (int) $popup_id; + + add_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', [ $this, 'allow_current_popup_fields' ], 10, 2 ); return $content; } @@ -71,8 +81,29 @@ public function enable_field_access( $content ) { * @return string */ public function restore_field_access( $content ) { - remove_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', '__return_false' ); + remove_filter( 'acf/shortcode/prevent_access_to_fields_on_non_public_posts', [ $this, 'allow_current_popup_fields' ], 10 ); + + $this->current_popup_id = 0; return $content; } + + /** + * Relax ACF's non-public-post guard only for the popup currently rendering. + * + * Scoped to the popup's own ID so `[acf post_id=]` can't + * read fields from unrelated posts. + * + * @param bool $prevent_access Whether ACF should block field access. + * @param int|string $post_id Post the `[acf]` shortcode is reading from. + * + * @return bool + */ + public function allow_current_popup_fields( $prevent_access, $post_id = 0 ) { + if ( $this->current_popup_id && is_numeric( $post_id ) && (int) $post_id === $this->current_popup_id ) { + return false; + } + + return $prevent_access; + } } diff --git a/classes/Controllers/Debug.php b/classes/Controllers/Debug.php index 5f019ce29..edfd5eacd 100644 --- a/classes/Controllers/Debug.php +++ b/classes/Controllers/Debug.php @@ -35,11 +35,35 @@ public function init() { } /** - * Enqueue admin assets. + * Version of react-scan to load. Pinned so the Subresource Integrity hash + * below stays valid; bump both together when updating. + * + * @var string + */ + const REACT_SCAN_VERSION = '0.5.7'; + + /** + * SRI hash for react-scan REACT_SCAN_VERSION auto.global.js. + * + * @var string + */ + const REACT_SCAN_SRI = 'sha384-DDZCsimcjpG92OUulxf7DHi4rGS/fNIW7lC5DT8+5ftaTDiUKfzIq+pDTUbPjC86'; + + /** + * Print the react-scan dev profiler (pinned + SRI over HTTPS). */ public function admin_head() { - // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript ?> - ' . "\n", + esc_url( $src ), + esc_attr( self::REACT_SCAN_SRI ) + ); } } diff --git a/classes/Controllers/Frontend/Popups.php b/classes/Controllers/Frontend/Popups.php index dc8c9e95d..9d88d0abd 100644 --- a/classes/Controllers/Frontend/Popups.php +++ b/classes/Controllers/Frontend/Popups.php @@ -132,6 +132,28 @@ public function get_loaded_popups() { return $this->popups ?? []; } + /** + * Get the loaded popups shaped as a WP_Query. + * + * Back-compat helper for the legacy PUM_Site_Popups::get_loaded_popups() API, + * which returned a WP_Query rather than a plain array. + * + * @return \WP_Query + */ + public function get_loaded_popups_query() { + $popups = array_values( $this->get_loaded_popups() ); + + $query = new \WP_Query(); + $query->posts = $popups; + $query->post_count = count( $popups ); + $query->found_posts = $query->post_count; + $query->post = null; + + $query->rewind_posts(); + + return $query; + } + /** * Preloads popup, if enabled. * diff --git a/classes/Controllers/PostTypes.php b/classes/Controllers/PostTypes.php index 6d7c6b1c6..6ce376a56 100644 --- a/classes/Controllers/PostTypes.php +++ b/classes/Controllers/PostTypes.php @@ -61,6 +61,38 @@ public function get_type_key( $type ) { return $this->get_type_keys()[ $type ]; } + /** + * Full primitive-capability map for a map_meta_cap post type. + * + * Overriding only edit_posts/delete_posts leaves the rest (edit_published_posts, + * etc.) on the default `post` caps; mapping all of them keeps every status + * behind the same permission. + * + * @param string $permission Capability required for all operations. + * + * @return array + */ + private function full_capabilities( $permission ) { + return [ + // Meta caps (map_meta_cap maps these to the primitives below). + 'edit_post' => $permission, + 'read_post' => $permission, + 'delete_post' => $permission, + // Primitive caps. + 'create_posts' => $permission, + 'edit_posts' => $permission, + 'edit_others_posts' => $permission, + 'edit_published_posts' => $permission, + 'edit_private_posts' => $permission, + 'publish_posts' => $permission, + 'read_private_posts' => $permission, + 'delete_posts' => $permission, + 'delete_others_posts' => $permission, + 'delete_published_posts' => $permission, + 'delete_private_posts' => $permission, + ]; + } + /** * Register post types. * @@ -132,11 +164,7 @@ public function register_popup_post_type() { 'can_export' => true, 'map_meta_cap' => true, 'delete_with_user' => false, - 'capabilities' => [ - 'create_posts' => $this->container->get_permission( 'edit_popups' ), - 'edit_posts' => $this->container->get_permission( 'edit_popups' ), - 'delete_posts' => $this->container->get_permission( 'edit_popups' ), - ], + 'capabilities' => $this->full_capabilities( $this->container->get_permission( 'edit_popups' ) ), ]; /** @@ -173,7 +201,6 @@ public function register_popup_post_type() { }, ] ); - } /** @@ -226,11 +253,7 @@ public function register_popup_theme_post_type() { 'can_export' => true, 'map_meta_cap' => true, 'delete_with_user' => false, - 'capabilities' => [ - 'create_posts' => $this->container->get_permission( 'edit_popup_themes' ), - 'edit_posts' => $this->container->get_permission( 'edit_popup_themes' ), - 'delete_posts' => $this->container->get_permission( 'edit_popup_themes' ), - ], + 'capabilities' => $this->full_capabilities( $this->container->get_permission( 'edit_popup_themes' ) ), ]; /** @@ -264,14 +287,14 @@ public function register_cta_post_type() { $cta_labels = $this->post_type_labels( __( 'Call to Action', 'popup-maker' ), - __( 'Call to Actions', 'popup-maker' ), + __( 'Calls to Action', 'popup-maker' ), $post_type_key ); $cta_args = [ 'label' => __( 'Call to Action', 'popup-maker' ), 'labels' => array_merge( $cta_labels, [ - 'all_items' => __( 'Call to Actions', 'popup-maker' ), + 'all_items' => __( 'Calls to Action', 'popup-maker' ), ] ), 'description' => '', // Basic. @@ -299,11 +322,7 @@ public function register_cta_post_type() { 'can_export' => true, 'map_meta_cap' => true, 'delete_with_user' => false, - 'capabilities' => [ - 'create_posts' => $this->container->get_permission( 'edit_ctas' ), - 'edit_posts' => $this->container->get_permission( 'edit_ctas' ), - 'delete_posts' => $this->container->get_permission( 'edit_ctas' ), - ], + 'capabilities' => $this->full_capabilities( $this->container->get_permission( 'edit_ctas' ) ), ]; /** @@ -373,15 +392,12 @@ public function register_popup_tag_tax() { $tag_labels = (array) get_taxonomy_labels( get_taxonomy( 'post_tag' ) ); - $tag_args = apply_filters( - 'popmake_tag_args', - [ - 'hierarchical' => false, - 'labels' => $tag_labels, - 'public' => false, - 'show_ui' => true, - ] - ); + $tag_args = [ + 'hierarchical' => false, + 'labels' => $tag_labels, + 'public' => false, + 'show_ui' => true, + ]; /** * Filter: popup_maker/popup_tag_tax_args diff --git a/classes/Controllers/RestAPI.php b/classes/Controllers/RestAPI.php index ba5c7126c..15a0c0b33 100644 --- a/classes/Controllers/RestAPI.php +++ b/classes/Controllers/RestAPI.php @@ -514,6 +514,16 @@ public function register_cta_rest_fields() { return get_post_status( $obj['id'] ); }, 'update_callback' => function ( $value, $obj ) { + // Trashing is a delete action; the field only checks edit_ctas, so + // require delete rights for this object before allowing it. + if ( 'trash' === $value && ! current_user_can( 'delete_post', $obj->ID ) ) { + return new \WP_Error( + 'rest_cannot_delete', + __( 'You do not have permission to trash this call to action.', 'popup-maker' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + wp_update_post( [ 'ID' => $obj->ID, 'post_status' => $value, @@ -571,7 +581,7 @@ public function register_cta_rest_fields() { 'description' => __( 'Stats for this CTA.', 'popup-maker' ), 'type' => 'object', 'properties' => [ - 'conversion' => [ + 'conversions' => [ 'type' => 'integer', 'minimum' => 0, ], diff --git a/classes/Controllers/WP/I18n.php b/classes/Controllers/WP/I18n.php index 7ae790866..b615b6c2c 100644 --- a/classes/Controllers/WP/I18n.php +++ b/classes/Controllers/WP/I18n.php @@ -33,6 +33,9 @@ public function init() { * @return void */ public function load_textdomain() { - load_plugin_textdomain( $this->container['text_domain'], false, $this->container->get_path( 'languages' ) ); + // The third argument must be relative to WP_PLUGIN_DIR, not an absolute path. + $languages_rel_path = dirname( $this->container->get( 'basename' ) ) . '/languages'; + + load_plugin_textdomain( $this->container['text_domain'], false, $languages_rel_path ); } } diff --git a/classes/Integration/Builder/BeaverBuilder.php b/classes/Integration/Builder/BeaverBuilder.php new file mode 100644 index 000000000..c46d2b6f9 --- /dev/null +++ b/classes/Integration/Builder/BeaverBuilder.php @@ -0,0 +1,188 @@ + [ 'pum_open_popup' ], + ]; + + // The popup selector itself. + $fields['pum_open_popup'] = [ + 'type' => 'select', + 'label' => __( 'Popup', 'popup-maker' ), + 'default' => '', + 'options' => $this->get_popup_options(), + 'help' => __( 'Open the selected popup when this button is clicked.', 'popup-maker' ), + ]; + + return $form; + } + + /** + * Append the Popup Maker trigger class to the button when a popup is set. + * + * @param array $attrs Module node attributes ('class' is an array). + * @param object $module Beaver Builder module instance. + * + * @return array + */ + public function add_trigger_class( $attrs, $module ) { + if ( ! isset( $module->slug ) || 'button' !== $module->slug ) { + return $attrs; + } + + // Only when the Open Popup click action is selected. + if ( ! isset( $module->settings->click_action ) || 'popup' !== $module->settings->click_action ) { + return $attrs; + } + + $popup_id = isset( $module->settings->pum_open_popup ) ? absint( $module->settings->pum_open_popup ) : 0; + + if ( $popup_id <= 0 ) { + return $attrs; + } + + if ( ! isset( $attrs['class'] ) || ! is_array( $attrs['class'] ) ) { + $attrs['class'] = isset( $attrs['class'] ) ? (array) $attrs['class'] : []; + } + + $attrs['class'][] = 'popmake-' . $popup_id; + + // Force the referenced popup to load on this page regardless of its own + // display conditions β€” otherwise the button would render but the popup + // it targets might not be enqueued. Skipped in the builder UI/admin. + // Mirrors the popup-trigger shortcode. maybe_preload_popup() still + // respects the popup's enabled state. + if ( ! is_admin() && ! ( class_exists( 'FLBuilderModel' ) && FLBuilderModel::is_builder_active() ) ) { + \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->maybe_preload_popup( $popup_id ); + } + + return $attrs; + } + + /** + * Build the popup options list for the select field. + * + * @return array + */ + private function get_popup_options() { + $options = [ + '' => __( 'β€” None β€”', 'popup-maker' ), + ]; + + $popups = pum_get_all_popups(); + + if ( empty( $popups ) || ! is_array( $popups ) ) { + return $options; + } + + foreach ( $popups as $popup ) { + if ( ! pum_is_popup( $popup ) ) { + continue; + } + + // Use the post title (the popup's admin name); get_title() reads the + // separate popup_title meta, which is usually empty. + $title = $popup->post_title; + + if ( '' === trim( (string) $title ) ) { + $title = __( '(no title)', 'popup-maker' ); + } + + $options[ $popup->ID ] = sprintf( + /* translators: 1: popup title, 2: popup ID. */ + __( '%1$s (ID: %2$d)', 'popup-maker' ), + $title, + $popup->ID + ); + } + + return $options; + } +} diff --git a/classes/Integration/Form/KaliForms.php b/classes/Integration/Form/KaliForms.php index fd1f65a56..4b060cbf4 100644 --- a/classes/Integration/Form/KaliForms.php +++ b/classes/Integration/Form/KaliForms.php @@ -128,8 +128,11 @@ public function on_success( $args ) { $popup_id = $this->get_popup_id(); - if ( $popup_id ) { + // popup_id comes from a forgeable public form field; only count real popups. + if ( $popup_id && pum_is_popup( $popup_id ) ) { $this->increase_conversion( $popup_id ); + } else { + $popup_id = false; } pum_integrated_form_submission( @@ -152,14 +155,14 @@ public function get_popup_id() { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw_data = wp_unslash( $_POST['data'] ); - // Handle JSON data first. - $json_data = json_decode( $raw_data, true ); - if ( is_array( $json_data ) && isset( $json_data['pum_form_popup_id'] ) ) { - return absint( $json_data['pum_form_popup_id'] ); - } - - // Parse the data if it's a URL-encoded string. + // data[]=x arrives as an array and would fatal in json_decode/parse_str. if ( is_string( $raw_data ) ) { + $json_data = json_decode( $raw_data, true ); + if ( is_array( $json_data ) && isset( $json_data['pum_form_popup_id'] ) ) { + return absint( $json_data['pum_form_popup_id'] ); + } + + // Parse the data if it's a URL-encoded string. // Parse first to preserve URL encoding, then sanitize individual values. parse_str( $raw_data, $data ); diff --git a/classes/Integrations.php b/classes/Integrations.php index 7cf03bb0e..30d2c1a90 100644 --- a/classes/Integrations.php +++ b/classes/Integrations.php @@ -58,6 +58,7 @@ public static function init() { // Page Builders. 'kingcomposer' => new PUM_Integration_Builder_KingComposer(), 'visualcomposer' => new PUM_Integration_Builder_VisualComposer(), + 'beaverbuilder_button' => new PUM_Integration_Builder_BeaverBuilder(), // 'bricks' => new PUM_Integration_Builder_Bricks(), ] ); diff --git a/classes/Licensing.php b/classes/Licensing.php index 6dbade8a2..e57de1646 100644 --- a/classes/Licensing.php +++ b/classes/Licensing.php @@ -92,7 +92,21 @@ public static function get_status_messages( $license = null, $key = '', $product // Prefer a stored error message from a recent API response. if ( false === $license->success && ! empty( $license->error_message ) ) { - $messages[] = $license->error_message; + // error_message is untrusted (remote API) and the template renders + // messages unescaped; allow only safe inline markup. + $messages[] = wp_kses( + $license->error_message, + [ + 'a' => [ + 'href' => true, + 'target' => true, + 'rel' => true, + ], + 'strong' => [], + 'em' => [], + 'br' => [], + ] + ); return $messages; } diff --git a/classes/Models/CallToAction.php b/classes/Models/CallToAction.php index 29164c2e7..06cf22144 100644 --- a/classes/Models/CallToAction.php +++ b/classes/Models/CallToAction.php @@ -219,8 +219,10 @@ public function get_description() { * @return string */ public function generate_url( $base_url = '', $extra_args = [] ) { + $cta_param = \PopupMaker\get_param_name( 'cta' ); + $args = wp_parse_args( $extra_args, [ - 'cta' => $this->get_uuid(), + $cta_param => $this->get_uuid(), ] ); return \add_query_arg( $args, $base_url ); diff --git a/classes/Plugin/Container.php b/classes/Plugin/Container.php index 782139de9..85759729b 100644 --- a/classes/Plugin/Container.php +++ b/classes/Plugin/Container.php @@ -99,7 +99,9 @@ public function get_controller( $name ) { $controller = $this->controllers->get( $name ); - if ( $controller instanceof Controller ) { + // Match the registration check so controllers that implement the interface + // without extending Base\Controller remain retrievable. + if ( $controller instanceof \PopupMaker\Interfaces\Controller ) { return $controller; } diff --git a/classes/Previews.php b/classes/Previews.php index e087315bc..f198410ef 100644 --- a/classes/Previews.php +++ b/classes/Previews.php @@ -87,6 +87,12 @@ private static function is_previewing_popup( $popup_id = 0 ) { public static function force_load_preview() { $preview_id = static::get_popup_preview(); + // The preview nonce is shared across block-editor screens; require edit + // access to this specific popup before force-loading draft/private content. + if ( ! $preview_id || ! current_user_can( 'edit_post', $preview_id ) ) { + return; + } + $popup = pum_get_popup( $preview_id ); if ( $popup->is_valid() && $preview_id === $popup->ID ) { diff --git a/classes/RestAPI/ObjectSearch.php b/classes/RestAPI/ObjectSearch.php index c46662103..feaa5dd82 100644 --- a/classes/RestAPI/ObjectSearch.php +++ b/classes/RestAPI/ObjectSearch.php @@ -112,12 +112,36 @@ public function search_objects( $request ) { switch ( $object_type ) { case 'post_type': $post_type = $request->get_param( 'object_key' ) ?: 'post'; - $results = $this->search_post_type( $post_type, $request, $included, $excluded ); + + // object_key can target any post type; enforce its own edit cap. + $post_type_object = get_post_type_object( $post_type ); + + if ( ! $post_type_object || ! current_user_can( $post_type_object->cap->edit_posts ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to search this object type.', 'popup-maker' ), + [ 'status' => 403 ] + ); + } + + $results = $this->search_post_type( $post_type, $request, $included, $excluded ); break; case 'taxonomy': $taxonomy = $request->get_param( 'object_key' ) ?: 'category'; - $results = $this->search_taxonomy( $taxonomy, $request, $included, $excluded ); + + // object_key can target any taxonomy; enforce its own assign cap. + $taxonomy_object = get_taxonomy( $taxonomy ); + + if ( ! $taxonomy_object || ! current_user_can( $taxonomy_object->cap->assign_terms ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to search this taxonomy.', 'popup-maker' ), + [ 'status' => 403 ] + ); + } + + $results = $this->search_taxonomy( $taxonomy, $request, $included, $excluded ); break; case 'user': diff --git a/classes/Services/License.php b/classes/Services/License.php index d3e971643..99e523808 100644 --- a/classes/Services/License.php +++ b/classes/Services/License.php @@ -528,7 +528,13 @@ private function update_license_key( string $key ): bool { * * @return bool */ - private function update_license_status( array $license_status ): bool { + private function update_license_status( ?array $license_status ): bool { + // Callers may pass null/empty on a failed or empty API response; never + // fatal the typed setter and never clobber stored status with nothing. + if ( empty( $license_status ) ) { + return false; + } + $license_data = $this->get_license_data(); $previous_status = isset( $license_data['status'] ) ? $license_data['status'] : []; @@ -597,6 +603,11 @@ public function refresh_license_status(): bool { $status = null; } + // A transient failure must not overwrite a previously valid status. + if ( empty( $status ) ) { + return false; + } + return $this->update_license_status( $status ); } @@ -724,13 +735,13 @@ public function activate_license(): bool { public function deactivate_license(): bool { $license_status = $this->api_call( 'deactivate_license' ); - $this->update_license_status( $license_status ); - if ( empty( $license_status ) ) { return false; } - $succeeded = 'deactivated' === $license_status['license']; + $this->update_license_status( $license_status ); + + $succeeded = isset( $license_status['license'] ) && 'deactivated' === $license_status['license']; /** * Fires when license is activated. diff --git a/classes/Services/UpgradeStream.php b/classes/Services/UpgradeStream.php index 9067393b9..997889dd8 100644 --- a/classes/Services/UpgradeStream.php +++ b/classes/Services/UpgradeStream.php @@ -90,6 +90,14 @@ public function update_task_status( $task_status ) { * @return void */ public function send_event( $event, $data = [] ) { + // Inherited callers such as send_error() may pass a string or scalar; + // normalize to an array so the offset writes below never hit a scalar. + if ( ! is_array( $data ) ) { + $data = ( null === $data || '' === $data ) + ? [] + : [ 'message' => is_scalar( $data ) ? (string) $data : \wp_json_encode( $data ) ]; + } + // Always send the status. $data['status'] = $this->status; diff --git a/classes/Shortcode/CallToAction.php b/classes/Shortcode/CallToAction.php index 689e021d7..11dcf115b 100644 --- a/classes/Shortcode/CallToAction.php +++ b/classes/Shortcode/CallToAction.php @@ -189,6 +189,12 @@ public function handler( $atts, $content = null ) { return 'Missing Call To Action'; } + // This shortcode runs in any post, and get_cta_by_id() ignores status β€” + // only render published CTAs so authors can't surface non-public UUIDs. + if ( 'publish' !== $cta->status ) { + return 'Missing Call To Action'; + } + $type = $cta->get_setting( 'type', 'link' ); $uuid = $cta->get_uuid(); diff --git a/classes/Site/Popups.php b/classes/Site/Popups.php index 532cc6bc5..6db8e7ab7 100644 --- a/classes/Site/Popups.php +++ b/classes/Site/Popups.php @@ -53,7 +53,14 @@ public static function init() { * @deprecated 1.8.0 Use pum()->current_popup directly or PopupMaker\set_current_popup() */ public static function current_popup( $new_popup = false ) { - return \PopupMaker\get_current_popup(); + global $popup; + + if ( false !== $new_popup ) { + \PopupMaker\set_current_popup( $new_popup ); + $popup = $new_popup; + } + + return pum()->current_popup; } /** @@ -63,7 +70,10 @@ public static function current_popup( $new_popup = false ) { * @deprecated 1.21.0 Use \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->get_loaded_popups */ public static function get_loaded_popups() { - return \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->get_loaded_popups(); + self::$loaded = \PopupMaker\plugin()->get_controller( 'Frontend\Popups' )->get_loaded_popups_query(); + self::$loaded_ids = wp_list_pluck( self::$loaded->posts, 'ID' ); + + return self::$loaded; } /** diff --git a/classes/Telemetry.php b/classes/Telemetry.php index 5b71c46a6..322493bb0 100644 --- a/classes/Telemetry.php +++ b/classes/Telemetry.php @@ -304,6 +304,12 @@ public static function optin_alert( $alerts ) { public static function optin_alert_check( $code, $action ) { if ( 'pum_telemetry_notice' === $code ) { if ( 'pum_optin_check_allow' === $action ) { + // The alert dismiss handler only requires edit_posts; enabling + // telemetry is a settings-level decision. + if ( ! current_user_can( \PopupMaker\plugin()->get_permission( 'manage_settings' ) ) ) { + return; + } + pum_update_option( 'telemetry', true ); } } diff --git a/classes/Upsell.php b/classes/Upsell.php index 9c46fa5e7..ed0645d46 100644 --- a/classes/Upsell.php +++ b/classes/Upsell.php @@ -43,6 +43,12 @@ public static function init() { * @since 1.14.0 */ public static function notice_bar_display() { + // pum_is_admin_page() trusts the post_type param; gate on capability so the + // notice can't leak installed integrations to low-privileged users. + if ( ! current_user_can( plugin()->get_permission( 'edit_popups' ) ) ) { + return; + } + if ( pum_is_admin_page() ) { // Temporarily disable for CTA post type screens. if ( isset( $_GET['page'] ) && 'popup-maker-call-to-actions' === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended @@ -204,7 +210,9 @@ private static function get_notice_bar_triggers() { // New installs (after form tracking shipped) get celebration messaging. // Existing installs get "tracking is now live" messaging instead. - $installed_on = get_option( 'pum_installed_on', '' ); + // The legacy pum_installed_on option is removed after migration; read the + // install date from the current version info instead. + $installed_on = \PopupMaker\get_current_install_info( 'installed_on' ); $is_new_install = ! empty( $installed_on ) && strtotime( $installed_on ) >= strtotime( '2026-03-25' ); $triggers = [ diff --git a/classes/Utils/Blocks.php b/classes/Utils/Blocks.php index f2223b8d2..ee2d22243 100644 --- a/classes/Utils/Blocks.php +++ b/classes/Utils/Blocks.php @@ -35,7 +35,17 @@ public static function find_blocks( $blocks, $search_name = 'pum/*' ) { $found_blocks = array_merge( $found_blocks, self::find_blocks( $block['innerBlocks'], $search_name ) ); } - if ( $search_name === $block['blockName'] ) { + $block_name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; + + if ( '/*' === substr( $search_name, -2 ) ) { + // Wildcard like 'pum/*' matches any block sharing the prefix. + $prefix = substr( $search_name, 0, -1 ); + $matches = '' !== $block_name && 0 === strpos( $block_name, $prefix ); + } else { + $matches = $search_name === $block_name; + } + + if ( $matches ) { $found_blocks[] = $block; } } diff --git a/classes/Utils/Template.php b/classes/Utils/Template.php index bd5af68e3..2648a2be2 100644 --- a/classes/Utils/Template.php +++ b/classes/Utils/Template.php @@ -151,7 +151,7 @@ public static function render( $template, $args = [] ) { return; } - if ( $args ) { + if ( is_array( $args ) && ! empty( $args ) ) { // phpcs:ignore WordPress.PHP.DontExtract.extract_extract extract( $args ); } diff --git a/composer.json b/composer.json index d0114048a..d0d554769 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "mockery/mockery": "^1.6.12", "brain/monkey": "^2.6.2", "phpcompatibility/phpcompatibility-wp": "^2.1.7", - "yoast/phpunit-polyfills": "^2.0" + "yoast/phpunit-polyfills": "^4.0" }, "autoload": { "psr-4": { @@ -50,7 +50,8 @@ "lint:changed": "vendor/bin/phpcs --standard=.phpcs.xml.dist --report-full --report-checkstyle=./phpcs-report.xml --file-list=.changed-files.txt", "generate-stubs": "./bin/generate-stubs.sh", "install-strauss": [ - "test -f strauss.phar || curl -o strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/download/0.22.4/strauss.phar" + "test -f strauss.phar || curl -o strauss.phar -L -C - https://github.com/BrianHenryIE/strauss/releases/download/0.22.4/strauss.phar", + "echo 'bced6c576608ab67247c4c06d24e40fcdfd6ed3104347d3a4b8c451cacbe4c1f strauss.phar' | shasum -a 256 -c -" ], "clean-vendor-prefix-folder": [ "rm -rf vendor-prefixed/**/*" diff --git a/composer.lock b/composer.lock index 7805269c3..9c097bc82 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f1fbf9993b749aa5804d6468583b1b6c", + "content-hash": "aedee167bad634dbfd805dfac453dc88", "packages": [ { "name": "code-atlantic/prerequisite-checks", @@ -815,20 +815,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -867,9 +866,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -3230,26 +3229,26 @@ }, { "name": "yoast/phpunit-polyfills", - "version": "2.0.5", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", - "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" + "reference": "134921bfca9b02d8f374c48381451da1d98402f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", - "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/134921bfca9b02d8f374c48381451da1d98402f9", + "reference": "134921bfca9b02d8f374c48381451da1d98402f9", "shasum": "" }, "require": { - "php": ">=5.6", - "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + "php": ">=7.1", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0 || ^11.0 || ^12.0" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "yoast/yoastcs": "^3.2.0" + "yoast/yoastcs": "^3.1.0" }, "type": "library", "extra": { @@ -3289,7 +3288,7 @@ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", "source": "https://github.com/Yoast/PHPUnit-Polyfills" }, - "time": "2025-08-10T05:13:49+00:00" + "time": "2025-02-09T18:58:54+00:00" } ], "aliases": [], diff --git a/includes/integrations/class-pum-gravity-forms.php b/includes/integrations/class-pum-gravity-forms.php index 77e6b761a..2864c09be 100644 --- a/includes/integrations/class-pum-gravity-forms.php +++ b/includes/integrations/class-pum-gravity-forms.php @@ -16,6 +16,12 @@ public static function init() { add_action( 'popmake_preload_popup', [ __CLASS__, 'preload' ] ); add_action( 'popmake_popup_before_inner', [ __CLASS__, 'force_ajax' ] ); add_action( 'popmake_popup_after_inner', [ __CLASS__, 'force_ajax' ] ); + + // Popup content is pre-rendered & cached (blocks at priority 9, + // shortcodes at 11), so the template hooks above fire after the + // form has already rendered. Sandwich the content pipeline too. + add_filter( 'pum_popup_content', [ __CLASS__, 'begin_force_ajax' ], 5 ); + add_filter( 'pum_popup_content', [ __CLASS__, 'end_force_ajax' ], 99 ); } public static function force_ajax() { @@ -27,6 +33,32 @@ public static function force_ajax() { } } + /** + * Force AJAX on Gravity Forms rendered within popup content. + * + * @param string $content Popup content. + * + * @return string + */ + public static function begin_force_ajax( $content ) { + add_filter( 'shortcode_atts_gravityforms', [ __CLASS__, 'gfrorms_shortcode_atts' ] ); + + return $content; + } + + /** + * Stop forcing AJAX once popup content has rendered. + * + * @param string $content Popup content. + * + * @return string + */ + public static function end_force_ajax( $content ) { + remove_filter( 'shortcode_atts_gravityforms', [ __CLASS__, 'gfrorms_shortcode_atts' ] ); + + return $content; + } + public static function gfrorms_shortcode_atts( $out ) { $out['ajax'] = 'true'; @@ -63,7 +95,7 @@ public static function settings_menu( $setting_tabs ) { public static function get_form( $form_string, $form ) { $settings = wp_json_encode( self::form_options( $form['id'] ) ); - $field = ""; + $field = ''; $form_string = preg_replace( '/()/', "$1 \r\n " . $field, $form_string ); return $form_string; @@ -92,8 +124,10 @@ public static function defaults() { */ public static function form_options( $id ) { $settings = get_option( 'gforms_pum_' . $id, self::defaults() ); + $settings = wp_parse_args( $settings, self::defaults() ); - return wp_parse_args( $settings, self::defaults() ); + // Restrict to known keys so legacy/poisoned option data cannot reach the render sink. + return array_intersect_key( $settings, self::defaults() ); } /** @@ -273,12 +307,15 @@ public static function save() { // Check if JSON decode was successful. if ( is_array( $settings ) ) { - $settings['openpopup'] = ! empty( $settings['openpopup'] ); - $settings['openpopup_id'] = ! empty( $settings['openpopup_id'] ) ? absint( $settings['openpopup_id'] ) : 0; - $settings['closepopup'] = ! empty( $settings['closepopup'] ); - $settings['closedelay'] = ! empty( $settings['closedelay'] ) ? absint( $settings['closedelay'] ) : 0; - - update_option( 'gforms_pum_' . $form_id, $settings ); + // Only persist the known keys. Discard any attacker-supplied extras. + $clean = [ + 'openpopup' => ! empty( $settings['openpopup'] ), + 'openpopup_id' => ! empty( $settings['openpopup_id'] ) ? absint( $settings['openpopup_id'] ) : 0, + 'closepopup' => ! empty( $settings['closepopup'] ), + 'closedelay' => ! empty( $settings['closedelay'] ) ? absint( $settings['closedelay'] ) : 0, + ]; + + update_option( 'gforms_pum_' . $form_id, $clean ); } } else { delete_option( 'gforms_pum_' . $form_id ); diff --git a/includes/legacy/importer/easy-modal-v2.php b/includes/legacy/importer/easy-modal-v2.php index 931356e43..e4a0351b9 100644 --- a/includes/legacy/importer/easy-modal-v2.php +++ b/includes/legacy/importer/easy-modal-v2.php @@ -31,22 +31,22 @@ function popmake_emodal_v2_import() { global $wpdb, $popmake_options, $wp_version, $popmake_tools_page; - require_once POPMAKE_DIR . 'includes/importer/easy-modal-v2/functions.php'; + require_once POPMAKE_DIR . 'includes/legacy/importer/easy-modal-v2/functions.php'; if ( ! class_exists( 'EModal_Model' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model.php'; } if ( ! class_exists( 'EModal_Model_Modal' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/modal.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/modal.php'; } if ( ! class_exists( 'EModal_Model_Theme' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/theme.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/theme.php'; } if ( ! class_exists( 'EModal_Model_Theme_Meta' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/theme/meta.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/theme/meta.php'; } if ( ! class_exists( 'EModal_Model_Modal_Meta' ) ) { - require_once POPMAKE_DIR . '/includes/importer/easy-modal-v2/model/modal/meta.php'; + require_once POPMAKE_DIR . '/includes/legacy/importer/easy-modal-v2/model/modal/meta.php'; } $themes = get_all_modal_themes( '1 = 1' ); diff --git a/includes/namespaced/types.php b/includes/namespaced/types.php index 644fae55e..66e387808 100644 --- a/includes/namespaced/types.php +++ b/includes/namespaced/types.php @@ -46,7 +46,7 @@ function get_post_type_labels( $post_type ) { $post_type_object = get_post_type_object( $post_type ); - return $post_type_object ? $post_type_object->labels : []; + return $post_type_object ? (array) $post_type_object->labels : []; } /** diff --git a/package.json b/package.json index 7b43e52cb..7dba04118 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "webpack-bundle-analyzer": "^4.10.2" }, "scripts": { - "preinstall": "npx -y only-allow pnpm", + "preinstall": "npx -y only-allow@1.2.2 pnpm", "build": "wp-scripts build --config webpack.config.js --config webpack.old.config.js", "build:production": "NODE_ENV=production wp-scripts build --mode production --config webpack.config.js --config webpack.old.config.js", "build:tsc": "pnpm -r --sort --if-present run build:tsc", diff --git a/packages/admin-bar/src/AdminBar.ts b/packages/admin-bar/src/AdminBar.ts index d2a0453e6..43040543c 100644 --- a/packages/admin-bar/src/AdminBar.ts +++ b/packages/admin-bar/src/AdminBar.ts @@ -9,6 +9,21 @@ declare const popupMakerAdminBar: } | undefined; +/** + * Escape a string for safe interpolation into innerHTML. + * + * @param {string} value Untrusted string. + * @return {string} HTML-escaped string. + */ +function escapeHtml( value: string ): string { + return String( value ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); +} + interface ModalOptions { title: string; content: string; @@ -225,7 +240,7 @@ export class AdminBar { title: this.text.results, content: `
-

${ selector }

+

${ escapeHtml( selector ) }

<# if (isActive) { #><# } else { #><# } #>