You've already forked wc-licensed-product
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a90e6b18b | |||
| 502a8c7cd7 | |||
| 6b83fce8b2 | |||
| 8c33eaff29 | |||
| 98002ae3d7 | |||
| a93381dce6 | |||
| a522455a0a | |||
| 2de6abe133 | |||
| 8d60758f23 | |||
| 82bec621c6 | |||
| 034593f896 | |||
| 202f8a6dc0 | |||
| 36b51c9fc8 | |||
| d0aaf3180f | |||
| 35d802c2b8 | |||
| 4e683e2ff4 |
88
CHANGELOG.md
88
CHANGELOG.md
@@ -7,6 +7,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.9] - 2026-01-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- "Generate Licenses" button in order meta box for admin-created orders
|
||||||
|
- "Generate Missing Licenses" button when some products in an order are missing licenses
|
||||||
|
- AJAX handler `ajaxGenerateOrderLicenses()` for manual license generation from admin
|
||||||
|
- Warning message when order domain is not set before generating licenses
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Critical:** Licenses are now generated for orders created manually in admin area
|
||||||
|
- Previously, licenses were only generated via checkout hooks, leaving admin-created orders without licenses
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Added `wclp_generate_order_licenses` AJAX action to `OrderLicenseController`
|
||||||
|
- Updated `order-licenses.js` with generate button handler and page reload on success
|
||||||
|
- Added CSS styles for generate status messages
|
||||||
|
- Updated translations (365 strings)
|
||||||
|
|
||||||
|
## [0.3.8] - 2026-01-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed duplicate German translation string causing `ArgumentCountError` in settings page
|
||||||
|
- The notification settings description had duplicated text with two `%s` placeholders
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated `magdev/wc-licensed-product-client` to latest version (64d215c)
|
||||||
|
|
||||||
|
## [0.3.7] - 2026-01-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Download counter for licensed product versions (tracked per version)
|
||||||
|
- Download Statistics admin dashboard widget showing total downloads, top products, and top versions
|
||||||
|
- New `DownloadWidgetController` class for download statistics widget
|
||||||
|
- New `incrementDownloadCount()`, `getTotalDownloadCount()`, and `getDownloadStatistics()` methods in `VersionManager`
|
||||||
|
- New `download_count` column in product versions database table
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Dashboard widget "View All Licenses" link now uses correct page slug (`wc-licenses`)
|
||||||
|
- Download links in customer account page no longer result in 404 errors (added query var registration)
|
||||||
|
- Added `license-download` endpoint registration during plugin activation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed redundant "Status Breakdown" section from dashboard widget (info already shown in stat cards)
|
||||||
|
- License Types section in dashboard widget now uses card style matching the stats row above
|
||||||
|
- Improved dashboard widget visual consistency
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Added `addDownloadQueryVar()` method to `DownloadController` for proper endpoint registration
|
||||||
|
- Updated `Installer::activate()` to register `license-download` endpoint before flushing rewrite rules
|
||||||
|
- Updated translations (356 strings)
|
||||||
|
|
||||||
|
## [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
|
## [0.3.5] - 2026-01-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
153
CLAUDE.md
153
CLAUDE.md
@@ -36,9 +36,13 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
|||||||
|
|
||||||
No known bugs at the moment.
|
No known bugs at the moment.
|
||||||
|
|
||||||
|
### Version 0.3.9
|
||||||
|
|
||||||
|
No changes at the moment.
|
||||||
|
|
||||||
### Version 0.4.0
|
### 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.
|
No changes at the moment.
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
|
|
||||||
@@ -974,3 +978,150 @@ Added admin dashboard widget for license statistics and automatic license expira
|
|||||||
- `autoExpireLicense()` updates status to expired and returns true if changed
|
- `autoExpireLicense()` updates status to expired and returns true if changed
|
||||||
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
|
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
|
||||||
- Expired notification tracked via user meta to prevent duplicate emails
|
- Expired notification tracked via user meta to prevent duplicate emails
|
||||||
|
|
||||||
|
### 2026-01-23 - Version 0.3.6 - Security Hardening
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
|
||||||
|
Security audit and implementation alignment with client/server documentation. Fixed response signing compatibility, rate limiting security, and XSS prevention.
|
||||||
|
|
||||||
|
**Security Fixes:**
|
||||||
|
|
||||||
|
- 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 (`'html'`) for XSS protection
|
||||||
|
- Fixed unescaped status values in CSS class names in Twig templates
|
||||||
|
|
||||||
|
**Implementation Fixes:**
|
||||||
|
|
||||||
|
- Fixed response signing to use recursive key sorting for client library compatibility
|
||||||
|
- ResponseSigner now recursively sorts nested array keys alphabetically as required by `magdev/wc-licensed-product-client`
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `src/Api/ResponseSigner.php` - Added `recursiveKeySort()` method for proper signature generation
|
||||||
|
- `src/Api/RestApiController.php` - Added trusted proxy support with `isTrustedProxy()`, `isCloudflareIp()`, `ipMatchesCidr()` methods
|
||||||
|
- `src/Plugin.php` - Added explicit `autoescape => 'html'` to Twig environment
|
||||||
|
- `src/Admin/AdminController.php` - Added nonce verification to `handleCsvExport()`, added `export_csv_url()` Twig function
|
||||||
|
- `templates/frontend/licenses.html.twig` - Added `esc_attr()` for CSS class status
|
||||||
|
- `templates/admin/licenses.html.twig` - Added `esc_attr()` for CSS class status, updated export link to use `export_csv_url()`
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
To enable trusted proxy support for rate limiting, add to `wp-config.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// For Cloudflare
|
||||||
|
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
|
||||||
|
|
||||||
|
// Or for specific IPs/CIDR ranges
|
||||||
|
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
- Rate limiting now only trusts proxy headers (`HTTP_CF_CONNECTING_IP`, `HTTP_X_FORWARDED_FOR`, `HTTP_X_REAL_IP`) when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
|
||||||
|
- Without trusted proxy configuration, rate limiting uses `REMOTE_ADDR` only (prevents IP spoofing)
|
||||||
|
- Cloudflare IP ranges are hardcoded for convenience (as of 2024)
|
||||||
|
- CIDR notation supported for custom proxy ranges
|
||||||
|
- Recursive key sorting ensures signature compatibility with SecureLicenseClient
|
||||||
|
- References: <https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/raw/branch/main/docs/server-implementation.md>
|
||||||
|
|
||||||
|
**Release v0.3.6:**
|
||||||
|
|
||||||
|
- Created release package: `releases/wc-licensed-product-0.3.6.zip` (818 KB)
|
||||||
|
- SHA256: `b0063f0312759f090e12faba83de730baf4114139d763e46fad2b781d4b38270`
|
||||||
|
- Tagged as `v0.3.6` and pushed to `main` branch
|
||||||
|
|
||||||
|
### 2026-01-24 - Version 0.3.7 - Dashboard Improvements & Download Counter
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
|
||||||
|
Fixed dashboard widget bugs, improved UI consistency, and added download tracking functionality with a new statistics widget.
|
||||||
|
|
||||||
|
**Bug Fixes:**
|
||||||
|
|
||||||
|
- Fixed: Dashboard widget "View All Licenses" link used wrong page slug (`wc-licensed-product-licenses` instead of `wc-licenses`)
|
||||||
|
- Fixed: Download links in customer account resulted in 404 errors due to missing query var registration
|
||||||
|
- Added `license-download` endpoint registration during plugin activation in `Installer::activate()`
|
||||||
|
- Added `addDownloadQueryVar()` method to `DownloadController` for proper WordPress endpoint recognition
|
||||||
|
|
||||||
|
**UI Improvements:**
|
||||||
|
|
||||||
|
- Removed redundant "Status Breakdown" section from license statistics widget (info already shown in stat cards above)
|
||||||
|
- Changed License Types section to use card-style layout matching the stats row above
|
||||||
|
- Cleaned up unused CSS for status badges
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
|
||||||
|
- Download counter for licensed product versions (tracked per version in database)
|
||||||
|
- New Download Statistics admin dashboard widget showing:
|
||||||
|
- Total downloads count
|
||||||
|
- Top 5 products by downloads
|
||||||
|
- Top 5 versions by downloads
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
|
||||||
|
- `src/Admin/DownloadWidgetController.php` - Dashboard widget for download statistics
|
||||||
|
|
||||||
|
**New methods in VersionManager:**
|
||||||
|
|
||||||
|
- `incrementDownloadCount()` - Atomically increment download count for a version
|
||||||
|
- `getTotalDownloadCount()` - Get total downloads across all versions
|
||||||
|
- `getDownloadStatistics()` - Get download stats grouped by product and version
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `src/Installer.php` - Added `download_count` column to versions table, added `license-download` endpoint registration
|
||||||
|
- `src/Product/ProductVersion.php` - Added `downloadCount` property and `getDownloadCount()` method
|
||||||
|
- `src/Product/VersionManager.php` - Added download counting methods
|
||||||
|
- `src/Frontend/DownloadController.php` - Added query var registration, increment download count on file serve
|
||||||
|
- `src/Admin/DashboardWidgetController.php` - Fixed URL, removed Status Breakdown, changed License Types to cards
|
||||||
|
- `src/Plugin.php` - Added DownloadWidgetController instantiation
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
- Download count is incremented atomically using SQL `download_count = download_count + 1`
|
||||||
|
- Statistics queries use SQL aggregation with product name enrichment via `wc_get_product()`
|
||||||
|
- WordPress endpoints require both `add_rewrite_endpoint()` AND `query_vars` filter registration
|
||||||
|
- Existing installations need to flush rewrite rules (Settings > Permalinks > Save) or reactivate plugin
|
||||||
|
|
||||||
|
**Release v0.3.7:**
|
||||||
|
|
||||||
|
- Created release package: `releases/wc-licensed-product-0.3.7.zip` (827 KB)
|
||||||
|
- SHA256: `e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6`
|
||||||
|
- Tagged as `v0.3.7` and pushed to `main` branch
|
||||||
|
|
||||||
|
### 2026-01-24 - Version 0.3.8 - Translation Bug Fix
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
|
||||||
|
Fixed a critical translation bug that caused the settings page to crash with an `ArgumentCountError`.
|
||||||
|
|
||||||
|
**Bug Fix:**
|
||||||
|
|
||||||
|
- Fixed: Duplicate German translation string in `wc-licensed-product-de_CH.po` causing `ArgumentCountError` in settings page
|
||||||
|
- Root cause: The notification settings description was duplicated in the translation, resulting in two `%s` placeholders when only one argument was passed to `sprintf()`
|
||||||
|
- Location: [wc-licensed-product-de_CH.po:322-328](languages/wc-licensed-product-de_CH.po#L322-L328)
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `languages/wc-licensed-product-de_CH.po` - Removed duplicated translation string
|
||||||
|
- `languages/wc-licensed-product-de_CH.mo` - Recompiled binary translation
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
- Error was logged to `tmp/fatal-errors-2026-01-24.log`
|
||||||
|
- The German `msgstr` contained the same text twice, each with a `%s` placeholder
|
||||||
|
- `sprintf()` at `SettingsController.php:221` only provided one argument for the single `%s` in the English source
|
||||||
|
- Translation strings with `%s` placeholders must have exactly matching placeholder counts between source and translation
|
||||||
|
|
||||||
|
**Dependency Updates:**
|
||||||
|
|
||||||
|
- Updated `magdev/wc-licensed-product-client` from `9f513a8` to `64d215c`
|
||||||
|
|
||||||
|
**Release v0.3.8:**
|
||||||
|
|
||||||
|
- Created release package: `releases/wc-licensed-product-0.3.8.zip` (829 KB)
|
||||||
|
- SHA256: `50ad6966c5ab8db2257572084d2d8a820448df62615678e1576696f2c0cb383d`
|
||||||
|
- Tagged as `v0.3.8` and pushed to `main` branch
|
||||||
|
|||||||
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)
|
- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
|
||||||
- **Domain Binding**: Licenses are bound to customer-specified domains
|
- **Domain Binding**: Licenses are bound to customer-specified domains
|
||||||
- **REST API**: Public endpoints for license validation and management
|
- **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
|
- **Version Binding**: Optional binding to major software versions
|
||||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||||
- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute)
|
- **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+)
|
- **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
|
### 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 Management**: Full CRUD interface for license management
|
||||||
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
|
- **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
|
- **Search & Filtering**: Search by license key, domain, status, or product
|
||||||
- **Live Search**: AJAX-powered instant search results
|
- **Live Search**: AJAX-powered instant search results
|
||||||
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
|
- **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
|
- **CSV Export/Import**: Export and import licenses via CSV
|
||||||
- **Order Integration**: View and manage licenses directly from order pages
|
- **Order Integration**: View and manage licenses directly from order pages
|
||||||
- **Expiration Warnings**: Automatic email notifications before license expiration
|
- **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
|
- **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
|
- **Global Settings**: Default license settings via WooCommerce settings tab
|
||||||
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
|
- **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)
|
3. Upload a CSV file (supports exported format or simplified format)
|
||||||
4. Choose options: skip header row, update existing licenses
|
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
|
## REST API
|
||||||
|
|
||||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
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');
|
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Generate a secure secret using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
**Response Headers:**
|
**Response Headers:**
|
||||||
|
|
||||||
| Header | Description |
|
| Header | Description |
|
||||||
@@ -256,11 +303,12 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## Email Notifications
|
## 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
|
- **Order Completion**: License keys included in order confirmation emails
|
||||||
- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration
|
- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration
|
||||||
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
|
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
|
||||||
|
- **License Expired**: Notification when a license auto-expires
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// Order domain save
|
// Order domain save
|
||||||
$('#wclp-save-order-domain').on('click', this.saveOrderDomain.bind(this));
|
$('#wclp-save-order-domain').on('click', this.saveOrderDomain.bind(this));
|
||||||
|
|
||||||
|
// Generate licenses button
|
||||||
|
$(document).on('click', '#wclp-generate-licenses', this.generateLicenses.bind(this));
|
||||||
|
|
||||||
// License domain edit/save/cancel
|
// License domain edit/save/cancel
|
||||||
$(document).on('click', '.wclp-edit-domain-btn', this.startEditDomain);
|
$(document).on('click', '.wclp-edit-domain-btn', this.startEditDomain);
|
||||||
$(document).on('click', '.wclp-save-domain-btn', this.saveLicenseDomain.bind(this));
|
$(document).on('click', '.wclp-save-domain-btn', this.saveLicenseDomain.bind(this));
|
||||||
@@ -135,6 +138,54 @@
|
|||||||
$editBtn.show();
|
$editBtn.show();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate licenses for order
|
||||||
|
*/
|
||||||
|
generateLicenses: function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var $btn = $(e.currentTarget);
|
||||||
|
var $spinner = $btn.siblings('.spinner');
|
||||||
|
var $status = $btn.siblings('.wclp-generate-status');
|
||||||
|
|
||||||
|
var orderId = $btn.data('order-id');
|
||||||
|
|
||||||
|
$btn.prop('disabled', true);
|
||||||
|
$spinner.addClass('is-active');
|
||||||
|
$status.text('').removeClass('success error');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: wclpOrderLicenses.ajaxUrl,
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'wclp_generate_order_licenses',
|
||||||
|
nonce: wclpOrderLicenses.nonce,
|
||||||
|
order_id: orderId
|
||||||
|
},
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
$status.text(response.data.message).addClass('success');
|
||||||
|
if (response.data.reload) {
|
||||||
|
// Reload the page after a short delay to show the new licenses
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$status.text(response.data.message || wclpOrderLicenses.strings.error).addClass('error');
|
||||||
|
$btn.prop('disabled', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
$status.text(wclpOrderLicenses.strings.error).addClass('error');
|
||||||
|
$btn.prop('disabled', false);
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
$spinner.removeClass('is-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save license domain
|
* Save license domain
|
||||||
*/
|
*/
|
||||||
|
|||||||
16
composer.lock
generated
16
composer.lock
generated
@@ -12,7 +12,7 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
||||||
"reference": "a3a957914fd6ef74cb479e213d1d3bc0606f496b"
|
"reference": "64d215cb265a64ff318cfbb954dd128b0076dc1d"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
||||||
},
|
},
|
||||||
"time": "2026-01-22T20:05:48+00:00"
|
"time": "2026-01-24T13:32:11+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/cache",
|
"name": "psr/cache",
|
||||||
@@ -894,16 +894,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/twig",
|
"name": "twig/twig",
|
||||||
"version": "v3.22.2",
|
"version": "v3.23.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/twigphp/Twig.git",
|
"url": "https://github.com/twigphp/Twig.git",
|
||||||
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2"
|
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2",
|
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
|
||||||
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2",
|
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -957,7 +957,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/twigphp/Twig/issues",
|
"issues": "https://github.com/twigphp/Twig/issues",
|
||||||
"source": "https://github.com/twigphp/Twig/tree/v3.22.2"
|
"source": "https://github.com/twigphp/Twig/tree/v3.23.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -969,7 +969,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-14T11:28:47+00:00"
|
"time": "2026-01-23T21:00:41+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [],
|
"packages-dev": [],
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
releases/wc-licensed-product-0.3.7.zip
Normal file
BIN
releases/wc-licensed-product-0.3.7.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.3.7.zip.sha256
Normal file
1
releases/wc-licensed-product-0.3.7.zip.sha256
Normal file
@@ -0,0 +1 @@
|
|||||||
|
e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6 wc-licensed-product-0.3.7.zip
|
||||||
@@ -572,6 +572,11 @@ final class AdminController
|
|||||||
*/
|
*/
|
||||||
private function handleCsvExport(): void
|
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')) {
|
if (!current_user_can('manage_woocommerce')) {
|
||||||
wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product'));
|
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>
|
<span class="dashicons dashicons-admin-network"></span>
|
||||||
<?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?>
|
<?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?>
|
||||||
</a>
|
</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>
|
<span class="dashicons dashicons-download"></span>
|
||||||
<?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?>
|
<?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?>
|
||||||
</a>
|
</a>
|
||||||
@@ -1048,6 +1053,12 @@ final class AdminController
|
|||||||
$this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string {
|
$this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string {
|
||||||
return wp_create_nonce('transfer_license');
|
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 {
|
try {
|
||||||
echo $this->twig->render('admin/licenses.html.twig', [
|
echo $this->twig->render('admin/licenses.html.twig', [
|
||||||
@@ -1187,7 +1198,7 @@ final class AdminController
|
|||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1 class="wp-heading-inline"><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
|
<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>
|
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||||
<?php esc_html_e('Export CSV', 'wc-licensed-product'); ?>
|
<?php esc_html_e('Export CSV', 'wc-licensed-product'); ?>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ final class DashboardWidgetController
|
|||||||
public function renderWidget(): void
|
public function renderWidget(): void
|
||||||
{
|
{
|
||||||
$stats = $this->licenseManager->getStatistics();
|
$stats = $this->licenseManager->getStatistics();
|
||||||
$licensesUrl = admin_url('admin.php?page=wc-licensed-product-licenses');
|
$licensesUrl = admin_url('admin.php?page=wc-licenses');
|
||||||
?>
|
?>
|
||||||
<style>
|
<style>
|
||||||
.wclp-widget-stats {
|
.wclp-widget-stats {
|
||||||
@@ -96,40 +96,6 @@ final class DashboardWidgetController
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-top: 4px;
|
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 {
|
.wclp-widget-footer {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
@@ -160,60 +126,16 @@ final class DashboardWidgetController
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wclp-widget-divider"></div>
|
<div class="wclp-widget-stats">
|
||||||
|
<div class="wclp-stat-card">
|
||||||
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;">
|
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring'])); ?></div>
|
||||||
<?php esc_html_e('Status Breakdown', 'wc-licensed-product'); ?>
|
<div class="wclp-stat-label"><?php esc_html_e('Time-limited', 'wc-licensed-product'); ?></div>
|
||||||
</h4>
|
</div>
|
||||||
<div class="wclp-status-list">
|
<div class="wclp-stat-card">
|
||||||
<span class="wclp-status-badge active">
|
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['lifetime'])); ?></div>
|
||||||
<span class="dashicons dashicons-yes-alt" style="font-size: 14px; width: 14px; height: 14px;"></span>
|
<div class="wclp-stat-label"><?php esc_html_e('Lifetime', 'wc-licensed-product'); ?></div>
|
||||||
<?php printf(
|
</div>
|
||||||
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>
|
||||||
|
|
||||||
<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">
|
<div class="wclp-widget-footer">
|
||||||
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">
|
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">
|
||||||
|
|||||||
184
src/Admin/DownloadWidgetController.php
Normal file
184
src/Admin/DownloadWidgetController.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Download Statistics Widget Controller
|
||||||
|
*
|
||||||
|
* @package Jeremias\WcLicensedProduct\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Jeremias\WcLicensedProduct\Admin;
|
||||||
|
|
||||||
|
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the WordPress admin dashboard widget for download statistics
|
||||||
|
*/
|
||||||
|
final class DownloadWidgetController
|
||||||
|
{
|
||||||
|
private VersionManager $versionManager;
|
||||||
|
|
||||||
|
public function __construct(VersionManager $versionManager)
|
||||||
|
{
|
||||||
|
$this->versionManager = $versionManager;
|
||||||
|
$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_download_statistics',
|
||||||
|
__('Download Statistics', 'wc-licensed-product'),
|
||||||
|
[$this, 'renderWidget']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the dashboard widget content
|
||||||
|
*/
|
||||||
|
public function renderWidget(): void
|
||||||
|
{
|
||||||
|
$stats = $this->versionManager->getDownloadStatistics();
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
.wclp-download-widget-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.wclp-download-stat-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e2e4e7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-left: 3px solid #2271b1;
|
||||||
|
}
|
||||||
|
.wclp-download-stat-number {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d2327;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.wclp-download-stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #646970;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.wclp-download-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.wclp-download-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #e2e4e7;
|
||||||
|
}
|
||||||
|
.wclp-download-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.wclp-download-list .product-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1d2327;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.wclp-download-list .version-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #646970;
|
||||||
|
}
|
||||||
|
.wclp-download-list .download-count {
|
||||||
|
background: #e7f5ff;
|
||||||
|
color: #0a4b78;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wclp-download-section-title {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1d2327;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.wclp-no-downloads {
|
||||||
|
color: #646970;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wclp-download-widget-stats">
|
||||||
|
<div class="wclp-download-stat-card">
|
||||||
|
<div class="wclp-download-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
|
||||||
|
<div class="wclp-download-stat-label"><?php esc_html_e('Total Downloads', 'wc-licensed-product'); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="wclp-download-section-title">
|
||||||
|
<?php esc_html_e('Top Products', 'wc-licensed-product'); ?>
|
||||||
|
</h4>
|
||||||
|
<?php if (!empty($stats['by_product'])): ?>
|
||||||
|
<ul class="wclp-download-list">
|
||||||
|
<?php foreach (array_slice($stats['by_product'], 0, 5) as $product): ?>
|
||||||
|
<li>
|
||||||
|
<span class="product-name"><?php echo esc_html($product['product_name']); ?></span>
|
||||||
|
<span class="download-count">
|
||||||
|
<?php echo esc_html(number_format_i18n($product['downloads'])); ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h4 class="wclp-download-section-title">
|
||||||
|
<?php esc_html_e('Top Versions', 'wc-licensed-product'); ?>
|
||||||
|
</h4>
|
||||||
|
<?php if (!empty($stats['by_version'])): ?>
|
||||||
|
<ul class="wclp-download-list">
|
||||||
|
<?php foreach (array_slice($stats['by_version'], 0, 5) as $version): ?>
|
||||||
|
<li>
|
||||||
|
<span class="product-name">
|
||||||
|
<?php echo esc_html($version['product_name']); ?>
|
||||||
|
<span class="version-info">v<?php echo esc_html($version['version']); ?></span>
|
||||||
|
</span>
|
||||||
|
<span class="download-count">
|
||||||
|
<?php echo esc_html(number_format_i18n($version['downloads'])); ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ final class OrderLicenseController
|
|||||||
// Handle AJAX actions
|
// Handle AJAX actions
|
||||||
add_action('wp_ajax_wclp_update_order_domain', [$this, 'ajaxUpdateOrderDomain']);
|
add_action('wp_ajax_wclp_update_order_domain', [$this, 'ajaxUpdateOrderDomain']);
|
||||||
add_action('wp_ajax_wclp_update_license_domain', [$this, 'ajaxUpdateLicenseDomain']);
|
add_action('wp_ajax_wclp_update_license_domain', [$this, 'ajaxUpdateLicenseDomain']);
|
||||||
|
add_action('wp_ajax_wclp_generate_order_licenses', [$this, 'ajaxGenerateOrderLicenses']);
|
||||||
|
|
||||||
// Enqueue admin scripts
|
// Enqueue admin scripts
|
||||||
add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']);
|
add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']);
|
||||||
@@ -126,6 +127,18 @@ final class OrderLicenseController
|
|||||||
|
|
||||||
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
|
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Count licensed products to check if all have licenses
|
||||||
|
$licensedProductCount = 0;
|
||||||
|
foreach ($order->get_items() as $item) {
|
||||||
|
$product = $item->get_product();
|
||||||
|
if ($product && $product->is_type('licensed')) {
|
||||||
|
$licensedProductCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$missingLicenses = $licensedProductCount - count($licenses);
|
||||||
|
?>
|
||||||
|
|
||||||
<?php if (empty($licenses)): ?>
|
<?php if (empty($licenses)): ?>
|
||||||
<p class="description">
|
<p class="description">
|
||||||
<?php esc_html_e('No licenses have been generated for this order yet.', 'wc-licensed-product'); ?>
|
<?php esc_html_e('No licenses have been generated for this order yet.', 'wc-licensed-product'); ?>
|
||||||
@@ -137,6 +150,20 @@ final class OrderLicenseController
|
|||||||
<em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em>
|
<em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php if ($orderDomain && $order->is_paid()): ?>
|
||||||
|
<p style="margin-top: 10px;">
|
||||||
|
<button type="button" class="button button-primary" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>">
|
||||||
|
<?php esc_html_e('Generate Licenses', 'wc-licensed-product'); ?>
|
||||||
|
</button>
|
||||||
|
<span class="spinner" style="float: none; margin-top: 4px;"></span>
|
||||||
|
<span class="wclp-generate-status"></span>
|
||||||
|
</p>
|
||||||
|
<?php elseif (!$orderDomain): ?>
|
||||||
|
<p class="description" style="margin-top: 10px; color: #d63638;">
|
||||||
|
<span class="dashicons dashicons-warning"></span>
|
||||||
|
<?php esc_html_e('Please set the order domain above before generating licenses.', 'wc-licensed-product'); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table class="widefat striped wclp-licenses-table">
|
<table class="widefat striped wclp-licenses-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -223,6 +250,29 @@ final class OrderLicenseController
|
|||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<?php if ($missingLicenses > 0 && $orderDomain && $order->is_paid()): ?>
|
||||||
|
<p style="margin-top: 10px;">
|
||||||
|
<span class="dashicons dashicons-warning" style="color: #dba617;"></span>
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %d: Number of missing licenses */
|
||||||
|
esc_html(_n(
|
||||||
|
'%d licensed product is missing a license.',
|
||||||
|
'%d licensed products are missing licenses.',
|
||||||
|
$missingLicenses,
|
||||||
|
'wc-licensed-product'
|
||||||
|
)),
|
||||||
|
$missingLicenses
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
<button type="button" class="button" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>">
|
||||||
|
<?php esc_html_e('Generate Missing Licenses', 'wc-licensed-product'); ?>
|
||||||
|
</button>
|
||||||
|
<span class="spinner" style="float: none; margin-top: 4px;"></span>
|
||||||
|
<span class="wclp-generate-status"></span>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,6 +298,9 @@ final class OrderLicenseController
|
|||||||
.wclp-lifetime { color: #0073aa; font-weight: 500; }
|
.wclp-lifetime { color: #0073aa; font-weight: 500; }
|
||||||
.wclp-edit-domain-btn { color: #0073aa; text-decoration: none; }
|
.wclp-edit-domain-btn { color: #0073aa; text-decoration: none; }
|
||||||
.wclp-edit-domain-btn .dashicons { font-size: 16px; width: 16px; height: 16px; }
|
.wclp-edit-domain-btn .dashicons { font-size: 16px; width: 16px; height: 16px; }
|
||||||
|
.wclp-generate-status { font-style: italic; margin-left: 8px; }
|
||||||
|
.wclp-generate-status.success { color: #46b450; }
|
||||||
|
.wclp-generate-status.error { color: #dc3232; }
|
||||||
</style>
|
</style>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
@@ -284,8 +337,9 @@ final class OrderLicenseController
|
|||||||
'strings' => [
|
'strings' => [
|
||||||
'saving' => __('Saving...', 'wc-licensed-product'),
|
'saving' => __('Saving...', 'wc-licensed-product'),
|
||||||
'saved' => __('Saved!', 'wc-licensed-product'),
|
'saved' => __('Saved!', 'wc-licensed-product'),
|
||||||
'error' => __('Error saving. Please try again.', 'wc-licensed-product'),
|
'error' => __('Error. Please try again.', 'wc-licensed-product'),
|
||||||
'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'),
|
'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'),
|
||||||
|
'generating' => __('Generating...', 'wc-licensed-product'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -392,4 +446,96 @@ final class OrderLicenseController
|
|||||||
$pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
|
$pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
|
||||||
return (bool) preg_match($pattern, $domain);
|
return (bool) preg_match($pattern, $domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler for generating order licenses
|
||||||
|
*/
|
||||||
|
public function ajaxGenerateOrderLicenses(): void
|
||||||
|
{
|
||||||
|
check_ajax_referer('wclp_order_license_actions', 'nonce');
|
||||||
|
|
||||||
|
if (!current_user_can('manage_woocommerce')) {
|
||||||
|
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = absint($_POST['order_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$orderId) {
|
||||||
|
wp_send_json_error(['message' => __('Invalid order ID.', 'wc-licensed-product')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = wc_get_order($orderId);
|
||||||
|
if (!$order) {
|
||||||
|
wp_send_json_error(['message' => __('Order not found.', 'wc-licensed-product')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if order is paid
|
||||||
|
if (!$order->is_paid()) {
|
||||||
|
wp_send_json_error(['message' => __('Order must be paid before licenses can be generated.', 'wc-licensed-product')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get domain
|
||||||
|
$domain = $order->get_meta('_licensed_product_domain');
|
||||||
|
if (empty($domain)) {
|
||||||
|
wp_send_json_error(['message' => __('Please set the order domain before generating licenses.', 'wc-licensed-product')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate licenses for each licensed product
|
||||||
|
$generated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($order->get_items() as $item) {
|
||||||
|
$product = $item->get_product();
|
||||||
|
if ($product && $product->is_type('licensed')) {
|
||||||
|
$license = $this->licenseManager->generateLicense(
|
||||||
|
$orderId,
|
||||||
|
$product->get_id(),
|
||||||
|
$order->get_customer_id(),
|
||||||
|
$domain
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($license) {
|
||||||
|
// Check if this is a new license or existing
|
||||||
|
$existingLicenses = $this->licenseManager->getLicensesByOrder($orderId);
|
||||||
|
$isNew = true;
|
||||||
|
foreach ($existingLicenses as $existing) {
|
||||||
|
if ($existing->getProductId() === $product->get_id() && $existing->getId() !== $license->getId()) {
|
||||||
|
$isNew = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($isNew) {
|
||||||
|
$generated++;
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($generated > 0) {
|
||||||
|
wp_send_json_success([
|
||||||
|
'message' => sprintf(
|
||||||
|
/* translators: %d: Number of licenses generated */
|
||||||
|
_n(
|
||||||
|
'%d license generated successfully.',
|
||||||
|
'%d licenses generated successfully.',
|
||||||
|
$generated,
|
||||||
|
'wc-licensed-product'
|
||||||
|
),
|
||||||
|
$generated
|
||||||
|
),
|
||||||
|
'generated' => $generated,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'reload' => true,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
wp_send_json_success([
|
||||||
|
'message' => __('All licenses already exist for this order.', 'wc-licensed-product'),
|
||||||
|
'generated' => 0,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'reload' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ final class ResponseSigner
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = $this->deriveKey($licenseKey);
|
$signingKey = $this->deriveKey($licenseKey);
|
||||||
|
|
||||||
// Sort keys for consistent ordering
|
// Recursively sort keys for consistent ordering (required by client implementation)
|
||||||
ksort($data);
|
$data = $this->recursiveKeySort($data);
|
||||||
|
|
||||||
// Build signature payload
|
// Build signature payload
|
||||||
$payload = $timestamp . ':' . json_encode(
|
$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
|
* Derive a unique signing key for a license
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -95,29 +95,152 @@ final class RestApiController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client IP address
|
* 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
|
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 = [
|
$headers = [
|
||||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||||
'HTTP_X_FORWARDED_FOR',
|
'HTTP_X_FORWARDED_FOR',
|
||||||
'HTTP_X_REAL_IP',
|
'HTTP_X_REAL_IP',
|
||||||
'REMOTE_ADDR',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($headers as $header) {
|
foreach ($headers as $header) {
|
||||||
if (!empty($_SERVER[$header])) {
|
if (!empty($_SERVER[$header])) {
|
||||||
$ips = explode(',', $_SERVER[$header]);
|
$ips = explode(',', $_SERVER[$header]);
|
||||||
$ip = trim($ips[0]);
|
$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;
|
return $ip;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and return direct connection IP
|
||||||
|
if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) {
|
||||||
|
return $remoteAddr;
|
||||||
|
}
|
||||||
|
|
||||||
return '0.0.0.0';
|
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
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ final class DownloadController
|
|||||||
// Add download endpoint
|
// Add download endpoint
|
||||||
add_action('init', [$this, 'addDownloadEndpoint']);
|
add_action('init', [$this, 'addDownloadEndpoint']);
|
||||||
|
|
||||||
|
// Register query var for the endpoint
|
||||||
|
add_filter('query_vars', [$this, 'addDownloadQueryVar']);
|
||||||
|
|
||||||
// Handle download requests
|
// Handle download requests
|
||||||
add_action('template_redirect', [$this, 'handleDownloadRequest']);
|
add_action('template_redirect', [$this, 'handleDownloadRequest']);
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,15 @@ final class DownloadController
|
|||||||
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
|
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the download query var
|
||||||
|
*/
|
||||||
|
public function addDownloadQueryVar(array $vars): array
|
||||||
|
{
|
||||||
|
$vars[] = 'license-download';
|
||||||
|
return $vars;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle download request
|
* Handle download request
|
||||||
*/
|
*/
|
||||||
@@ -160,8 +172,12 @@ final class DownloadController
|
|||||||
$downloadUrl = $version->getDownloadUrl();
|
$downloadUrl = $version->getDownloadUrl();
|
||||||
|
|
||||||
if ($attachmentId) {
|
if ($attachmentId) {
|
||||||
|
// Increment download count before serving
|
||||||
|
$this->versionManager->incrementDownloadCount($versionId);
|
||||||
$this->serveAttachment($attachmentId, $version->getVersion());
|
$this->serveAttachment($attachmentId, $version->getVersion());
|
||||||
} elseif ($downloadUrl) {
|
} elseif ($downloadUrl) {
|
||||||
|
// Increment download count before redirect
|
||||||
|
$this->versionManager->incrementDownloadCount($versionId);
|
||||||
// Redirect to external URL
|
// Redirect to external URL
|
||||||
wp_redirect($downloadUrl);
|
wp_redirect($downloadUrl);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ final class Installer
|
|||||||
// Set version in options
|
// Set version in options
|
||||||
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
|
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
|
||||||
|
|
||||||
// Register the licenses endpoint before flushing rewrite rules
|
// Register endpoints before flushing rewrite rules
|
||||||
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
|
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
|
||||||
|
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
|
||||||
|
|
||||||
// Flush rewrite rules for REST API and My Account endpoints
|
// Flush rewrite rules for REST API and My Account endpoints
|
||||||
flush_rewrite_rules();
|
flush_rewrite_rules();
|
||||||
@@ -103,6 +104,7 @@ final class Installer
|
|||||||
download_url VARCHAR(512) DEFAULT NULL,
|
download_url VARCHAR(512) DEFAULT NULL,
|
||||||
attachment_id BIGINT UNSIGNED DEFAULT NULL,
|
attachment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||||
file_hash VARCHAR(64) DEFAULT NULL,
|
file_hash VARCHAR(64) DEFAULT NULL,
|
||||||
|
download_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace Jeremias\WcLicensedProduct;
|
|||||||
|
|
||||||
use Jeremias\WcLicensedProduct\Admin\AdminController;
|
use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||||
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
|
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
|
||||||
|
use Jeremias\WcLicensedProduct\Admin\DownloadWidgetController;
|
||||||
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
|
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
|
||||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||||
@@ -98,6 +99,7 @@ final class Plugin
|
|||||||
$this->twig = new Environment($loader, [
|
$this->twig = new Environment($loader, [
|
||||||
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
|
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
|
||||||
'auto_reload' => true, // Always check for template changes
|
'auto_reload' => true, // Always check for template changes
|
||||||
|
'autoescape' => 'html', // Explicitly enable HTML autoescape for XSS protection
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add WordPress functions as Twig functions
|
// Add WordPress functions as Twig functions
|
||||||
@@ -153,6 +155,7 @@ final class Plugin
|
|||||||
new OrderLicenseController($this->licenseManager);
|
new OrderLicenseController($this->licenseManager);
|
||||||
new SettingsController();
|
new SettingsController();
|
||||||
new DashboardWidgetController($this->licenseManager);
|
new DashboardWidgetController($this->licenseManager);
|
||||||
|
new DownloadWidgetController($this->versionManager);
|
||||||
|
|
||||||
// Show admin notice if unlicensed and not on localhost
|
// Show admin notice if unlicensed and not on localhost
|
||||||
if (!$isLicensed && !$licenseChecker->isLocalhost()) {
|
if (!$isLicensed && !$licenseChecker->isLocalhost()) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ProductVersion
|
|||||||
private ?string $downloadUrl;
|
private ?string $downloadUrl;
|
||||||
private ?int $attachmentId;
|
private ?int $attachmentId;
|
||||||
private ?string $fileHash;
|
private ?string $fileHash;
|
||||||
|
private int $downloadCount;
|
||||||
private bool $isActive;
|
private bool $isActive;
|
||||||
private \DateTimeInterface $releasedAt;
|
private \DateTimeInterface $releasedAt;
|
||||||
private \DateTimeInterface $createdAt;
|
private \DateTimeInterface $createdAt;
|
||||||
@@ -44,6 +45,7 @@ class ProductVersion
|
|||||||
$version->downloadUrl = $data['download_url'] ?: null;
|
$version->downloadUrl = $data['download_url'] ?: null;
|
||||||
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
|
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
|
||||||
$version->fileHash = $data['file_hash'] ?? null;
|
$version->fileHash = $data['file_hash'] ?? null;
|
||||||
|
$version->downloadCount = (int) ($data['download_count'] ?? 0);
|
||||||
$version->isActive = (bool) $data['is_active'];
|
$version->isActive = (bool) $data['is_active'];
|
||||||
$version->releasedAt = new \DateTimeImmutable($data['released_at']);
|
$version->releasedAt = new \DateTimeImmutable($data['released_at']);
|
||||||
$version->createdAt = new \DateTimeImmutable($data['created_at']);
|
$version->createdAt = new \DateTimeImmutable($data['created_at']);
|
||||||
@@ -144,6 +146,11 @@ class ProductVersion
|
|||||||
return $this->fileHash;
|
return $this->fileHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDownloadCount(): int
|
||||||
|
{
|
||||||
|
return $this->downloadCount;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the download URL from attachment
|
* Get the download URL from attachment
|
||||||
*/
|
*/
|
||||||
@@ -197,6 +204,7 @@ class ProductVersion
|
|||||||
'download_url' => $this->downloadUrl,
|
'download_url' => $this->downloadUrl,
|
||||||
'attachment_id' => $this->attachmentId,
|
'attachment_id' => $this->attachmentId,
|
||||||
'file_hash' => $this->fileHash,
|
'file_hash' => $this->fileHash,
|
||||||
|
'download_count' => $this->downloadCount,
|
||||||
'is_active' => $this->isActive,
|
'is_active' => $this->isActive,
|
||||||
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
|
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
|
||||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||||
|
|||||||
@@ -276,4 +276,98 @@ class VersionManager
|
|||||||
|
|
||||||
return (int) $count > 0;
|
return (int) $count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment download count for a version
|
||||||
|
*/
|
||||||
|
public function incrementDownloadCount(int $versionId): bool
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$tableName = Installer::getVersionsTable();
|
||||||
|
$result = $wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE {$tableName} SET download_count = download_count + 1 WHERE id = %d",
|
||||||
|
$versionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total download count across all versions
|
||||||
|
*/
|
||||||
|
public function getTotalDownloadCount(): int
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$tableName = Installer::getVersionsTable();
|
||||||
|
$count = $wpdb->get_var("SELECT COALESCE(SUM(download_count), 0) FROM {$tableName}");
|
||||||
|
|
||||||
|
return (int) $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download statistics per product
|
||||||
|
*/
|
||||||
|
public function getDownloadStatistics(): array
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$tableName = Installer::getVersionsTable();
|
||||||
|
|
||||||
|
// Get total downloads
|
||||||
|
$totalDownloads = $this->getTotalDownloadCount();
|
||||||
|
|
||||||
|
// Get downloads per product (top 10)
|
||||||
|
$byProduct = $wpdb->get_results(
|
||||||
|
"SELECT product_id, SUM(download_count) as downloads
|
||||||
|
FROM {$tableName}
|
||||||
|
GROUP BY product_id
|
||||||
|
ORDER BY downloads DESC
|
||||||
|
LIMIT 10",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get downloads per version (top 10)
|
||||||
|
$byVersion = $wpdb->get_results(
|
||||||
|
"SELECT id, product_id, version, download_count
|
||||||
|
FROM {$tableName}
|
||||||
|
WHERE download_count > 0
|
||||||
|
ORDER BY download_count DESC
|
||||||
|
LIMIT 10",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrich product data with names
|
||||||
|
$productsWithNames = [];
|
||||||
|
foreach ($byProduct ?: [] as $row) {
|
||||||
|
$product = wc_get_product((int) $row['product_id']);
|
||||||
|
$productsWithNames[] = [
|
||||||
|
'product_id' => (int) $row['product_id'],
|
||||||
|
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||||
|
'downloads' => (int) $row['downloads'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich version data with product names
|
||||||
|
$versionsWithNames = [];
|
||||||
|
foreach ($byVersion ?: [] as $row) {
|
||||||
|
$product = wc_get_product((int) $row['product_id']);
|
||||||
|
$versionsWithNames[] = [
|
||||||
|
'version_id' => (int) $row['id'],
|
||||||
|
'product_id' => (int) $row['product_id'],
|
||||||
|
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||||
|
'version' => $row['version'],
|
||||||
|
'downloads' => (int) $row['download_count'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $totalDownloads,
|
||||||
|
'by_product' => $productsWithNames,
|
||||||
|
'by_version' => $versionsWithNames,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1 class="wp-heading-inline">{{ __('Licenses') }}</h1>
|
<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>
|
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||||
{{ __('Export CSV') }}
|
{{ __('Export CSV') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -143,8 +143,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="wclp-editable-cell" data-field="status" data-license-id="{{ item.license.id }}">
|
<td class="wclp-editable-cell" data-field="status" data-license-id="{{ item.license.id }}">
|
||||||
<span class="wclp-display-value">
|
<span class="wclp-display-value">
|
||||||
<span class="license-status license-status-{{ item.license.status }}">
|
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
|
||||||
{{ item.license.status|capitalize }}
|
{{ esc_html(item.license.status)|capitalize }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" class="wclp-edit-btn button-link" title="{{ __('Edit') }}">
|
<button type="button" class="wclp-edit-btn button-link" title="{{ __('Edit') }}">
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
{{ esc_html(item.product_name) }}
|
{{ esc_html(item.product_name) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="license-status license-status-{{ item.license.status }}">
|
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
|
||||||
{{ item.license.status|capitalize }}
|
{{ esc_html(item.license.status)|capitalize }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: WooCommerce Licensed Product
|
* Plugin Name: WooCommerce Licensed Product
|
||||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-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.
|
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
|
||||||
* Version: 0.3.5
|
* Version: 0.3.9
|
||||||
* Author: Marco Graetsch
|
* Author: Marco Graetsch
|
||||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plugin constants
|
// Plugin constants
|
||||||
define('WC_LICENSED_PRODUCT_VERSION', '0.3.5');
|
define('WC_LICENSED_PRODUCT_VERSION', '0.3.9');
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user