You've already forked wc-licensed-product
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36e1fdc20a | |||
| cbece2f279 | |||
| b50969f701 | |||
| d0af939f5e | |||
| c1a337aabe | |||
| ff0229061d | |||
| 7bbffa50b4 | |||
| e168b1a44b | |||
| eb8818aa81 | |||
| fddeda4a80 |
61
CHANGELOG.md
61
CHANGELOG.md
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] - 2026-01-28
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed XSS vulnerability in checkout blocks DOM fallback injection
|
||||
- Unified IP detection for rate limiting across all REST API endpoints
|
||||
- Added rate limiting to license transfers (5 per hour) and downloads (30 per hour)
|
||||
- Added file size (2MB), row count (1000), and rate limiting to CSV import
|
||||
- Added JSON decode error handling in Store API extension
|
||||
- Added jQuery selector sanitization for license ID validation
|
||||
|
||||
### Added
|
||||
|
||||
- New `IpDetectionTrait` for shared IP detection logic with proxy support
|
||||
- New `RateLimitTrait` for reusable frontend rate limiting
|
||||
- New `src/Common/` directory for shared traits
|
||||
|
||||
### Changed
|
||||
|
||||
- RestApiController now uses IpDetectionTrait instead of inline methods
|
||||
- UpdateController now uses IpDetectionTrait for consistent rate limiting behind proxies
|
||||
- AccountController now uses RateLimitTrait for transfer rate limiting
|
||||
- DownloadController now uses RateLimitTrait for download rate limiting
|
||||
- Checkout blocks fallback uses safe DOM construction instead of innerHTML
|
||||
|
||||
## [0.6.1] - 2026-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Filter functionality on customer account licenses page (filter by product or domain)
|
||||
- Split auto-update settings into two options: "Enable Update Notifications" and "Automatically Install Updates"
|
||||
- New `isUpdateNotificationEnabled()`, `isAutoInstallEnabled()` static methods in SettingsController
|
||||
- WordPress auto-update filter integration (`auto_update_plugin`) for automatic installation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed admin license test popup showing empty product field
|
||||
- `handleAjaxTestLicense()` now enriches response with product name
|
||||
- Removed version field from test popup (version_id is only set for version-bound licenses)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.1
|
||||
- "Automatically Install Updates" is only selectable when "Enable Update Notifications" is enabled
|
||||
|
||||
## [0.6.0] - 2026-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- WordPress-style automatic update system for licensed plugins
|
||||
- Server-side `/update-check` REST API endpoint for WordPress-compatible update information
|
||||
- Client-side `PluginUpdateChecker` singleton for WordPress update integration
|
||||
- New "Auto-Updates" settings subtab with enable/disable and check frequency options
|
||||
- Secure download authentication via `X-License-Key` header
|
||||
- Response signing support for tamper-proof update responses
|
||||
- Configurable cache TTL for update checks (1-168 hours)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated OpenAPI specification to version 0.6.0 with `/update-check` endpoint documentation
|
||||
|
||||
## [0.5.15] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
205
CLAUDE.md
205
CLAUDE.md
@@ -32,9 +32,14 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
|
||||
|
||||
### Version 0.6.0
|
||||
### Version 0.7.0
|
||||
|
||||
*No planned features yet.*
|
||||
This is a security version. It includes a full security audit and a remote check of a live version of this plugin on <https://shop.magdev.cc>. The shop is the property of the plugin developer, all actions are permitted.
|
||||
|
||||
- Check the sourcecode for best practises of all involved components, including checks for SQLi, XSRF, XSS and similar techniques
|
||||
- Check the remote version for the OWASP Top 10
|
||||
- Check the whole licensing workflow
|
||||
- Minimize the thread vectors
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -197,11 +202,13 @@ wc-licensed-product/
|
||||
├── releases/ # Release packages (version 0.1.0+)
|
||||
├── src/
|
||||
│ ├── Admin/ # AdminController - license management UI
|
||||
│ ├── Api/ # RestApiController - license validation endpoints
|
||||
│ ├── Api/ # RestApiController, UpdateController - REST API endpoints
|
||||
│ ├── Checkout/ # CheckoutController - domain field at checkout
|
||||
│ ├── Email/ # WooCommerce email classes
|
||||
│ ├── Frontend/ # AccountController - customer licenses page
|
||||
│ ├── License/ # License model and LicenseManager
|
||||
│ └── Product/ # LicensedProduct type and LicensedProductType
|
||||
│ ├── Product/ # LicensedProduct type and LicensedProductType
|
||||
│ └── Update/ # PluginUpdateChecker - WordPress auto-update integration
|
||||
├── templates/
|
||||
│ ├── admin/ # Twig templates for admin views
|
||||
│ └── frontend/ # Twig templates for customer views
|
||||
@@ -219,11 +226,12 @@ Created on plugin activation via `Installer::createTables()`:
|
||||
|
||||
Base: `/wp-json/wc-licensed-product/v1/`
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ----------- | ------ | ------------------------------- |
|
||||
| `/validate` | POST | Validate license key for domain |
|
||||
| `/status` | POST | Get license status |
|
||||
| `/activate` | POST | Activate license on domain |
|
||||
| Endpoint | Method | Description |
|
||||
| --------------- | ------ | ---------------------------------- |
|
||||
| `/validate` | POST | Validate license key for domain |
|
||||
| `/status` | POST | Get license status |
|
||||
| `/activate` | POST | Activate license on domain |
|
||||
| `/update-check` | POST | Check for plugin updates (v0.6.0+) |
|
||||
|
||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||
|
||||
@@ -1656,3 +1664,182 @@ Fixed tab rendering bug in WooCommerce product edit page when switching to licen
|
||||
- Created release package: `releases/wc-licensed-product-0.5.15.zip` (862 KB)
|
||||
- SHA256: `47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278`
|
||||
- Committed to `dev` branch
|
||||
|
||||
### 2026-01-27 - Version 0.6.0 - WordPress Auto-Update System
|
||||
|
||||
**Overview:**
|
||||
|
||||
Major feature release implementing WordPress-style automatic updates. Licensed plugins can now receive updates through WordPress's native plugin update mechanism by checking against the license server.
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Api/UpdateController.php` - Server-side REST API endpoint for update checks
|
||||
- `src/Update/PluginUpdateChecker.php` - Client-side singleton for WordPress update integration
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Server-side `/update-check` REST API endpoint serving WordPress-compatible update information
|
||||
- Client-side `PluginUpdateChecker` singleton hooking into WordPress's native update system
|
||||
- Hooks: `pre_set_site_transient_update_plugins`, `plugins_api`, `http_request_args`
|
||||
- New "Auto-Updates" settings subtab with enable/disable toggle and check frequency
|
||||
- Configurable cache TTL for update checks (1-168 hours, default: 12)
|
||||
- Secure download authentication via `X-License-Key` header
|
||||
- Response signing support for tamper-proof update responses
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Plugin.php` - Added UpdateController and PluginUpdateChecker initialization
|
||||
- `src/Admin/SettingsController.php` - Added 'auto-updates' section with settings
|
||||
- `openapi.json` - Documented `/update-check` endpoint with request/response schemas
|
||||
- `languages/*` - Updated translations for new strings
|
||||
|
||||
**Settings Controller Changes:**
|
||||
|
||||
- Added `'auto-updates'` to `getSections()` for sub-tab navigation
|
||||
- New `getAutoUpdatesSettings()` method returning enable/frequency settings
|
||||
- New static methods: `isAutoUpdateEnabled()`, `getUpdateCheckFrequency()`
|
||||
|
||||
**UpdateController API:**
|
||||
|
||||
- Endpoint: `POST /wp-json/wc-licensed-product/v1/update-check`
|
||||
- Request: `license_key`, `domain`, `plugin_slug` (optional), `current_version` (optional)
|
||||
- Response: `update_available`, `version`, `download_url`, `package`, `changelog`, `tested`, `requires`, `requires_php`, etc.
|
||||
- License validation before serving update info
|
||||
- Secure download URL generation using existing DownloadController patterns
|
||||
|
||||
**PluginUpdateChecker Features:**
|
||||
|
||||
- Singleton pattern with `getInstance()`
|
||||
- Caching via WordPress transients (`wclp_update_info`)
|
||||
- Automatic cache clearing on settings save
|
||||
- Only activates when license server URL is configured and not self-licensing
|
||||
- `forceUpdateCheck()` method for manual refresh
|
||||
|
||||
**Configuration:**
|
||||
|
||||
To disable auto-updates programmatically:
|
||||
|
||||
```php
|
||||
define('WC_LICENSE_DISABLE_AUTO_UPDATE', true);
|
||||
```
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Update checker only registers when `SettingsController::getPluginLicenseServerUrl()` returns a value
|
||||
- Self-licensing detection prevents circular update checks (via `PluginLicenseChecker::isSelfLicensing()`)
|
||||
- Download URLs include license key in `X-License-Key` header for server-side verification
|
||||
- Uses Symfony HttpClient for server requests with 15s timeout
|
||||
- Cache TTL configurable from 1-168 hours in settings
|
||||
- OpenAPI spec updated to version 0.6.0 with full `/update-check` documentation
|
||||
|
||||
**Release v0.6.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.6.0.zip` (1.1 MB)
|
||||
- SHA256: `171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027`
|
||||
- Tagged as `v0.6.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-27 - Version 0.6.1 - UI Improvements & Bug Fixes
|
||||
|
||||
**Overview:**
|
||||
|
||||
Bug fix and improvement release addressing admin license testing, auto-update settings, and customer license filtering.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Filter functionality on customer account licenses page (filter by product or domain)
|
||||
- Split auto-update settings into "Enable Update Notifications" and "Automatically Install Updates"
|
||||
- WordPress `auto_update_plugin` filter integration for automatic installation
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Fixed admin license test popup showing empty product field
|
||||
- Removed version field from test popup (version_id is only set for version-bound licenses)
|
||||
- `handleAjaxTestLicense()` now enriches response with product name
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/AdminController.php` - Enriched test license response with product name
|
||||
- `src/Admin/SettingsController.php` - Split auto-update settings, added static helper methods
|
||||
- `src/Update/PluginUpdateChecker.php` - Added `auto_update_plugin` filter, use new settings methods
|
||||
- `src/Frontend/AccountController.php` - Added filter functionality with `applyLicenseFilters()` method
|
||||
- `templates/frontend/licenses.html.twig` - Added filter form with product and domain dropdowns
|
||||
- `templates/admin/licenses.html.twig` - Removed version row from test license modal
|
||||
- `assets/css/frontend.css` - Added responsive styles for filter form
|
||||
- `languages/*` - Updated all translation files
|
||||
|
||||
**New methods in SettingsController:**
|
||||
|
||||
- `isUpdateNotificationEnabled()` - Check if update notifications are enabled
|
||||
- `isAutoInstallEnabled()` - Check if auto-install is enabled (requires notifications enabled)
|
||||
|
||||
**New methods in AccountController:**
|
||||
|
||||
- `applyLicenseFilters()` - Filter licenses by product ID and/or domain
|
||||
- `getFilterOptions()` - Get unique products and domains for filter dropdowns
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Filter form uses GET parameters: `filter_product` and `filter_domain`
|
||||
- Auto-install setting is disabled (greyed out) when update notifications are disabled
|
||||
- License test popup now only shows Product and Expires fields (version removed)
|
||||
- Domain filter uses case-insensitive partial matching via `stripos()`
|
||||
|
||||
**Dependency Updates:**
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1
|
||||
|
||||
### 2026-01-28 - Version 0.7.0 - Security Hardening
|
||||
|
||||
**Overview:**
|
||||
|
||||
Security-focused release with comprehensive audit and hardening. Performed OWASP Top 10 testing against live shop (shop.magdev.cc) and fixed identified vulnerabilities.
|
||||
|
||||
**Security Audit Results:**
|
||||
|
||||
- SQL injection: Protected (prepared statements throughout)
|
||||
- CSRF: Protected (nonce verification on all forms/AJAX)
|
||||
- Rate limiting: Working (429 responses after ~28 requests)
|
||||
- Access control: Working (403 for unauthorized access)
|
||||
- HTTPS: Enforced with proper redirect
|
||||
- Missing security headers noted: X-Frame-Options, HSTS, CSP (server-level configuration)
|
||||
|
||||
**Critical Fixes:**
|
||||
|
||||
- **XSS in checkout-blocks.js**: Replaced `innerHTML` template literals with safe DOM construction using `document.createElement()` and `textContent`
|
||||
- **IP Detection**: UpdateController was using raw `$_SERVER['REMOTE_ADDR']` without proxy support - now uses shared `IpDetectionTrait`
|
||||
|
||||
**New Files:**
|
||||
|
||||
- `src/Api/IpDetectionTrait.php` - Shared IP detection with proxy header support (Cloudflare, X-Forwarded-For, X-Real-IP)
|
||||
- `src/Common/RateLimitTrait.php` - Reusable rate limiting for frontend operations
|
||||
|
||||
**Security Enhancements:**
|
||||
|
||||
- Added rate limiting to license transfers (5/hour per user)
|
||||
- Added rate limiting to file downloads (30/hour per user)
|
||||
- Added CSV import limits: 2MB max file size, 1000 max rows, 5-minute cooldown
|
||||
- Added JSON error handling in StoreApiExtension
|
||||
- Added license ID validation in frontend.js to prevent selector injection
|
||||
|
||||
**Modified Files:**
|
||||
|
||||
- `assets/js/checkout-blocks.js` - XSS-safe DOM construction
|
||||
- `assets/js/frontend.js` - Added `sanitizeForSelector()` helper
|
||||
- `src/Api/RestApiController.php` - Use IpDetectionTrait, remove duplicate methods
|
||||
- `src/Api/UpdateController.php` - Use IpDetectionTrait for rate limiting
|
||||
- `src/Admin/AdminController.php` - CSV import security limits
|
||||
- `src/Frontend/AccountController.php` - Transfer rate limiting
|
||||
- `src/Frontend/DownloadController.php` - Download rate limiting
|
||||
- `src/Checkout/StoreApiExtension.php` - JSON error handling
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- IpDetectionTrait supports `WC_LICENSE_TRUSTED_PROXIES` constant for proxy configuration
|
||||
- RateLimitTrait uses WordPress transients with user ID-based keys
|
||||
- CSV import constants: `MAX_IMPORT_FILE_SIZE = 2097152`, `MAX_IMPORT_ROWS = 1000`, `IMPORT_RATE_LIMIT_WINDOW = 300`
|
||||
|
||||
**Release v0.7.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.7.0.zip` (883 KB)
|
||||
- SHA256: `12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18`
|
||||
- Tagged as `v0.7.0` and pushed to `main` branch
|
||||
|
||||
@@ -37,6 +37,80 @@
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
/* Filter Form */
|
||||
.wclp-filter-form {
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1em;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wclp-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.wclp-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.wclp-filter-field label {
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.wclp-filter-field select {
|
||||
width: 100%;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.wclp-filter-field select:focus {
|
||||
border-color: #0073aa;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px #0073aa;
|
||||
}
|
||||
|
||||
.wclp-filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.wclp-filter-actions .button {
|
||||
padding: 0.5em 1em;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wclp-filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wclp-filter-field {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.wclp-filter-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wclp-filter-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* License Packages */
|
||||
.woocommerce-licenses {
|
||||
display: flex;
|
||||
|
||||
@@ -367,64 +367,90 @@
|
||||
container.className = 'wc-block-components-licensed-product-wrapper';
|
||||
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
|
||||
|
||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||
container.innerHTML = `
|
||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
|
||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||||
${settings.fieldDescription || 'Enter a unique domain for each license.'}
|
||||
</p>
|
||||
${settings.licensedProducts.map(product => {
|
||||
const productKey = product.variation_id && product.variation_id > 0
|
||||
? `${product.product_id}_${product.variation_id}`
|
||||
: product.product_id;
|
||||
const durationLabel = product.duration_label || '';
|
||||
const displayName = durationLabel
|
||||
? `${product.name} (${durationLabel})`
|
||||
: product.name;
|
||||
// Helper function to create elements with text content (XSS-safe)
|
||||
function createEl(tag, textContent, styles) {
|
||||
var el = document.createElement(tag);
|
||||
if (textContent) el.textContent = textContent;
|
||||
if (styles) el.style.cssText = styles;
|
||||
return el;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
||||
<strong style="display: block; margin-bottom: 8px;">
|
||||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
||||
</strong>
|
||||
${Array.from({ length: product.quantity }, (_, i) => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: block; margin-bottom: 4px;">
|
||||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_domains[${productKey}][${i}]"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
${product.variation_id && product.variation_id > 0 ? `
|
||||
<input type="hidden"
|
||||
name="licensed_variation_ids[${productKey}]"
|
||||
value="${product.variation_id}"
|
||||
/>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}).join('')}
|
||||
`;
|
||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||
// Build header safely using DOM methods
|
||||
var header = createEl('h4', settings.sectionTitle || 'License Domains', 'margin: 0 0 8px 0;');
|
||||
container.appendChild(header);
|
||||
|
||||
var desc = createEl('p', settings.fieldDescription || 'Enter a unique domain for each license.',
|
||||
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||
container.appendChild(desc);
|
||||
|
||||
// Build product sections
|
||||
settings.licensedProducts.forEach(function(product) {
|
||||
var productKey = product.variation_id && product.variation_id > 0
|
||||
? product.product_id + '_' + product.variation_id
|
||||
: String(product.product_id);
|
||||
var durationLabel = product.duration_label || '';
|
||||
var displayName = durationLabel
|
||||
? product.name + ' (' + durationLabel + ')'
|
||||
: product.name;
|
||||
|
||||
var productDiv = createEl('div', null, 'margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;');
|
||||
|
||||
var nameEl = createEl('strong', displayName + (product.quantity > 1 ? ' ×' + product.quantity : ''),
|
||||
'display: block; margin-bottom: 8px;');
|
||||
productDiv.appendChild(nameEl);
|
||||
|
||||
// Create input fields for each quantity
|
||||
for (var i = 0; i < product.quantity; i++) {
|
||||
var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
|
||||
|
||||
var label = createEl('label', (settings.licenseLabel || 'License %d:').replace('%d', i + 1),
|
||||
'display: block; margin-bottom: 4px;');
|
||||
fieldDiv.appendChild(label);
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = 'licensed_domains[' + productKey + '][' + i + ']';
|
||||
input.placeholder = settings.fieldPlaceholder || 'example.com';
|
||||
input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;';
|
||||
fieldDiv.appendChild(input);
|
||||
|
||||
// Hidden variation ID if applicable
|
||||
if (product.variation_id && product.variation_id > 0) {
|
||||
var hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'licensed_variation_ids[' + productKey + ']';
|
||||
hiddenInput.value = String(product.variation_id);
|
||||
fieldDiv.appendChild(hiddenInput);
|
||||
}
|
||||
|
||||
productDiv.appendChild(fieldDiv);
|
||||
}
|
||||
|
||||
container.appendChild(productDiv);
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4>
|
||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||||
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
|
||||
</p>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: block; margin-bottom: 4px;">
|
||||
${settings.singleDomainLabel || 'Domain'}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_product_domain"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
// Single domain mode - build safely using DOM methods
|
||||
var header = createEl('h4', settings.sectionTitle || 'License Domain', 'margin: 0 0 8px 0;');
|
||||
container.appendChild(header);
|
||||
|
||||
var desc = createEl('p', settings.fieldDescription || 'Enter the domain where you will use the license.',
|
||||
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||
container.appendChild(desc);
|
||||
|
||||
var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
|
||||
|
||||
var label = createEl('label', settings.singleDomainLabel || 'Domain', 'display: block; margin-bottom: 4px;');
|
||||
fieldDiv.appendChild(label);
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = 'licensed_product_domain';
|
||||
input.placeholder = settings.fieldPlaceholder || 'example.com';
|
||||
input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;';
|
||||
fieldDiv.appendChild(input);
|
||||
|
||||
container.appendChild(fieldDiv);
|
||||
}
|
||||
|
||||
if (contactInfo) {
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
$modal: null,
|
||||
$form: null,
|
||||
|
||||
/**
|
||||
* Sanitize a value for safe use in jQuery selectors
|
||||
* License IDs should be numeric only
|
||||
*/
|
||||
sanitizeForSelector: function(value) {
|
||||
return String(value).replace(/[^\d]/g, '');
|
||||
},
|
||||
|
||||
init: function() {
|
||||
this.$modal = $('#wclp-transfer-modal');
|
||||
this.$form = $('#wclp-transfer-form');
|
||||
@@ -171,6 +179,11 @@
|
||||
var licenseId = $btn.data('license-id');
|
||||
var currentDomain = $btn.data('current-domain');
|
||||
|
||||
// Validate license ID is numeric
|
||||
if (!licenseId || !/^\d+$/.test(String(licenseId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#transfer-license-id').val(licenseId);
|
||||
$('#transfer-current-domain').text(currentDomain);
|
||||
$('#transfer-new-domain').val('');
|
||||
@@ -235,9 +248,12 @@
|
||||
.removeClass('error').addClass('success').show();
|
||||
|
||||
// Update the domain display in the license card
|
||||
var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]');
|
||||
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
||||
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain);
|
||||
var safeLicenseId = self.sanitizeForSelector(licenseId);
|
||||
if (safeLicenseId) {
|
||||
var $domainDisplay = $('.license-domain-display[data-license-id="' + safeLicenseId + '"]');
|
||||
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
||||
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain);
|
||||
}
|
||||
|
||||
// Close modal after a short delay
|
||||
setTimeout(function() {
|
||||
|
||||
4
composer.lock
generated
4
composer.lock
generated
@@ -12,7 +12,7 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
||||
"reference": "5e4b5a970f75d0163c5496581d963a24ade4f276"
|
||||
"reference": "760e1e752a0c088fa634cf7ff678e0735ed525a4"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
@@ -52,7 +52,7 @@
|
||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
||||
},
|
||||
"time": "2026-01-26T15:54:37+00:00"
|
||||
"time": "2026-01-27T19:52:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1
releases/wc-licensed-product-0.6.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.6.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027 releases/wc-licensed-product-0.6.0.zip
|
||||
1
releases/wc-licensed-product-0.6.1.zip.sha256
Normal file
1
releases/wc-licensed-product-0.6.1.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
f1f1cbdfdd6cda7b20cbd2b88ab4697cde38d987e04cda1f52e885d7818d32f5 wc-licensed-product-0.6.1.zip
|
||||
BIN
releases/wc-licensed-product-0.7.0.zip
Normal file
BIN
releases/wc-licensed-product-0.7.0.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.7.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.7.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18 wc-licensed-product-0.7.0.zip
|
||||
@@ -18,6 +18,21 @@ use Twig\Environment;
|
||||
*/
|
||||
final class AdminController
|
||||
{
|
||||
/**
|
||||
* Maximum CSV file size in bytes (2MB)
|
||||
*/
|
||||
private const MAX_IMPORT_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Maximum rows to import per file
|
||||
*/
|
||||
private const MAX_IMPORT_ROWS = 1000;
|
||||
|
||||
/**
|
||||
* Minimum time between imports in seconds (5 minutes)
|
||||
*/
|
||||
private const IMPORT_RATE_LIMIT_WINDOW = 300;
|
||||
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
@@ -379,6 +394,19 @@ final class AdminController
|
||||
// Validate the license using LicenseManager
|
||||
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||
|
||||
// Enrich result with product name for display in the popup
|
||||
if (!empty($result['valid']) && isset($result['license'])) {
|
||||
// Get product name
|
||||
$productId = $result['license']['product_id'] ?? null;
|
||||
if ($productId) {
|
||||
$product = wc_get_product($productId);
|
||||
$result['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
// Flatten expires_at for easier access in JavaScript
|
||||
$result['expires_at'] = $result['license']['expires_at'] ?? null;
|
||||
}
|
||||
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
@@ -640,6 +668,23 @@ final class AdminController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check file size limit
|
||||
if ($file['size'] > self::MAX_IMPORT_FILE_SIZE) {
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=size'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check rate limit for imports
|
||||
$lastImport = get_transient('wclp_last_csv_import_' . get_current_user_id());
|
||||
if ($lastImport !== false && (time() - $lastImport) < self::IMPORT_RATE_LIMIT_WINDOW) {
|
||||
$retryAfter = self::IMPORT_RATE_LIMIT_WINDOW - (time() - $lastImport);
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=rate_limit&retry_after=' . $retryAfter));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set rate limit marker
|
||||
set_transient('wclp_last_csv_import_' . get_current_user_id(), time(), self::IMPORT_RATE_LIMIT_WINDOW);
|
||||
|
||||
// Read the CSV file
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
if (!$handle) {
|
||||
@@ -666,6 +711,7 @@ final class AdminController
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$errors = [];
|
||||
$rowCount = 0;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
// Skip empty rows
|
||||
@@ -673,6 +719,24 @@ final class AdminController
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check row limit
|
||||
$rowCount++;
|
||||
if ($rowCount > self::MAX_IMPORT_ROWS) {
|
||||
fclose($handle);
|
||||
$this->addNotice(
|
||||
sprintf(
|
||||
/* translators: %1$d: max rows, %2$d: imported count, %3$d: updated count */
|
||||
__('Import stopped: Maximum of %1$d rows allowed. %2$d imported, %3$d updated.', 'wc-licensed-product'),
|
||||
self::MAX_IMPORT_ROWS,
|
||||
$imported,
|
||||
$updated
|
||||
),
|
||||
'warning'
|
||||
);
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&import_success=partial'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Map CSV columns (expected format from export):
|
||||
// ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At
|
||||
// For import we need: License Key (or generate), Product ID, Customer ID, Domain, Status, Max Activations, Expires At
|
||||
@@ -1605,12 +1669,11 @@ final class AdminController
|
||||
if (result.valid) {
|
||||
html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>';
|
||||
html += '<table class="widefat striped"><tbody>';
|
||||
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>';
|
||||
html += '<tr><th><?php echo esc_js(__('Version', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
|
||||
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></td></tr>';
|
||||
if (result.expires_at) {
|
||||
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
|
||||
} else {
|
||||
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></td></tr>';
|
||||
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><span class="license-lifetime"><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></span></td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
@@ -1688,6 +1751,21 @@ final class AdminController
|
||||
case 'read':
|
||||
esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product');
|
||||
break;
|
||||
case 'size':
|
||||
printf(
|
||||
/* translators: %s: max file size */
|
||||
esc_html__('File too large. Maximum size is %s.', 'wc-licensed-product'),
|
||||
esc_html(size_format(self::MAX_IMPORT_FILE_SIZE))
|
||||
);
|
||||
break;
|
||||
case 'rate_limit':
|
||||
$retryAfter = isset($_GET['retry_after']) ? absint($_GET['retry_after']) : self::IMPORT_RATE_LIMIT_WINDOW;
|
||||
printf(
|
||||
/* translators: %d: seconds to wait */
|
||||
esc_html__('Please wait %d seconds before importing again.', 'wc-licensed-product'),
|
||||
$retryAfter
|
||||
);
|
||||
break;
|
||||
default:
|
||||
esc_html_e('An error occurred during import.', 'wc-licensed-product');
|
||||
}
|
||||
@@ -1696,6 +1774,20 @@ final class AdminController
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="notice notice-info" style="max-width: 800px;">
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %1$s: max file size, %2$d: max rows, %3$d: rate limit minutes */
|
||||
esc_html__('Import limits: Maximum file size %1$s, maximum %2$d rows per import. You can import again after %3$d minutes.', 'wc-licensed-product'),
|
||||
esc_html(size_format(self::MAX_IMPORT_FILE_SIZE)),
|
||||
self::MAX_IMPORT_ROWS,
|
||||
(int) (self::IMPORT_RATE_LIMIT_WINDOW / 60)
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: 800px; padding: 20px;">
|
||||
<h2><?php esc_html_e('Import Licenses from CSV', 'wc-licensed-product'); ?></h2>
|
||||
|
||||
|
||||
@@ -167,6 +167,8 @@ final class SettingsController
|
||||
*/
|
||||
private function getAutoUpdatesSettings(): array
|
||||
{
|
||||
$autoInstallDisabled = !self::isUpdateNotificationEnabled();
|
||||
|
||||
return [
|
||||
'auto_update_section_title' => [
|
||||
'name' => __('Auto-Updates', 'wc-licensed-product'),
|
||||
@@ -174,13 +176,23 @@ final class SettingsController
|
||||
'desc' => __('Configure automatic plugin updates from the license server.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_section_auto_update',
|
||||
],
|
||||
'plugin_auto_update_enabled' => [
|
||||
'name' => __('Enable Auto-Updates', 'wc-licensed-product'),
|
||||
'update_notification_enabled' => [
|
||||
'name' => __('Enable Update Notifications', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => __('Automatically check for and receive plugin updates from the license server.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_plugin_auto_update_enabled',
|
||||
'desc' => __('Check for and display available updates from the license server in WordPress admin.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_update_notification_enabled',
|
||||
'default' => 'yes',
|
||||
],
|
||||
'plugin_auto_install_enabled' => [
|
||||
'name' => __('Automatically Install Updates', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => $autoInstallDisabled
|
||||
? __('Enable "Update Notifications" above to use this option.', 'wc-licensed-product')
|
||||
: __('Automatically install updates when they become available (requires update notifications enabled).', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_plugin_auto_install_enabled',
|
||||
'default' => 'no',
|
||||
'custom_attributes' => $autoInstallDisabled ? ['disabled' => 'disabled'] : [],
|
||||
],
|
||||
'update_check_frequency' => [
|
||||
'name' => __('Check Frequency (Hours)', 'wc-licensed-product'),
|
||||
'type' => 'number',
|
||||
@@ -501,11 +513,32 @@ final class SettingsController
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-updates are enabled
|
||||
* Check if update notifications are enabled
|
||||
*/
|
||||
public static function isUpdateNotificationEnabled(): bool
|
||||
{
|
||||
return get_option('wc_licensed_product_update_notification_enabled', 'yes') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-updates are enabled (legacy alias for isUpdateNotificationEnabled)
|
||||
*/
|
||||
public static function isAutoUpdateEnabled(): bool
|
||||
{
|
||||
return get_option('wc_licensed_product_plugin_auto_update_enabled', 'yes') === 'yes';
|
||||
return self::isUpdateNotificationEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if automatic installation of updates is enabled
|
||||
*/
|
||||
public static function isAutoInstallEnabled(): bool
|
||||
{
|
||||
// Auto-install requires notifications to be enabled first
|
||||
if (!self::isUpdateNotificationEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return get_option('wc_licensed_product_plugin_auto_install_enabled', 'no') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
168
src/Api/IpDetectionTrait.php
Normal file
168
src/Api/IpDetectionTrait.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* IP Detection Trait
|
||||
*
|
||||
* Provides shared IP detection logic for API controllers with proxy support.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Api
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
/**
|
||||
* Trait for detecting client IP addresses with proxy support
|
||||
*
|
||||
* Security note: Only trust proxy headers when explicitly configured.
|
||||
* Set WC_LICENSE_TRUSTED_PROXIES constant in wp-config.php to enable proxy header support.
|
||||
*
|
||||
* Examples:
|
||||
* define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
|
||||
* define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
|
||||
*/
|
||||
trait IpDetectionTrait
|
||||
{
|
||||
/**
|
||||
* Get client IP address with proxy support
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
protected function getClientIp(): string
|
||||
{
|
||||
// Get the direct connection IP first
|
||||
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
// Only check proxy headers if we're behind a trusted proxy
|
||||
if ($this->isTrustedProxy($remoteAddr)) {
|
||||
// Check headers in order of trust preference
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and return direct connection IP
|
||||
if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) {
|
||||
return $remoteAddr;
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given IP is a trusted proxy
|
||||
*
|
||||
* @param string $ip The IP address to check
|
||||
* @return bool Whether the IP is a trusted proxy
|
||||
*/
|
||||
protected function isTrustedProxy(string $ip): bool
|
||||
{
|
||||
// Check if trusted proxies are configured
|
||||
if (!defined('WC_LICENSE_TRUSTED_PROXIES')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trustedProxies = WC_LICENSE_TRUSTED_PROXIES;
|
||||
|
||||
// Handle string constant (comma-separated list)
|
||||
if (is_string($trustedProxies)) {
|
||||
$trustedProxies = array_map('trim', explode(',', $trustedProxies));
|
||||
}
|
||||
|
||||
if (!is_array($trustedProxies)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for special keywords
|
||||
if (in_array('CLOUDFLARE', $trustedProxies, true)) {
|
||||
if ($this->isCloudflareIp($ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check direct IP match or CIDR notation
|
||||
foreach ($trustedProxies as $proxy) {
|
||||
if ($proxy === $ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Support CIDR notation
|
||||
if (str_contains($proxy, '/') && $this->ipMatchesCidr($ip, $proxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP is in Cloudflare range
|
||||
*
|
||||
* @param string $ip The IP to check
|
||||
* @return bool Whether IP belongs to Cloudflare
|
||||
*/
|
||||
protected function isCloudflareIp(string $ip): bool
|
||||
{
|
||||
// Cloudflare IPv4 ranges (as of 2024)
|
||||
$cloudflareRanges = [
|
||||
'173.245.48.0/20',
|
||||
'103.21.244.0/22',
|
||||
'103.22.200.0/22',
|
||||
'103.31.4.0/22',
|
||||
'141.101.64.0/18',
|
||||
'108.162.192.0/18',
|
||||
'190.93.240.0/20',
|
||||
'188.114.96.0/20',
|
||||
'197.234.240.0/22',
|
||||
'198.41.128.0/17',
|
||||
'162.158.0.0/15',
|
||||
'104.16.0.0/13',
|
||||
'104.24.0.0/14',
|
||||
'172.64.0.0/13',
|
||||
'131.0.72.0/22',
|
||||
];
|
||||
|
||||
foreach ($cloudflareRanges as $range) {
|
||||
if ($this->ipMatchesCidr($ip, $range)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches a CIDR range
|
||||
*
|
||||
* @param string $ip The IP to check
|
||||
* @param string $cidr The CIDR range (e.g., "192.168.1.0/24")
|
||||
* @return bool Whether the IP matches the CIDR range
|
||||
*/
|
||||
protected function ipMatchesCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr);
|
||||
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
|
||||
!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
return ($ipLong & $mask) === ($subnetLong & $mask);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use WP_REST_Server;
|
||||
*/
|
||||
final class RestApiController
|
||||
{
|
||||
use IpDetectionTrait;
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
/**
|
||||
@@ -115,154 +116,6 @@ final class RestApiController
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* Security note: Only trust proxy headers when explicitly configured.
|
||||
* Set WC_LICENSE_TRUSTED_PROXIES constant or configure trusted_proxies
|
||||
* in wp-config.php to enable proxy header support.
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
// Get the direct connection IP first
|
||||
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
// Only check proxy headers if we're behind a trusted proxy
|
||||
if ($this->isTrustedProxy($remoteAddr)) {
|
||||
// Check headers in order of trust preference
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and return direct connection IP
|
||||
if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) {
|
||||
return $remoteAddr;
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given IP is a trusted proxy
|
||||
*
|
||||
* @param string $ip The IP address to check
|
||||
* @return bool Whether the IP is a trusted proxy
|
||||
*/
|
||||
private function isTrustedProxy(string $ip): bool
|
||||
{
|
||||
// Check if trusted proxies are configured
|
||||
if (!defined('WC_LICENSE_TRUSTED_PROXIES')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trustedProxies = WC_LICENSE_TRUSTED_PROXIES;
|
||||
|
||||
// Handle string constant (comma-separated list)
|
||||
if (is_string($trustedProxies)) {
|
||||
$trustedProxies = array_map('trim', explode(',', $trustedProxies));
|
||||
}
|
||||
|
||||
if (!is_array($trustedProxies)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for special keywords
|
||||
if (in_array('CLOUDFLARE', $trustedProxies, true)) {
|
||||
// Cloudflare IP ranges (simplified - in production, fetch from Cloudflare API)
|
||||
if ($this->isCloudflareIp($ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check direct IP match or CIDR notation
|
||||
foreach ($trustedProxies as $proxy) {
|
||||
if ($proxy === $ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Support CIDR notation
|
||||
if (str_contains($proxy, '/') && $this->ipMatchesCidr($ip, $proxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP is in Cloudflare range
|
||||
*
|
||||
* @param string $ip The IP to check
|
||||
* @return bool Whether IP belongs to Cloudflare
|
||||
*/
|
||||
private function isCloudflareIp(string $ip): bool
|
||||
{
|
||||
// Cloudflare IPv4 ranges (as of 2024)
|
||||
$cloudflareRanges = [
|
||||
'173.245.48.0/20',
|
||||
'103.21.244.0/22',
|
||||
'103.22.200.0/22',
|
||||
'103.31.4.0/22',
|
||||
'141.101.64.0/18',
|
||||
'108.162.192.0/18',
|
||||
'190.93.240.0/20',
|
||||
'188.114.96.0/20',
|
||||
'197.234.240.0/22',
|
||||
'198.41.128.0/17',
|
||||
'162.158.0.0/15',
|
||||
'104.16.0.0/13',
|
||||
'104.24.0.0/14',
|
||||
'172.64.0.0/13',
|
||||
'131.0.72.0/22',
|
||||
];
|
||||
|
||||
foreach ($cloudflareRanges as $range) {
|
||||
if ($this->ipMatchesCidr($ip, $range)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches a CIDR range
|
||||
*
|
||||
* @param string $ip The IP to check
|
||||
* @param string $cidr The CIDR range (e.g., "192.168.1.0/24")
|
||||
* @return bool Whether the IP matches the CIDR range
|
||||
*/
|
||||
private function ipMatchesCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr);
|
||||
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
|
||||
!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
return ($ipLong & $mask) === ($subnetLong & $mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ use WP_REST_Server;
|
||||
*/
|
||||
final class UpdateController
|
||||
{
|
||||
use IpDetectionTrait;
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
/**
|
||||
@@ -83,7 +84,7 @@ final class UpdateController
|
||||
*/
|
||||
private function checkRateLimit(): ?WP_REST_Response
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$ip = $this->getClientIp();
|
||||
$transientKey = 'wclp_update_rate_' . md5($ip);
|
||||
$rateLimit = $this->getRateLimit();
|
||||
$rateWindow = $this->getRateWindow();
|
||||
|
||||
@@ -200,6 +200,11 @@ final class StoreApiExtension
|
||||
{
|
||||
$requestData = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Handle JSON decode errors gracefully
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$requestData = null;
|
||||
}
|
||||
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->processMultiDomainOrder($order, $requestData);
|
||||
} else {
|
||||
@@ -270,7 +275,7 @@ final class StoreApiExtension
|
||||
// Check for wclp_license_domains (from our hidden input - JSON string)
|
||||
if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
|
||||
$parsed = json_decode($requestData['wclp_license_domains'], true);
|
||||
if (is_array($parsed)) {
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($parsed)) {
|
||||
$domainData = $this->normalizeDomainsData($parsed);
|
||||
}
|
||||
}
|
||||
|
||||
91
src/Common/RateLimitTrait.php
Normal file
91
src/Common/RateLimitTrait.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limit Trait
|
||||
*
|
||||
* Provides rate limiting functionality for frontend operations.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Common
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Common;
|
||||
|
||||
/**
|
||||
* Trait for implementing rate limiting on user actions
|
||||
*
|
||||
* Uses WordPress transients for storage. Rate limits are per-user when logged in,
|
||||
* or per-IP when not logged in.
|
||||
*/
|
||||
trait RateLimitTrait
|
||||
{
|
||||
/**
|
||||
* Check rate limit for a user action
|
||||
*
|
||||
* @param string $action Action identifier (e.g., 'transfer', 'download')
|
||||
* @param int $limit Maximum attempts per window
|
||||
* @param int $window Time window in seconds
|
||||
* @return bool True if within limit, false if exceeded
|
||||
*/
|
||||
protected function checkUserRateLimit(string $action, int $limit, int $window): bool
|
||||
{
|
||||
$userId = get_current_user_id();
|
||||
$key = $userId > 0
|
||||
? (string) $userId
|
||||
: 'ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
|
||||
$transientKey = 'wclp_rate_' . $action . '_' . $key;
|
||||
$data = get_transient($transientKey);
|
||||
|
||||
if ($data === false) {
|
||||
// First request, start counting
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $window);
|
||||
return true;
|
||||
}
|
||||
|
||||
$count = (int) ($data['count'] ?? 0);
|
||||
$start = (int) ($data['start'] ?? time());
|
||||
|
||||
// Check if window has expired
|
||||
if (time() - $start >= $window) {
|
||||
// Reset counter
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $window);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if ($count >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $window);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time until rate limit resets
|
||||
*
|
||||
* @param string $action Action identifier
|
||||
* @param int $window Time window in seconds (must match the one used in checkUserRateLimit)
|
||||
* @return int Seconds until rate limit resets, or 0 if not rate limited
|
||||
*/
|
||||
protected function getRateLimitRetryAfter(string $action, int $window): int
|
||||
{
|
||||
$userId = get_current_user_id();
|
||||
$key = $userId > 0
|
||||
? (string) $userId
|
||||
: 'ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
|
||||
$transientKey = 'wclp_rate_' . $action . '_' . $key;
|
||||
$data = get_transient($transientKey);
|
||||
|
||||
if ($data === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$start = (int) ($data['start'] ?? time());
|
||||
|
||||
return max(0, $window - (time() - $start));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ declare(strict_types=1);
|
||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Twig\Environment;
|
||||
@@ -19,6 +20,8 @@ use Twig\Environment;
|
||||
*/
|
||||
final class AccountController
|
||||
{
|
||||
use RateLimitTrait;
|
||||
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
@@ -106,23 +109,104 @@ final class AccountController
|
||||
return;
|
||||
}
|
||||
|
||||
// Get filter parameters from URL
|
||||
$filterProductId = isset($_GET['filter_product']) ? absint($_GET['filter_product']) : 0;
|
||||
$filterDomain = isset($_GET['filter_domain']) ? sanitize_text_field(wp_unslash($_GET['filter_domain'])) : '';
|
||||
|
||||
$licenses = $this->licenseManager->getLicensesByCustomer($customerId);
|
||||
|
||||
// Apply filters
|
||||
$filteredLicenses = $this->applyLicenseFilters($licenses, $filterProductId, $filterDomain);
|
||||
|
||||
// Group licenses by product+order into "packages"
|
||||
$packages = $this->groupLicensesIntoPackages($licenses);
|
||||
$packages = $this->groupLicensesIntoPackages($filteredLicenses);
|
||||
|
||||
// Get unique products and domains for filter dropdowns
|
||||
$filterOptions = $this->getFilterOptions($licenses);
|
||||
|
||||
try {
|
||||
echo $this->twig->render('frontend/licenses.html.twig', [
|
||||
'packages' => $packages,
|
||||
'has_packages' => !empty($packages),
|
||||
'signing_enabled' => ResponseSigner::isSigningEnabled(),
|
||||
'filter_products' => $filterOptions['products'],
|
||||
'filter_domains' => $filterOptions['domains'],
|
||||
'current_filter_product' => $filterProductId,
|
||||
'current_filter_domain' => $filterDomain,
|
||||
'is_filtered' => $filterProductId > 0 || !empty($filterDomain),
|
||||
'licenses_url' => wc_get_account_endpoint_url('licenses'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to PHP template if Twig fails
|
||||
$this->displayLicensesFallback($packages);
|
||||
$this->displayLicensesFallback($packages, $filterOptions, $filterProductId, $filterDomain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to licenses
|
||||
*
|
||||
* @param array $licenses Array of License objects
|
||||
* @param int $productId Filter by product ID (0 for all)
|
||||
* @param string $domain Filter by domain (empty for all)
|
||||
* @return array Filtered array of License objects
|
||||
*/
|
||||
private function applyLicenseFilters(array $licenses, int $productId, string $domain): array
|
||||
{
|
||||
if ($productId === 0 && empty($domain)) {
|
||||
return $licenses;
|
||||
}
|
||||
|
||||
return array_filter($licenses, function ($license) use ($productId, $domain) {
|
||||
// Filter by product
|
||||
if ($productId > 0 && $license->getProductId() !== $productId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by domain (partial match)
|
||||
if (!empty($domain) && stripos($license->getDomain(), $domain) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique filter options from licenses
|
||||
*
|
||||
* @param array $licenses Array of License objects
|
||||
* @return array Array with 'products' and 'domains' keys
|
||||
*/
|
||||
private function getFilterOptions(array $licenses): array
|
||||
{
|
||||
$products = [];
|
||||
$domains = [];
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
// Collect unique products
|
||||
$productId = $license->getProductId();
|
||||
if (!isset($products[$productId])) {
|
||||
$product = wc_get_product($productId);
|
||||
$products[$productId] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
// Collect unique domains
|
||||
$domain = $license->getDomain();
|
||||
if (!in_array($domain, $domains, true)) {
|
||||
$domains[] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort products by name, domains alphabetically
|
||||
asort($products);
|
||||
sort($domains);
|
||||
|
||||
return [
|
||||
'products' => $products,
|
||||
'domains' => $domains,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group licenses into packages by product+order
|
||||
*
|
||||
@@ -217,10 +301,67 @@ final class AccountController
|
||||
/**
|
||||
* Fallback display method if Twig is unavailable
|
||||
*/
|
||||
private function displayLicensesFallback(array $packages): void
|
||||
{
|
||||
private function displayLicensesFallback(
|
||||
array $packages,
|
||||
array $filterOptions = [],
|
||||
int $currentFilterProduct = 0,
|
||||
string $currentFilterDomain = ''
|
||||
): void {
|
||||
$isFiltered = $currentFilterProduct > 0 || !empty($currentFilterDomain);
|
||||
$licensesUrl = wc_get_account_endpoint_url('licenses');
|
||||
|
||||
// Display filter form if we have filter options
|
||||
if (!empty($filterOptions['products']) || !empty($filterOptions['domains'])) {
|
||||
?>
|
||||
<div class="wclp-filter-form">
|
||||
<form method="get" action="<?php echo esc_url($licensesUrl); ?>">
|
||||
<div class="wclp-filter-row">
|
||||
<?php if (!empty($filterOptions['products'])): ?>
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_product"><?php esc_html_e('Product', 'wc-licensed-product'); ?></label>
|
||||
<select name="filter_product" id="filter_product">
|
||||
<option value=""><?php esc_html_e('All Products', 'wc-licensed-product'); ?></option>
|
||||
<?php foreach ($filterOptions['products'] as $id => $name): ?>
|
||||
<option value="<?php echo esc_attr($id); ?>" <?php selected($currentFilterProduct, $id); ?>>
|
||||
<?php echo esc_html($name); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($filterOptions['domains'])): ?>
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_domain"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></label>
|
||||
<select name="filter_domain" id="filter_domain">
|
||||
<option value=""><?php esc_html_e('All Domains', 'wc-licensed-product'); ?></option>
|
||||
<?php foreach ($filterOptions['domains'] as $domain): ?>
|
||||
<option value="<?php echo esc_attr($domain); ?>" <?php selected($currentFilterDomain, $domain); ?>>
|
||||
<?php echo esc_html($domain); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="wclp-filter-actions">
|
||||
<button type="submit" class="button"><?php esc_html_e('Filter', 'wc-licensed-product'); ?></button>
|
||||
<?php if ($isFiltered): ?>
|
||||
<a href="<?php echo esc_url($licensesUrl); ?>" class="button"><?php esc_html_e('Clear', 'wc-licensed-product'); ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
if (empty($packages)) {
|
||||
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
|
||||
if ($isFiltered) {
|
||||
echo '<p>' . esc_html__('No licenses found matching your filters.', 'wc-licensed-product') . '</p>';
|
||||
} else {
|
||||
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -437,6 +578,15 @@ final class AccountController
|
||||
*/
|
||||
public function handleTransferRequest(): void
|
||||
{
|
||||
// Rate limit: 5 transfer attempts per hour per user
|
||||
if (!$this->checkUserRateLimit('transfer', 5, 3600)) {
|
||||
$retryAfter = $this->getRateLimitRetryAfter('transfer', 3600);
|
||||
wp_send_json_error([
|
||||
'message' => __('Too many transfer attempts. Please try again later.', 'wc-licensed-product'),
|
||||
'retry_after' => $retryAfter,
|
||||
], 429);
|
||||
}
|
||||
|
||||
// Verify nonce
|
||||
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
|
||||
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
|
||||
|
||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
|
||||
@@ -17,6 +18,8 @@ use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
*/
|
||||
final class DownloadController
|
||||
{
|
||||
use RateLimitTrait;
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
|
||||
@@ -110,6 +113,15 @@ final class DownloadController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate limit: 30 downloads per hour per user
|
||||
if (!$this->checkUserRateLimit('download', 30, 3600)) {
|
||||
wp_die(
|
||||
__('Too many download attempts. Please try again later.', 'wc-licensed-product'),
|
||||
__('Download Error', 'wc-licensed-product'),
|
||||
['response' => 429]
|
||||
);
|
||||
}
|
||||
|
||||
// Get license
|
||||
$license = $this->licenseManager->getLicenseById($licenseId);
|
||||
if (!$license) {
|
||||
|
||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Update;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
@@ -74,8 +75,8 @@ final class PluginUpdateChecker
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Skip if auto-updates are disabled
|
||||
if ($this->isAutoUpdateDisabled()) {
|
||||
// Skip if update notifications are disabled
|
||||
if ($this->isUpdateNotificationDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,15 +89,19 @@ final class PluginUpdateChecker
|
||||
// Add authentication headers to download requests
|
||||
add_filter('http_request_args', [$this, 'addAuthHeaders'], 10, 2);
|
||||
|
||||
// Handle auto-install setting
|
||||
add_filter('auto_update_plugin', [$this, 'handleAutoInstall'], 10, 2);
|
||||
|
||||
// Clear cache on settings save
|
||||
add_action('update_option_wc_licensed_product_plugin_license_key', [$this, 'clearCache']);
|
||||
add_action('update_option_wc_licensed_product_plugin_license_server_url', [$this, 'clearCache']);
|
||||
add_action('update_option_wc_licensed_product_update_notification_enabled', [$this, 'clearCache']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-updates are disabled
|
||||
* Check if update notifications are disabled
|
||||
*/
|
||||
private function isAutoUpdateDisabled(): bool
|
||||
private function isUpdateNotificationDisabled(): bool
|
||||
{
|
||||
// Check constant
|
||||
if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) {
|
||||
@@ -104,8 +109,25 @@ final class PluginUpdateChecker
|
||||
}
|
||||
|
||||
// Check setting
|
||||
$enabled = get_option('wc_licensed_product_plugin_auto_update_enabled', 'yes');
|
||||
return $enabled !== 'yes';
|
||||
return !SettingsController::isUpdateNotificationEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-install setting for WordPress automatic updates
|
||||
*
|
||||
* @param bool|null $update The update decision
|
||||
* @param object $item The plugin update object
|
||||
* @return bool|null Whether to auto-update this plugin
|
||||
*/
|
||||
public function handleAutoInstall($update, $item): ?bool
|
||||
{
|
||||
// Only handle our plugin
|
||||
if (!isset($item->plugin) || $item->plugin !== $this->pluginBasename) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
// Return true to enable auto-install, false to disable, or null to use default
|
||||
return SettingsController::isAutoInstallEnabled() ? true : $update;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -424,12 +424,11 @@
|
||||
if (result.valid) {
|
||||
html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>';
|
||||
html += '<table class="widefat striped"><tbody>';
|
||||
html += '<tr><th>{{ __('Product') }}</th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>';
|
||||
html += '<tr><th>{{ __('Version') }}</th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
|
||||
html += '<tr><th>{{ __('Product') }}</th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></td></tr>';
|
||||
if (result.expires_at) {
|
||||
html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
|
||||
} else {
|
||||
html += '<tr><th>{{ __('Expires') }}</th><td>{{ __('Lifetime') }}</td></tr>';
|
||||
html += '<tr><th>{{ __('Expires') }}</th><td><span class="license-lifetime">{{ __('Lifetime') }}</span></td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
{# License Filter Form #}
|
||||
{% if filter_products is defined and filter_products|length > 0 or filter_domains is defined and filter_domains|length > 0 %}
|
||||
<div class="wclp-filter-form">
|
||||
<form method="get" action="{{ esc_url(licenses_url) }}">
|
||||
<div class="wclp-filter-row">
|
||||
{% if filter_products is defined and filter_products|length > 0 %}
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_product">{{ __('Product') }}</label>
|
||||
<select name="filter_product" id="filter_product">
|
||||
<option value="">{{ __('All Products') }}</option>
|
||||
{% for id, name in filter_products %}
|
||||
<option value="{{ id }}" {{ current_filter_product == id ? 'selected' : '' }}>
|
||||
{{ esc_html(name) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filter_domains is defined and filter_domains|length > 0 %}
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_domain">{{ __('Domain') }}</label>
|
||||
<select name="filter_domain" id="filter_domain">
|
||||
<option value="">{{ __('All Domains') }}</option>
|
||||
{% for domain in filter_domains %}
|
||||
<option value="{{ esc_attr(domain) }}" {{ current_filter_domain == domain ? 'selected' : '' }}>
|
||||
{{ esc_html(domain) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="wclp-filter-actions">
|
||||
<button type="submit" class="button">{{ __('Filter') }}</button>
|
||||
{% if is_filtered %}
|
||||
<a href="{{ esc_url(licenses_url) }}" class="button">{{ __('Clear') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not has_packages %}
|
||||
<p>{{ __('You have no licenses yet.') }}</p>
|
||||
{% if is_filtered %}
|
||||
<p>{{ __('No licenses found matching your filters.') }}</p>
|
||||
{% else %}
|
||||
<p>{{ __('You have no licenses yet.') }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="woocommerce-licenses">
|
||||
{% for package in packages %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: WooCommerce Licensed Product
|
||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
|
||||
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
|
||||
* Version: 0.6.0
|
||||
* Version: 0.7.0
|
||||
* Author: Marco Graetsch
|
||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.6.0');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.7.0');
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user