You've already forked wc-licensed-product
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35d802c2b8 | |||
| c7967f71ab | |||
| 1de8257527 | |||
| 26245c0c57 | |||
| a6c6d247aa | |||
| fba8bf2352 | |||
| 12a3a37658 | |||
| b1fe34adfd |
69
CHANGELOG.md
69
CHANGELOG.md
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.6] - 2026-01-23
|
||||
|
||||
### Security
|
||||
|
||||
- Added CSRF protection (nonce verification) to CSV export functionality
|
||||
- Fixed IP header spoofing vulnerability in rate limiting - now requires explicit trusted proxy configuration
|
||||
- Enabled explicit Twig autoescape for XSS protection
|
||||
- Fixed unescaped status values in CSS classes in Twig templates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed response signing to use recursive key sorting for client compatibility
|
||||
- ResponseSigner now recursively sorts nested array keys alphabetically as required by client implementation
|
||||
|
||||
### Changed
|
||||
|
||||
- Rate limiting now only trusts proxy headers when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
|
||||
- Added Cloudflare IP range support via `WC_LICENSE_TRUSTED_PROXIES = 'CLOUDFLARE'` configuration
|
||||
- Improved IP detection with CIDR notation support for trusted proxy ranges
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `recursiveKeySort()` method to `ResponseSigner` for proper response signing
|
||||
- Added `isTrustedProxy()`, `isCloudflareIp()`, and `ipMatchesCidr()` methods to `RestApiController`
|
||||
- Twig environment now explicitly sets `autoescape => 'html'`
|
||||
- Export CSV link now includes nonce via `wp_nonce_url()`
|
||||
- Added `export_csv_url()` Twig function for generating export URL with nonce
|
||||
|
||||
## [0.3.5] - 2026-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- Admin dashboard widget showing license statistics on WordPress dashboard
|
||||
- Automatic license expiration via daily wp-cron job
|
||||
- License expired email notification sent when license auto-expires
|
||||
- New `LicenseExpiredEmail` WooCommerce email class (configurable via WooCommerce > Settings > Emails)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved download list layout in customer account licenses page
|
||||
- Downloads now displayed in two-row format: file link on first row, metadata on second row
|
||||
- Better visual separation between download link and version/date/checksum information
|
||||
|
||||
### Technical Details
|
||||
|
||||
- New `DashboardWidgetController` class in `src/Admin/` for WordPress dashboard widget
|
||||
- Widget displays: total licenses, active, expiring soon, expired counts, status breakdown, license types
|
||||
- New `LicenseExpiredEmail` class in `src/Email/` for expired license notifications
|
||||
- Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods to `LicenseManager`
|
||||
- Daily cron now auto-expires licenses with past expiration date and sends notification emails
|
||||
- Updated `templates/frontend/licenses.html.twig` with new two-row structure
|
||||
- Added `.download-item`, `.download-row-file`, `.download-row-meta` CSS classes
|
||||
- Improved responsive behavior for download metadata
|
||||
|
||||
## [0.3.4] - 2026-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- Current version display on single product pages for licensed products
|
||||
- Version number shown directly under the product title
|
||||
- Frontend CSS styling for version badge with monospace font
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `displayCurrentVersion()` method to `LicensedProductType` class
|
||||
- Hooked to `woocommerce_single_product_summary` at priority 6 (after title)
|
||||
- Added `enqueueFrontendStyles()` to load CSS on product pages
|
||||
- Uses `LicensedProduct::get_current_version()` to fetch latest version
|
||||
|
||||
## [0.3.3] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
|
||||
96
CLAUDE.md
96
CLAUDE.md
@@ -36,6 +36,10 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
No known bugs at the moment.
|
||||
|
||||
### Version 0.4.0
|
||||
|
||||
- On first plugin activation, get the checksums of all security related files (at least in `src/`) as hashes, store them encrypted on the server and add a mechanism to check the integrity of the files and the license validity periodically, control via wp-cron.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
- **Language:** PHP 8.3.x
|
||||
@@ -878,3 +882,95 @@ Updated OpenAPI specification to document response signing feature added in v0.2
|
||||
- Created release package: `releases/wc-licensed-product-0.3.2.zip` (810 KB)
|
||||
- SHA256: `ca33c81516b5dcf4a80b3192d8ae4ad39a7bf67196a1f729b563c5ae01b1d39c`
|
||||
- Tagged as `v0.3.2` and pushed to `main` branch
|
||||
|
||||
### 2026-01-22 - Version 0.3.3 - Bug Fix & License Testing
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed version deactivation bug and added license testing functionality.
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- Fixed version deactivation button not working in admin product versions table
|
||||
- Root cause: Parameters in wrong order in `VersionAdminController::ajaxToggleVersion()`
|
||||
- Changed from `updateVersion($versionId, null, null, !$currentlyActive)` to `updateVersion($versionId, null, !$currentlyActive, null)`
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Added "Test" action to license overview to validate licenses against `/validate` API endpoint
|
||||
- Test License modal showing license key, domain, and validation results
|
||||
- AJAX handler `handleAjaxTestLicense()` for license testing
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/VersionAdminController.php` - Fixed parameter order in toggle method
|
||||
- `src/Admin/AdminController.php` - Added Test action to PHP fallback and AJAX handler
|
||||
- `templates/admin/licenses.html.twig` - Added Test action and modal to Twig template
|
||||
|
||||
**Release v0.3.3:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.3.zip` (795 KB)
|
||||
- SHA256: `a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c`
|
||||
- Tagged as `v0.3.3` and pushed to `main` branch
|
||||
|
||||
### 2026-01-23 - Version 0.3.4 - Frontend Version Display
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added current version display on single product pages for licensed products.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Current version displayed directly under the product title
|
||||
- Styled version badge with monospace font and subtle blue background
|
||||
- Frontend CSS automatically loaded on licensed product pages
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Product/LicensedProductType.php` - Added `displayCurrentVersion()` and `enqueueFrontendStyles()` methods
|
||||
- `assets/css/frontend.css` - Added `.wclp-product-version` styles
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Uses `woocommerce_single_product_summary` hook at priority 6 (after title at priority 5)
|
||||
- Only displays for licensed product type
|
||||
- Only displays if product has at least one version defined
|
||||
- Uses `LicensedProduct::get_current_version()` which queries `VersionManager::getLatestVersion()`
|
||||
|
||||
### 2026-01-23 - Version 0.3.5 - Dashboard Widget & Auto-Expire
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added admin dashboard widget for license statistics and automatic license expiration via daily cron job.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Admin dashboard widget showing license statistics (total, active, expiring soon, expired)
|
||||
- Status breakdown display with color-coded badges
|
||||
- License type breakdown (time-limited vs lifetime)
|
||||
- Daily wp-cron job to auto-expire licenses past their expiration date
|
||||
- License expired email notification sent when license auto-expires
|
||||
- Downloads in customer account now displayed in two-row format
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Admin/DashboardWidgetController.php` - WordPress dashboard widget controller
|
||||
- `src/Email/LicenseExpiredEmail.php` - WooCommerce email for expired license notifications
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Plugin.php` - Added DashboardWidgetController instantiation
|
||||
- `src/License/LicenseManager.php` - Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods
|
||||
- `src/Email/LicenseEmailController.php` - Added auto-expire logic and LicenseExpiredEmail registration
|
||||
- `templates/frontend/licenses.html.twig` - Restructured download list with two-row layout
|
||||
- `assets/css/frontend.css` - Added dashboard widget and download list styles
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Dashboard widget uses `wp_add_dashboard_widget()` hook, requires `manage_woocommerce` capability
|
||||
- Widget displays statistics from existing `LicenseManager::getStatistics()` method
|
||||
- Auto-expire runs during daily `wclp_check_expiring_licenses` cron event
|
||||
- `getExpiredActiveLicenses()` finds licenses with past expiration date but still active status
|
||||
- `autoExpireLicense()` updates status to expired and returns true if changed
|
||||
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
|
||||
- Expired notification tracked via user meta to prevent duplicate emails
|
||||
|
||||
50
README.md
50
README.md
@@ -14,10 +14,13 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
|
||||
- **Domain Binding**: Licenses are bound to customer-specified domains
|
||||
- **REST API**: Public endpoints for license validation and management
|
||||
- **Response Signing**: Optional HMAC-SHA256 cryptographic signatures for API responses
|
||||
- **Version Binding**: Optional binding to major software versions
|
||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||
- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute)
|
||||
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
|
||||
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
|
||||
- **Self-Licensing**: The plugin can validate its own license (for commercial distribution)
|
||||
|
||||
### Customer Features
|
||||
|
||||
@@ -30,6 +33,7 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
|
||||
- **License Management**: Full CRUD interface for license management
|
||||
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
|
||||
- **Dashboard Widget**: License statistics on WordPress admin dashboard
|
||||
- **Search & Filtering**: Search by license key, domain, status, or product
|
||||
- **Live Search**: AJAX-powered instant search results
|
||||
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
|
||||
@@ -38,7 +42,10 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
- **CSV Export/Import**: Export and import licenses via CSV
|
||||
- **Order Integration**: View and manage licenses directly from order pages
|
||||
- **Expiration Warnings**: Automatic email notifications before license expiration
|
||||
- **Auto-Expire**: Daily cron job automatically expires licenses past their expiration date
|
||||
- **License Testing**: Test licenses against the API directly from admin interface
|
||||
- **Version Management**: Manage multiple versions per product with file attachments
|
||||
- **SHA256 Checksums**: File integrity verification with SHA256 hash display
|
||||
- **Global Settings**: Default license settings via WooCommerce settings tab
|
||||
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
|
||||
|
||||
@@ -103,6 +110,40 @@ When a customer purchases a licensed product, they must enter the domain where t
|
||||
3. Upload a CSV file (supports exported format or simplified format)
|
||||
4. Choose options: skip header row, update existing licenses
|
||||
|
||||
## Security
|
||||
|
||||
The plugin implements several security best practices:
|
||||
|
||||
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
||||
- **Output Escaping**: All output is escaped to prevent XSS attacks
|
||||
- **CSRF Protection**: Nonce verification on all forms and AJAX requests
|
||||
- **SQL Injection Prevention**: All database queries use prepared statements
|
||||
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
||||
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
||||
- **Response Signing**: Optional HMAC-SHA256 signatures for API tamper protection
|
||||
|
||||
### Trusted Proxy Configuration
|
||||
|
||||
If your server is behind a load balancer, reverse proxy, or CDN (like Cloudflare), you need to configure trusted proxies for accurate rate limiting. Without this, the rate limiter uses the direct connection IP which may be your proxy's IP.
|
||||
|
||||
**Configuration (wp-config.php):**
|
||||
|
||||
```php
|
||||
// For Cloudflare (includes all Cloudflare IP ranges)
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
|
||||
|
||||
// For specific proxy IPs
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
|
||||
|
||||
// For CIDR ranges
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.0/8,192.168.1.0/24');
|
||||
|
||||
// Combine multiple methods
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE,10.0.0.1');
|
||||
```
|
||||
|
||||
**Note**: Only configure trusted proxies if you actually use them. Without this configuration, rate limiting is more secure against IP spoofing attacks.
|
||||
|
||||
## REST API
|
||||
|
||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||
@@ -117,6 +158,12 @@ When the server is configured with a shared secret, all API responses include cr
|
||||
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||
```
|
||||
|
||||
Generate a secure secret using:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**Response Headers:**
|
||||
|
||||
| Header | Description |
|
||||
@@ -256,11 +303,12 @@ Content-Type: application/json
|
||||
|
||||
## Email Notifications
|
||||
|
||||
The plugin sends automatic email notifications:
|
||||
The plugin sends automatic email notifications (configurable via WooCommerce > Settings > Emails):
|
||||
|
||||
- **Order Completion**: License keys included in order confirmation emails
|
||||
- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration
|
||||
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
|
||||
- **License Expired**: Notification when a license auto-expires
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
@@ -202,18 +202,30 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.download-list li {
|
||||
.download-list li.download-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding: 0.5em 0;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
padding: 0.75em 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.download-list li:last-child {
|
||||
.download-list li.download-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.download-row-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.download-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -244,7 +256,6 @@
|
||||
.download-date {
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.download-hash {
|
||||
@@ -338,15 +349,11 @@
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.download-list li {
|
||||
.download-row-meta {
|
||||
padding-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.download-date {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.woocommerce-licenses-table,
|
||||
.woocommerce-licenses-table thead,
|
||||
.woocommerce-licenses-table tbody,
|
||||
@@ -528,3 +535,24 @@
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
/* Product Version Display (Single Product Page) */
|
||||
.wclp-product-version {
|
||||
margin: 0.5em 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.wclp-product-version .version-label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.wclp-product-version .version-number {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
background: #e7f3ff;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 3px;
|
||||
color: #2271b1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
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": "83037ea0c2d9e365cf9ec0ad50251d3ebc7e4782"
|
||||
"reference": "a3a957914fd6ef74cb479e213d1d3bc0606f496b"
|
||||
},
|
||||
"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-22T15:24:57+00:00"
|
||||
"time": "2026-01-22T20:05:48+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.3.3.sha256
Normal file
1
releases/wc-licensed-product-0.3.3.sha256
Normal file
@@ -0,0 +1 @@
|
||||
a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c wc-licensed-product-0.3.3.zip
|
||||
BIN
releases/wc-licensed-product-0.3.3.zip
Normal file
BIN
releases/wc-licensed-product-0.3.3.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.3.4.sha256
Normal file
1
releases/wc-licensed-product-0.3.4.sha256
Normal file
@@ -0,0 +1 @@
|
||||
36a81c00eb03adf5dfa633891664d44b7e5225bf1ee594904f8acc9adec6bb47 releases/wc-licensed-product-0.3.4.zip
|
||||
BIN
releases/wc-licensed-product-0.3.4.zip
Normal file
BIN
releases/wc-licensed-product-0.3.4.zip
Normal file
Binary file not shown.
@@ -572,6 +572,11 @@ final class AdminController
|
||||
*/
|
||||
private function handleCsvExport(): void
|
||||
{
|
||||
// Verify nonce for CSRF protection
|
||||
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'export_licenses_csv')) {
|
||||
wp_die(__('Security check failed.', 'wc-licensed-product'));
|
||||
}
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product'));
|
||||
}
|
||||
@@ -954,7 +959,7 @@ final class AdminController
|
||||
<span class="dashicons dashicons-admin-network"></span>
|
||||
<?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="button">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="button">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
<?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
@@ -1048,6 +1053,12 @@ final class AdminController
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string {
|
||||
return wp_create_nonce('transfer_license');
|
||||
}));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('export_csv_url', function (): string {
|
||||
return wp_nonce_url(
|
||||
admin_url('admin.php?page=wc-licenses&action=export_csv'),
|
||||
'export_licenses_csv'
|
||||
);
|
||||
}));
|
||||
|
||||
try {
|
||||
echo $this->twig->render('admin/licenses.html.twig', [
|
||||
@@ -1187,7 +1198,7 @@ final class AdminController
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline"><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
|
||||
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="page-title-action">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="page-title-action">
|
||||
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||
<?php esc_html_e('Export CSV', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
|
||||
225
src/Admin/DashboardWidgetController.php
Normal file
225
src/Admin/DashboardWidgetController.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard Widget Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Admin
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Admin;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\License;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
|
||||
/**
|
||||
* Handles the WordPress admin dashboard widget for license statistics
|
||||
*/
|
||||
final class DashboardWidgetController
|
||||
{
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager)
|
||||
{
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dashboard widget
|
||||
*/
|
||||
public function registerDashboardWidget(): void
|
||||
{
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_add_dashboard_widget(
|
||||
'wclp_license_statistics',
|
||||
__('License Statistics', 'wc-licensed-product'),
|
||||
[$this, 'renderWidget']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the dashboard widget content
|
||||
*/
|
||||
public function renderWidget(): void
|
||||
{
|
||||
$stats = $this->licenseManager->getStatistics();
|
||||
$licensesUrl = admin_url('admin.php?page=wc-licensed-product-licenses');
|
||||
?>
|
||||
<style>
|
||||
.wclp-widget-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wclp-stat-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e4e7;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.wclp-stat-card.highlight {
|
||||
border-left: 3px solid #7f54b3;
|
||||
}
|
||||
.wclp-stat-card.warning {
|
||||
border-left: 3px solid #f0b849;
|
||||
}
|
||||
.wclp-stat-card.danger {
|
||||
border-left: 3px solid #dc3232;
|
||||
}
|
||||
.wclp-stat-card.success {
|
||||
border-left: 3px solid #46b450;
|
||||
}
|
||||
.wclp-stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wclp-stat-label {
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.wclp-widget-divider {
|
||||
border-top: 1px solid #e2e4e7;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.wclp-status-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.wclp-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wclp-status-badge.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.wclp-status-badge.inactive {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
.wclp-status-badge.expired {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.wclp-status-badge.revoked {
|
||||
background: #d6d8db;
|
||||
color: #1b1e21;
|
||||
}
|
||||
.wclp-widget-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e2e4e7;
|
||||
text-align: center;
|
||||
}
|
||||
.wclp-widget-footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wclp-widget-stats">
|
||||
<div class="wclp-stat-card highlight">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Total Licenses', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card success">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_ACTIVE])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Active', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card warning">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring_soon'])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Expiring Soon', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card danger">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_EXPIRED])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Expired', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wclp-widget-divider"></div>
|
||||
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;">
|
||||
<?php esc_html_e('Status Breakdown', 'wc-licensed-product'); ?>
|
||||
</h4>
|
||||
<div class="wclp-status-list">
|
||||
<span class="wclp-status-badge active">
|
||||
<span class="dashicons dashicons-yes-alt" style="font-size: 14px; width: 14px; height: 14px;"></span>
|
||||
<?php printf(
|
||||
esc_html__('Active: %d', 'wc-licensed-product'),
|
||||
$stats['by_status'][License::STATUS_ACTIVE]
|
||||
); ?>
|
||||
</span>
|
||||
<span class="wclp-status-badge inactive">
|
||||
<span class="dashicons dashicons-marker" style="font-size: 14px; width: 14px; height: 14px;"></span>
|
||||
<?php printf(
|
||||
esc_html__('Inactive: %d', 'wc-licensed-product'),
|
||||
$stats['by_status'][License::STATUS_INACTIVE]
|
||||
); ?>
|
||||
</span>
|
||||
<span class="wclp-status-badge expired">
|
||||
<span class="dashicons dashicons-clock" style="font-size: 14px; width: 14px; height: 14px;"></span>
|
||||
<?php printf(
|
||||
esc_html__('Expired: %d', 'wc-licensed-product'),
|
||||
$stats['by_status'][License::STATUS_EXPIRED]
|
||||
); ?>
|
||||
</span>
|
||||
<span class="wclp-status-badge revoked">
|
||||
<span class="dashicons dashicons-dismiss" style="font-size: 14px; width: 14px; height: 14px;"></span>
|
||||
<?php printf(
|
||||
esc_html__('Revoked: %d', 'wc-licensed-product'),
|
||||
$stats['by_status'][License::STATUS_REVOKED]
|
||||
); ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wclp-widget-divider"></div>
|
||||
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;">
|
||||
<?php esc_html_e('License Types', 'wc-licensed-product'); ?>
|
||||
</h4>
|
||||
<p style="margin: 0; font-size: 13px; color: #646970;">
|
||||
<span class="dashicons dashicons-calendar-alt" style="font-size: 14px; width: 14px; height: 14px; vertical-align: text-bottom;"></span>
|
||||
<?php printf(
|
||||
esc_html__('Time-limited: %d', 'wc-licensed-product'),
|
||||
$stats['expiring']
|
||||
); ?>
|
||||
|
|
||||
<span class="dashicons dashicons-infinity" style="font-size: 14px; width: 14px; height: 14px; vertical-align: text-bottom;"></span>
|
||||
<?php printf(
|
||||
esc_html__('Lifetime: %d', 'wc-licensed-product'),
|
||||
$stats['lifetime']
|
||||
); ?>
|
||||
</p>
|
||||
|
||||
<div class="wclp-widget-footer">
|
||||
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">
|
||||
<?php esc_html_e('View All Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -94,8 +94,8 @@ final class ResponseSigner
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($data);
|
||||
// Recursively sort keys for consistent ordering (required by client implementation)
|
||||
$data = $this->recursiveKeySort($data);
|
||||
|
||||
// Build signature payload
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
@@ -109,6 +109,33 @@ final class ResponseSigner
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys alphabetically
|
||||
*
|
||||
* @param mixed $data The data to sort
|
||||
* @return mixed The sorted data
|
||||
*/
|
||||
private function recursiveKeySort(mixed $data): mixed
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Check if array is associative (has string keys)
|
||||
$isAssociative = array_keys($data) !== range(0, count($data) - 1);
|
||||
|
||||
if ($isAssociative) {
|
||||
ksort($data);
|
||||
}
|
||||
|
||||
// Recursively sort nested arrays
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = $this->recursiveKeySort($value);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique signing key for a license
|
||||
*
|
||||
|
||||
@@ -95,29 +95,152 @@ final class RestApiController
|
||||
|
||||
/**
|
||||
* 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',
|
||||
'REMOTE_ADDR',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,7 @@ final class LicenseEmailController
|
||||
public function registerEmailClasses(array $email_classes): array
|
||||
{
|
||||
$email_classes['WCLP_License_Expiration'] = new LicenseExpirationEmail();
|
||||
$email_classes['WCLP_License_Expired'] = new LicenseExpiredEmail();
|
||||
return $email_classes;
|
||||
}
|
||||
|
||||
@@ -69,10 +70,13 @@ final class LicenseEmailController
|
||||
}
|
||||
|
||||
/**
|
||||
* Send expiration warning emails
|
||||
* Send expiration warning emails and auto-expire licenses
|
||||
*/
|
||||
public function sendExpirationWarnings(): void
|
||||
{
|
||||
// First, auto-expire licenses that have passed their expiration date
|
||||
$this->autoExpireAndNotify();
|
||||
|
||||
// Check if expiration emails are enabled in settings
|
||||
if (!SettingsController::isExpirationEmailsEnabled()) {
|
||||
return;
|
||||
@@ -107,6 +111,41 @@ final class LicenseEmailController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-expire licenses and send expired notifications
|
||||
*/
|
||||
private function autoExpireAndNotify(): void
|
||||
{
|
||||
// Get licenses that should be auto-expired
|
||||
$expiredActiveLicenses = $this->licenseManager->getExpiredActiveLicenses();
|
||||
|
||||
if (empty($expiredActiveLicenses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the WooCommerce email instance for expired notifications
|
||||
$mailer = WC()->mailer();
|
||||
$emails = $mailer->get_emails();
|
||||
|
||||
/** @var LicenseExpiredEmail|null $expiredEmail */
|
||||
$expiredEmail = $emails['WCLP_License_Expired'] ?? null;
|
||||
|
||||
foreach ($expiredActiveLicenses as $license) {
|
||||
// Auto-expire the license
|
||||
$wasExpired = $this->licenseManager->autoExpireLicense($license->getId());
|
||||
|
||||
if ($wasExpired && $expiredEmail && $expiredEmail->is_enabled()) {
|
||||
// Check if we haven't already sent an expired notification
|
||||
if (!$this->licenseManager->wasExpirationNotified($license->getId(), 'license_expired')) {
|
||||
// Send expired notification email
|
||||
if ($expiredEmail->trigger($license)) {
|
||||
$this->licenseManager->markExpirationNotified($license->getId(), 'license_expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and send expiration warnings for a specific time frame
|
||||
*
|
||||
|
||||
335
src/Email/LicenseExpiredEmail.php
Normal file
335
src/Email/LicenseExpiredEmail.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
/**
|
||||
* License Expired Email
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Email
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Email;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\License;
|
||||
use WC_Email;
|
||||
|
||||
/**
|
||||
* License Expired Email class
|
||||
*
|
||||
* Sends email notifications to customers when their licenses have expired.
|
||||
* Uses WooCommerce's transactional email system for consistent styling and customization.
|
||||
*/
|
||||
class LicenseExpiredEmail extends WC_Email
|
||||
{
|
||||
/**
|
||||
* License object
|
||||
*/
|
||||
public ?License $license = null;
|
||||
|
||||
/**
|
||||
* Product name
|
||||
*/
|
||||
public string $product_name = '';
|
||||
|
||||
/**
|
||||
* Expiration date formatted
|
||||
*/
|
||||
public string $expiration_date = '';
|
||||
|
||||
/**
|
||||
* Customer display name
|
||||
*/
|
||||
public string $customer_name = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->id = 'wclp_license_expired';
|
||||
$this->customer_email = true;
|
||||
$this->title = __('License Expired', 'wc-licensed-product');
|
||||
$this->description = __('License expired emails are sent to customers when their licenses have expired.', 'wc-licensed-product');
|
||||
|
||||
$this->placeholders = [
|
||||
'{site_title}' => $this->get_blogname(),
|
||||
'{product_name}' => '',
|
||||
'{expiration_date}' => '',
|
||||
];
|
||||
|
||||
// Call parent constructor
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email subject
|
||||
*/
|
||||
public function get_default_subject(): string
|
||||
{
|
||||
return __('[{site_title}] Your license for {product_name} has expired', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email heading
|
||||
*/
|
||||
public function get_default_heading(): string
|
||||
{
|
||||
return __('License Expired', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the email
|
||||
*
|
||||
* @param License $license License object
|
||||
*/
|
||||
public function trigger(License $license): bool
|
||||
{
|
||||
$this->setup_locale();
|
||||
|
||||
$customer = get_userdata($license->getCustomerId());
|
||||
if (!$customer || !$customer->user_email) {
|
||||
$this->restore_locale();
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->license = $license;
|
||||
$this->recipient = $customer->user_email;
|
||||
$this->customer_name = $customer->display_name ?: __('Customer', 'wc-licensed-product');
|
||||
|
||||
$product = wc_get_product($license->getProductId());
|
||||
$this->product_name = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
|
||||
$expiresAt = $license->getExpiresAt();
|
||||
$this->expiration_date = $expiresAt ? $expiresAt->format(get_option('date_format')) : '';
|
||||
|
||||
// Update placeholders
|
||||
$this->placeholders['{product_name}'] = $this->product_name;
|
||||
$this->placeholders['{expiration_date}'] = $this->expiration_date;
|
||||
|
||||
if (!$this->is_enabled() || !$this->get_recipient()) {
|
||||
$this->restore_locale();
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->send(
|
||||
$this->get_recipient(),
|
||||
$this->get_subject(),
|
||||
$this->get_content(),
|
||||
$this->get_headers(),
|
||||
$this->get_attachments()
|
||||
);
|
||||
|
||||
$this->restore_locale();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content HTML
|
||||
*/
|
||||
public function get_content_html(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
// Use WooCommerce's email header
|
||||
wc_get_template('emails/email-header.php', ['email_heading' => $this->get_heading()]);
|
||||
|
||||
$this->render_email_body_html();
|
||||
|
||||
// Use WooCommerce's email footer
|
||||
wc_get_template('emails/email-footer.php', ['email' => $this]);
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content plain text
|
||||
*/
|
||||
public function get_content_plain(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
|
||||
echo esc_html(wp_strip_all_tags($this->get_heading()));
|
||||
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
|
||||
|
||||
$this->render_email_body_plain();
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render HTML email body content
|
||||
*/
|
||||
private function render_email_body_html(): void
|
||||
{
|
||||
$account_url = wc_get_account_endpoint_url('licenses');
|
||||
?>
|
||||
<p><?php printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name)); ?></p>
|
||||
|
||||
<p style="color: #dc3232; font-weight: 600;">
|
||||
<?php printf(
|
||||
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
|
||||
'<strong>' . esc_html($this->product_name) . '</strong>',
|
||||
esc_html($this->expiration_date)
|
||||
); ?>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<?php esc_html_e('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
|
||||
<h2><?php esc_html_e('Expired License Details', 'wc-licensed-product'); ?></h2>
|
||||
|
||||
<div style="margin-bottom: 40px;">
|
||||
<table class="td" cellspacing="0" cellpadding="6" style="width: 100%; font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif;" border="1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Product:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->product_name); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
|
||||
<code style="background: #f5f5f5; padding: 3px 8px; border-radius: 3px; font-family: monospace;">
|
||||
<?php echo esc_html($this->license->getLicenseKey()); ?>
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->license->getDomain()); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Expired on:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>; color: #dc3232; font-weight: 600;"><?php echo esc_html($this->expiration_date); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Status:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
|
||||
<span style="background: #f8d7da; color: #721c24; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||
<?php esc_html_e('Expired', 'wc-licensed-product'); ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$additional_content = $this->get_additional_content();
|
||||
if ($additional_content) :
|
||||
?>
|
||||
<p><?php echo wp_kses_post($additional_content); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<p style="margin-top: 25px;">
|
||||
<a href="<?php echo esc_url($account_url); ?>" class="button" style="display: inline-block; background-color: #7f54b3; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: 600;">
|
||||
<?php esc_html_e('View My Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render plain text email body content
|
||||
*/
|
||||
private function render_email_body_plain(): void
|
||||
{
|
||||
printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name));
|
||||
echo "\n\n";
|
||||
|
||||
printf(
|
||||
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
|
||||
esc_html($this->product_name),
|
||||
esc_html($this->expiration_date)
|
||||
);
|
||||
echo "\n\n";
|
||||
|
||||
echo esc_html__('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product');
|
||||
echo "\n\n";
|
||||
|
||||
echo "----------\n";
|
||||
echo esc_html__('Expired License Details', 'wc-licensed-product') . "\n";
|
||||
echo "----------\n\n";
|
||||
|
||||
echo esc_html__('Product:', 'wc-licensed-product') . ' ' . esc_html($this->product_name) . "\n";
|
||||
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($this->license->getLicenseKey()) . "\n";
|
||||
echo esc_html__('Domain:', 'wc-licensed-product') . ' ' . esc_html($this->license->getDomain()) . "\n";
|
||||
echo esc_html__('Expired on:', 'wc-licensed-product') . ' ' . esc_html($this->expiration_date) . "\n";
|
||||
echo esc_html__('Status:', 'wc-licensed-product') . ' ' . esc_html__('Expired', 'wc-licensed-product') . "\n\n";
|
||||
|
||||
$additional_content = $this->get_additional_content();
|
||||
if ($additional_content) {
|
||||
echo "----------\n\n";
|
||||
echo esc_html(wp_strip_all_tags(wptexturize($additional_content)));
|
||||
echo "\n\n";
|
||||
}
|
||||
|
||||
echo esc_html__('View My Licenses', 'wc-licensed-product') . ': ' . esc_url(wc_get_account_endpoint_url('licenses')) . "\n\n";
|
||||
|
||||
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Default content to show below main email content
|
||||
*/
|
||||
public function get_default_additional_content(): string
|
||||
{
|
||||
return __('To continue using this product, please renew your license.', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize settings form fields
|
||||
*/
|
||||
public function init_form_fields(): void
|
||||
{
|
||||
$placeholder_text = sprintf(
|
||||
/* translators: %s: list of placeholders */
|
||||
__('Available placeholders: %s', 'wc-licensed-product'),
|
||||
'<code>{site_title}, {product_name}, {expiration_date}</code>'
|
||||
);
|
||||
|
||||
$this->form_fields = [
|
||||
'enabled' => [
|
||||
'title' => __('Enable/Disable', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'label' => __('Enable this email notification', 'wc-licensed-product'),
|
||||
'default' => 'yes',
|
||||
],
|
||||
'subject' => [
|
||||
'title' => __('Subject', 'wc-licensed-product'),
|
||||
'type' => 'text',
|
||||
'desc_tip' => true,
|
||||
'description' => $placeholder_text,
|
||||
'placeholder' => $this->get_default_subject(),
|
||||
'default' => '',
|
||||
],
|
||||
'heading' => [
|
||||
'title' => __('Email heading', 'wc-licensed-product'),
|
||||
'type' => 'text',
|
||||
'desc_tip' => true,
|
||||
'description' => $placeholder_text,
|
||||
'placeholder' => $this->get_default_heading(),
|
||||
'default' => '',
|
||||
],
|
||||
'additional_content' => [
|
||||
'title' => __('Additional content', 'wc-licensed-product'),
|
||||
'description' => __('Text to appear below the main email content.', 'wc-licensed-product') . ' ' . $placeholder_text,
|
||||
'css' => 'width:400px; height: 75px;',
|
||||
'placeholder' => $this->get_default_additional_content(),
|
||||
'type' => 'textarea',
|
||||
'default' => '',
|
||||
'desc_tip' => true,
|
||||
],
|
||||
'email_type' => [
|
||||
'title' => __('Email type', 'wc-licensed-product'),
|
||||
'type' => 'select',
|
||||
'description' => __('Choose which format of email to send.', 'wc-licensed-product'),
|
||||
'default' => 'html',
|
||||
'class' => 'email_type wc-enhanced-select',
|
||||
'options' => $this->get_email_type_options(),
|
||||
'desc_tip' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -862,6 +862,56 @@ class LicenseManager
|
||||
return (bool) get_user_meta($license->getCustomerId(), $metaKey, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get licenses that have passed their expiration date but are still marked as active
|
||||
*
|
||||
* @return array Array of License objects that need to be auto-expired
|
||||
*/
|
||||
public function getExpiredActiveLicenses(): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
$sql = "SELECT * FROM {$tableName}
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < %s
|
||||
AND status = %s";
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare($sql, $now->format('Y-m-d H:i:s'), License::STATUS_ACTIVE),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-expire a license and return true if status was changed
|
||||
*
|
||||
* @param int $licenseId License ID
|
||||
* @return bool True if license was expired, false if already expired or error
|
||||
*/
|
||||
public function autoExpireLicense(int $licenseId): bool
|
||||
{
|
||||
$license = $this->getLicenseById($licenseId);
|
||||
if (!$license) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only expire if currently active and past expiration date
|
||||
if ($license->getStatus() !== License::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$license->isExpired()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->updateLicenseStatus($licenseId, License::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a license from CSV data
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ declare(strict_types=1);
|
||||
namespace Jeremias\WcLicensedProduct;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
|
||||
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||
@@ -97,6 +98,7 @@ final class Plugin
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
|
||||
'auto_reload' => true, // Always check for template changes
|
||||
'autoescape' => 'html', // Explicitly enable HTML autoescape for XSS protection
|
||||
]);
|
||||
|
||||
// Add WordPress functions as Twig functions
|
||||
@@ -151,6 +153,7 @@ final class Plugin
|
||||
new VersionAdminController($this->versionManager);
|
||||
new OrderLicenseController($this->licenseManager);
|
||||
new SettingsController();
|
||||
new DashboardWidgetController($this->licenseManager);
|
||||
|
||||
// Show admin notice if unlicensed and not on localhost
|
||||
if (!$isLicensed && !$licenseChecker->isLocalhost()) {
|
||||
|
||||
@@ -45,6 +45,12 @@ final class LicensedProductType
|
||||
|
||||
// Make product virtual by default
|
||||
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
|
||||
|
||||
// Display current version under product title on single product page
|
||||
add_action('woocommerce_single_product_summary', [$this, 'displayCurrentVersion'], 6);
|
||||
|
||||
// Enqueue frontend CSS for licensed products on single product pages
|
||||
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,4 +241,52 @@ final class LicensedProductType
|
||||
}
|
||||
return $isVirtual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend styles for licensed products on single product pages
|
||||
*/
|
||||
public function enqueueFrontendStyles(): void
|
||||
{
|
||||
if (!is_product()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'wc-licensed-product-frontend',
|
||||
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/frontend.css',
|
||||
[],
|
||||
WC_LICENSED_PRODUCT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display current version under product title on single product page
|
||||
*/
|
||||
public function displayCurrentVersion(): void
|
||||
{
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var LicensedProduct $product */
|
||||
$version = $product->get_current_version();
|
||||
|
||||
if (empty($version)) {
|
||||
return;
|
||||
}
|
||||
|
||||
printf(
|
||||
'<p class="wclp-product-version"><span class="version-label">%s</span> <span class="version-number">%s</span></p>',
|
||||
esc_html__('Version:', 'wc-licensed-product'),
|
||||
esc_html($version)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline">{{ __('Licenses') }}</h1>
|
||||
<a href="{{ admin_url }}?action=export_csv" class="page-title-action">
|
||||
<a href="{{ export_csv_url() }}" class="page-title-action">
|
||||
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||
{{ __('Export CSV') }}
|
||||
</a>
|
||||
@@ -143,8 +143,8 @@
|
||||
</td>
|
||||
<td class="wclp-editable-cell" data-field="status" data-license-id="{{ item.license.id }}">
|
||||
<span class="wclp-display-value">
|
||||
<span class="license-status license-status-{{ item.license.status }}">
|
||||
{{ item.license.status|capitalize }}
|
||||
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
|
||||
{{ esc_html(item.license.status)|capitalize }}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="wclp-edit-btn button-link" title="{{ __('Edit') }}">
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
{{ esc_html(item.product_name) }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
<span class="license-status license-status-{{ item.license.status }}">
|
||||
{{ item.license.status|capitalize }}
|
||||
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
|
||||
{{ esc_html(item.license.status)|capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -57,12 +57,14 @@
|
||||
<h4>{{ __('Available Downloads') }}</h4>
|
||||
<ul class="download-list">
|
||||
{% for download in item.downloads %}
|
||||
<li>
|
||||
<li class="download-item">
|
||||
<div class="download-row-file">
|
||||
<a href="{{ esc_url(download.download_url) }}" class="download-link">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }}
|
||||
</a>
|
||||
<span class="download-version">v{{ esc_html(download.version) }}</span>
|
||||
</div>
|
||||
<div class="download-row-meta">
|
||||
<span class="download-date">{{ esc_html(download.released_at) }}</span>
|
||||
{% if download.file_hash %}
|
||||
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
|
||||
@@ -70,6 +72,7 @@
|
||||
<code>{{ download.file_hash[:12] }}...</code>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -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.3.3
|
||||
* Version: 0.3.6
|
||||
* 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.3.3');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.3.6');
|
||||
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