You've already forked wc-licensed-product
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aecba3272 | |||
| 23bbc24c5f | |||
| 8420734f37 | |||
| 968cd6a18f | |||
| 5256f88815 | |||
| d0c0756412 | |||
| 6770ec5201 | |||
| 6f1ea3c6fa | |||
| 10453360ad | |||
| 8ada21afb0 | |||
| 27dc1b67c5 | |||
| a73b7cc550 | |||
| 89c5a40f56 | |||
| 79b9c2cefd | |||
| f8b88e4c98 | |||
| 1008617623 | |||
| 45531f86d6 | |||
| ff9b27e811 | |||
| a59631aec3 | |||
| 0e3b57482e | |||
| b3cf3e114b |
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(composer config:*)",
|
||||||
|
"Bash(composer update:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
wp-plugins
|
wp-plugins
|
||||||
wp-core
|
wp-core
|
||||||
vendor/
|
vendor/
|
||||||
|
releases/*
|
||||||
|
|||||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-01-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Response signing for REST API using HMAC-SHA256
|
||||||
|
- SHA256 hash field for product version uploads with checksum validation
|
||||||
|
- File integrity verification before storing uploaded version files
|
||||||
|
- New `ResponseSigner` class for automatic API response signing
|
||||||
|
- Database column `file_hash` in versions table for storing checksums
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Version uploads now require file attachments (external URL option removed)
|
||||||
|
- API responses now include `X-License-Signature` and `X-License-Timestamp` headers when `WC_LICENSE_SERVER_SECRET` is configured
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- External download URL field from product version form
|
||||||
|
- Direct URL support in version uploads (use Media Library uploads only)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- API response signing prevents tampering and replay attacks
|
||||||
|
- Per-license key derivation using HKDF-like approach
|
||||||
|
- SHA256 checksum validation ensures file integrity
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- New class: `ResponseSigner` for HMAC-SHA256 response signing
|
||||||
|
- VersionManager extended with `$fileHash` parameter and validation
|
||||||
|
- ProductVersion model extended with `fileHash` property
|
||||||
|
- Signature algorithm: `HMAC-SHA256(derived_key, timestamp + ':' + canonical_json)`
|
||||||
|
- Key derivation: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
|
||||||
|
- Compatible with `magdev/wc-licensed-product-client` SecureLicenseClient
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
To enable response signing, add to `wp-config.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||||
|
```
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- First stable minor release
|
||||||
|
- Comprehensive code review for WordPress/WooCommerce best practices
|
||||||
|
- Security audit completed
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved input sanitization for admin page context checks
|
||||||
|
- Fixed VersionManager null format handling for attachment updates
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- All code reviewed for OWASP Top 10 security vulnerabilities
|
||||||
|
- Verified proper nonce verification, capability checks, and input sanitization
|
||||||
|
- SQL injection prevention confirmed using `$wpdb->prepare()` throughout
|
||||||
|
- XSS prevention confirmed with proper output escaping
|
||||||
|
- Rate limiting verified on REST API endpoints
|
||||||
|
- README.md updated with full feature documentation
|
||||||
|
|
||||||
|
## [0.0.11] - 2026-01-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Created date column in admin license overview
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Added "Created" column to licenses table in admin (Twig template and PHP fallback)
|
||||||
|
- Shows when each license was generated
|
||||||
|
|
||||||
## [0.0.10] - 2026-01-21
|
## [0.0.10] - 2026-01-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -17,13 +93,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- View licenses table showing all licenses for an order
|
- View licenses table showing all licenses for an order
|
||||||
- Link to full licenses management page from order view
|
- Link to full licenses management page from order view
|
||||||
- Support for both classic orders and HPOS (High-Performance Order Storage)
|
- Support for both classic orders and HPOS (High-Performance Order Storage)
|
||||||
|
- Inline editing for license fields (status, expiry date, domain) in admin
|
||||||
|
- Copy license key button in admin licenses overview
|
||||||
|
- Live search for licenses in admin overview
|
||||||
|
- Settings link in plugin actions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed 404 error on licenses menu item
|
||||||
|
- Fixed Twig template cache issues with auto_reload
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
|
|
||||||
- New `OrderLicenseController` class for order page integration
|
- New `OrderLicenseController` class for order page integration
|
||||||
- New `getLicensesByOrder()` method in LicenseManager
|
- New `getLicensesByOrder()` method in LicenseManager
|
||||||
|
- New `updateLicenseExpiry()` method in LicenseManager
|
||||||
- JavaScript file `order-licenses.js` for inline domain editing
|
- JavaScript file `order-licenses.js` for inline domain editing
|
||||||
|
- JavaScript file `admin-licenses.js` for live search and inline editing
|
||||||
- AJAX handlers for updating order domain and license domains
|
- AJAX handlers for updating order domain and license domains
|
||||||
|
- AJAX handlers for inline editing (status, expiry, domain updates)
|
||||||
|
|
||||||
## [0.0.9] - 2026-01-21
|
## [0.0.9] - 2026-01-21
|
||||||
|
|
||||||
@@ -252,7 +340,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- WordPress REST API integration
|
- WordPress REST API integration
|
||||||
- Custom WooCommerce product type extending WC_Product
|
- Custom WooCommerce product type extending WC_Product
|
||||||
|
|
||||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.7...HEAD
|
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.0...HEAD
|
||||||
|
[0.2.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.11...v0.1.0
|
||||||
|
[0.0.11]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.10...v0.0.11
|
||||||
|
[0.0.10]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.9...v0.0.10
|
||||||
|
[0.0.9]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.8...v0.0.9
|
||||||
|
[0.0.8]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.7...v0.0.8
|
||||||
[0.0.7]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.6...v0.0.7
|
[0.0.7]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.6...v0.0.7
|
||||||
[0.0.6]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.5...v0.0.6
|
[0.0.6]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.5...v0.0.6
|
||||||
[0.0.5]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.4...v0.0.5
|
[0.0.5]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.4...v0.0.5
|
||||||
|
|||||||
173
CLAUDE.md
173
CLAUDE.md
@@ -34,11 +34,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
|||||||
|
|
||||||
### Known Bugs
|
### Known Bugs
|
||||||
|
|
||||||
None currently known.
|
No known bugs at the moment
|
||||||
|
|
||||||
### Version 0.0.11 (planned)
|
No planned features at this time. See Session History for completed work.
|
||||||
|
|
||||||
- TBD - no specific features planned yet
|
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
|
|
||||||
@@ -554,17 +552,168 @@ Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
|||||||
- Inline edit UI with save/cancel for license domains
|
- Inline edit UI with save/cancel for license domains
|
||||||
- Links to full licenses management page for advanced actions
|
- Links to full licenses management page for advanced actions
|
||||||
|
|
||||||
**Bug fix (post v0.0.10):**
|
**Additional features added in this session:**
|
||||||
|
|
||||||
|
- Inline editing for license fields (status, expiry date, domain) in admin licenses overview
|
||||||
|
- Copy license key button with clipboard API and fallback for older browsers
|
||||||
|
- Live search for licenses in admin overview with AJAX-powered results dropdown
|
||||||
|
- Settings link added to plugin action links in Plugins list
|
||||||
|
- Fixed 404 error on licenses menu item in customer account
|
||||||
|
- Fixed Twig template cache issues with `auto_reload` option
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
|
||||||
|
- `assets/js/admin-licenses.js` - JavaScript for live search, inline editing, and copy functionality
|
||||||
|
|
||||||
|
**New methods in LicenseManager:**
|
||||||
|
|
||||||
|
- `updateLicenseExpiry()` - Update license expiry date with auto-reactivation for expired licenses
|
||||||
|
|
||||||
|
**AJAX handlers added to AdminController:**
|
||||||
|
|
||||||
|
- `handleAjaxStatusUpdate()` - Update license status via AJAX
|
||||||
|
- `handleAjaxExpiryUpdate()` - Update license expiry date via AJAX
|
||||||
|
- `handleAjaxDomainUpdate()` - Update license domain via AJAX
|
||||||
|
- `handleAjaxRevoke()` - Revoke license via AJAX
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
- Live search uses 300ms debounce and keyboard navigation (arrows, enter, escape)
|
||||||
|
- Inline editing shows edit icons on hover, supports enter to save and escape to cancel
|
||||||
|
- Copy button uses Clipboard API with textarea fallback for older browsers
|
||||||
|
- All AJAX handlers use nonce verification (`wclp_inline_edit` nonce)
|
||||||
|
- Twig configured with `auto_reload => true` for development to always check template changes
|
||||||
|
|
||||||
|
**Bug fix:**
|
||||||
|
|
||||||
- Fixed: Licenses menu item in customer account page resulted in 404 error
|
- Fixed: Licenses menu item in customer account page resulted in 404 error
|
||||||
|
- Root cause: WooCommerce My Account endpoints require both `add_rewrite_endpoint()` AND registration with `woocommerce_get_query_vars` filter
|
||||||
|
- Fix: Added `addLicensesQueryVar()` method to register the endpoint query var with WooCommerce
|
||||||
|
|
||||||
**Root cause:**
|
**Release v0.0.10:**
|
||||||
|
|
||||||
- WooCommerce My Account endpoints require both `add_rewrite_endpoint()` AND registration with `woocommerce_get_query_vars` filter
|
- Created release package: `releases/wc-licensed-product-0.0.10.zip` (472 KB)
|
||||||
- The endpoint also needs to be registered before rewrite rules are flushed during activation
|
- SHA256: `3f4a093f6d4d02389082c3a88c00542f477ab3ad4d4a0c65079e524ef0739620`
|
||||||
|
- Tagged as `v0.0.10` and pushed to `main` branch
|
||||||
|
|
||||||
**Fix:**
|
### 2026-01-22 - Version 0.0.11 Features
|
||||||
|
|
||||||
- Added `addLicensesQueryVar()` method to register the endpoint query var with WooCommerce
|
**Implemented:**
|
||||||
- Updated Installer to register endpoint before flushing rewrite rules on activation
|
|
||||||
- Existing installations may need to visit Settings > Permalinks and click Save to regenerate rewrite rules
|
- Created date column added to admin license overview showing when each license was generated
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `templates/admin/licenses.html.twig` - Added "Created" column to table header and data cells
|
||||||
|
- `src/Admin/AdminController.php` - Added "Created" column to PHP fallback rendering
|
||||||
|
- `src/Plugin.php` - Added `getInstance()` alias for singleton access
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
- New column displays license creation date in Y-m-d format
|
||||||
|
- Both Twig template and PHP fallback updated for consistency
|
||||||
|
- WooCommerce Analytics integration was attempted but removed due to WordPress permission issues with submenu pages
|
||||||
|
|
||||||
|
**Release v0.0.11:**
|
||||||
|
|
||||||
|
- Created release package: `releases/wc-licensed-product-0.0.11.zip` (473 KB)
|
||||||
|
- SHA256: `c3f66c4ac54741053f87ce1a63b4ddb49ad9707d5c194a271311bb95518ab13c`
|
||||||
|
- Tagged as `v0.0.11` and pushed to `main` branch
|
||||||
|
|
||||||
|
### 2026-01-22 - Version 0.1.0 - First Stable Minor Release
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
|
||||||
|
First stable minor release after comprehensive code review for WordPress/WooCommerce best practices and security.
|
||||||
|
|
||||||
|
**Code Review Findings:**
|
||||||
|
|
||||||
|
Security practices verified:
|
||||||
|
|
||||||
|
- Input sanitization with `sanitize_text_field()`, `absint()`, `esc_attr()`, `esc_html()`, `esc_url()`
|
||||||
|
- Nonce verification on all forms and AJAX handlers
|
||||||
|
- Capability checks with `current_user_can('manage_woocommerce')`
|
||||||
|
- SQL injection prevention using `$wpdb->prepare()` throughout
|
||||||
|
- Secure download URLs with hash verification using `hash_equals()`
|
||||||
|
- Rate limiting on REST API (30 requests/minute)
|
||||||
|
- Cryptographically secure license key generation with `random_int()`
|
||||||
|
|
||||||
|
**Bug Fixes:**
|
||||||
|
|
||||||
|
- Fixed `VersionManager::updateVersion()` null format handling for attachment ID updates
|
||||||
|
- Improved input sanitization in `AdminController::enqueueAdminAssets()` for page context checks
|
||||||
|
|
||||||
|
**Documentation Updates:**
|
||||||
|
|
||||||
|
- Updated README.md with complete feature documentation
|
||||||
|
- Added new features: Live Search, Inline Editing, Order Integration, WooCommerce HPOS compatibility, Checkout Blocks support
|
||||||
|
- Removed outdated "Current Version" field from usage instructions
|
||||||
|
|
||||||
|
**Translation Updates:**
|
||||||
|
|
||||||
|
- Regenerated .pot template with all current strings
|
||||||
|
- Updated German (de_CH) translation with new strings
|
||||||
|
- Compiled .mo file for production use
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `src/Product/VersionManager.php` - Fixed null format handling in attachment update
|
||||||
|
- `src/Admin/AdminController.php` - Improved $_GET sanitization for page context
|
||||||
|
- `README.md` - Updated feature documentation
|
||||||
|
- `CHANGELOG.md` - Added 0.1.0 release notes
|
||||||
|
- `wc-licensed-product.php` - Version bump to 0.1.0
|
||||||
|
- `languages/*` - Updated all translation files
|
||||||
|
|
||||||
|
**Release v0.1.0:**
|
||||||
|
|
||||||
|
- Created release package: `releases/wc-licensed-product-0.1.0.zip` (478 KB)
|
||||||
|
- SHA256: `62638e240315107098be4cb40faff8395e9e1b719d79b73d80e69d680b305e87`
|
||||||
|
- Tagged as `v0.1.0` and pushed to `main` branch
|
||||||
|
|
||||||
|
### 2026-01-22 - Version 0.2.0 - Security & Integrity Features
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
|
||||||
|
Added response signing for REST API security and SHA256 checksum validation for uploaded version files.
|
||||||
|
|
||||||
|
**Implemented:**
|
||||||
|
|
||||||
|
- REST API response signing using HMAC-SHA256 for tamper-proof responses
|
||||||
|
- SHA256 hash field for product version uploads with server-side validation
|
||||||
|
- Per-license key derivation using HKDF-like approach
|
||||||
|
- Automatic signature headers on license API endpoints
|
||||||
|
|
||||||
|
**Removed:**
|
||||||
|
|
||||||
|
- External download URL field from product version form
|
||||||
|
- Direct URL support in version uploads (Media Library only now)
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
|
||||||
|
- `src/Api/ResponseSigner.php` - HMAC-SHA256 response signing class
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
- `src/Installer.php` - Added `file_hash` column to versions table schema
|
||||||
|
- `src/Product/ProductVersion.php` - Added `fileHash` property and getter
|
||||||
|
- `src/Product/VersionManager.php` - Removed `$downloadUrl` parameter, added `$fileHash` with validation
|
||||||
|
- `src/Admin/VersionAdminController.php` - Removed URL field, added SHA256 hash field
|
||||||
|
- `assets/js/versions.js` - Updated form handling for hash field
|
||||||
|
- `src/Plugin.php` - Initialize ResponseSigner when server secret is configured
|
||||||
|
|
||||||
|
**Technical notes:**
|
||||||
|
|
||||||
|
- Response signing only activates when `WC_LICENSE_SERVER_SECRET` constant is defined
|
||||||
|
- Signature algorithm: `HMAC-SHA256(derived_key, timestamp + ':' + canonical_json)`
|
||||||
|
- Key derivation: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
|
||||||
|
- Hash validation throws `InvalidArgumentException` on mismatch
|
||||||
|
- Compatible with `magdev/wc-licensed-product-client` SecureLicenseClient
|
||||||
|
- Database migration handled by WordPress `dbDelta()` function
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
To enable response signing, add to `wp-config.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||||
|
```
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
|||||||
- **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)
|
||||||
|
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
|
||||||
|
|
||||||
### Customer Features
|
### Customer Features
|
||||||
|
|
||||||
@@ -30,12 +31,16 @@ 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)
|
||||||
- **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
|
||||||
|
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
|
||||||
- **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses
|
- **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses
|
||||||
- **License Transfer**: Transfer licenses to new domains
|
- **License Transfer**: Transfer licenses to new domains
|
||||||
- **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
|
||||||
- **Expiration Warnings**: Automatic email notifications before license expiration
|
- **Expiration Warnings**: Automatic email notifications before license expiration
|
||||||
- **Version Management**: Manage multiple versions per product with file attachments
|
- **Version Management**: Manage multiple versions per product with file attachments
|
||||||
- **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
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -60,7 +65,6 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
|||||||
- **Max Activations**: Number of domains allowed per license
|
- **Max Activations**: Number of domains allowed per license
|
||||||
- **License Validity**: Days until expiration (empty = lifetime)
|
- **License Validity**: Days until expiration (empty = lifetime)
|
||||||
- **Bind to Major Version**: Lock license to current major version
|
- **Bind to Major Version**: Lock license to current major version
|
||||||
- **Current Version**: Your software's current version
|
|
||||||
|
|
||||||
### Managing Product Versions
|
### Managing Product Versions
|
||||||
|
|
||||||
|
|||||||
@@ -78,14 +78,14 @@
|
|||||||
$('#selected_file_name').text(attachment.filename);
|
$('#selected_file_name').text(attachment.filename);
|
||||||
$('#remove-version-file-btn').show();
|
$('#remove-version-file-btn').show();
|
||||||
|
|
||||||
|
// Show SHA256 hash field
|
||||||
|
$('#sha256-hash-row').show();
|
||||||
|
|
||||||
// Try to extract version from filename
|
// Try to extract version from filename
|
||||||
var extractedVersion = self.extractVersionFromFilename(attachment.filename);
|
var extractedVersion = self.extractVersionFromFilename(attachment.filename);
|
||||||
if (extractedVersion && !$('#new_version').val().trim()) {
|
if (extractedVersion && !$('#new_version').val().trim()) {
|
||||||
$('#new_version').val(extractedVersion);
|
$('#new_version').val(extractedVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear external URL when file is selected
|
|
||||||
$('#new_download_url').val('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.mediaFrame.open();
|
this.mediaFrame.open();
|
||||||
@@ -100,6 +100,10 @@
|
|||||||
$('#new_attachment_id').val('');
|
$('#new_attachment_id').val('');
|
||||||
$('#selected_file_name').text('');
|
$('#selected_file_name').text('');
|
||||||
$('#remove-version-file-btn').hide();
|
$('#remove-version-file-btn').hide();
|
||||||
|
|
||||||
|
// Hide and clear SHA256 hash field
|
||||||
|
$('#sha256-hash-row').hide();
|
||||||
|
$('#new_file_hash').val('');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,9 +138,9 @@
|
|||||||
var $spinner = $btn.siblings('.spinner');
|
var $spinner = $btn.siblings('.spinner');
|
||||||
var productId = $btn.data('product-id');
|
var productId = $btn.data('product-id');
|
||||||
var version = $('#new_version').val().trim();
|
var version = $('#new_version').val().trim();
|
||||||
var downloadUrl = $('#new_download_url').val().trim();
|
|
||||||
var releaseNotes = $('#new_release_notes').val().trim();
|
var releaseNotes = $('#new_release_notes').val().trim();
|
||||||
var attachmentId = $('#new_attachment_id').val();
|
var attachmentId = $('#new_attachment_id').val();
|
||||||
|
var fileHash = $('#new_file_hash').val().trim();
|
||||||
|
|
||||||
// Validate version
|
// Validate version
|
||||||
if (!version) {
|
if (!version) {
|
||||||
@@ -160,9 +164,9 @@
|
|||||||
nonce: wcLicensedProductVersions.nonce,
|
nonce: wcLicensedProductVersions.nonce,
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
version: version,
|
version: version,
|
||||||
download_url: downloadUrl,
|
|
||||||
release_notes: releaseNotes,
|
release_notes: releaseNotes,
|
||||||
attachment_id: attachmentId
|
attachment_id: attachmentId,
|
||||||
|
file_hash: fileHash
|
||||||
},
|
},
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -174,11 +178,12 @@
|
|||||||
|
|
||||||
// Clear form
|
// Clear form
|
||||||
$('#new_version').val('');
|
$('#new_version').val('');
|
||||||
$('#new_download_url').val('');
|
|
||||||
$('#new_release_notes').val('');
|
$('#new_release_notes').val('');
|
||||||
$('#new_attachment_id').val('');
|
$('#new_attachment_id').val('');
|
||||||
$('#selected_file_name').text('');
|
$('#selected_file_name').text('');
|
||||||
$('#remove-version-file-btn').hide();
|
$('#remove-version-file-btn').hide();
|
||||||
|
$('#sha256-hash-row').hide();
|
||||||
|
$('#new_file_hash').val('');
|
||||||
} else {
|
} else {
|
||||||
alert(response.data.message || wcLicensedProductVersions.strings.error);
|
alert(response.data.message || wcLicensedProductVersions.strings.error);
|
||||||
}
|
}
|
||||||
|
|||||||
393
docs/server-implementation.md
Normal file
393
docs/server-implementation.md
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
# Server-Side Response Signing Implementation
|
||||||
|
|
||||||
|
This document describes how to implement response signing on the server side (e.g., in the WooCommerce Licensed Product plugin) to work with the `SecureLicenseClient`.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The security model works as follows:
|
||||||
|
|
||||||
|
1. Server generates a unique signature for each response using HMAC-SHA256
|
||||||
|
2. Signature includes a timestamp to prevent replay attacks
|
||||||
|
3. Client verifies the signature using a shared secret
|
||||||
|
4. Invalid signatures cause the client to reject the response
|
||||||
|
|
||||||
|
This prevents attackers from:
|
||||||
|
|
||||||
|
- Faking valid license responses
|
||||||
|
- Replaying old responses
|
||||||
|
- Tampering with response data
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 7.4+ (8.0+ recommended)
|
||||||
|
- A server secret stored securely (not in version control)
|
||||||
|
|
||||||
|
## Server Configuration
|
||||||
|
|
||||||
|
### 1. Store the Server Secret
|
||||||
|
|
||||||
|
Add a secret key to your WordPress configuration:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// wp-config.php or secure configuration file
|
||||||
|
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a secure secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using OpenSSL
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# Or using PHP
|
||||||
|
php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Never commit this secret to version control!
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Key Derivation
|
||||||
|
|
||||||
|
Each license key gets a unique signing key derived from the server secret:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Derive a unique signing key for a license.
|
||||||
|
*
|
||||||
|
* @param string $licenseKey The license key
|
||||||
|
* @param string $serverSecret The server's master secret
|
||||||
|
* @return string The derived key (hex encoded)
|
||||||
|
*/
|
||||||
|
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||||
|
{
|
||||||
|
// HKDF-like key derivation
|
||||||
|
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
||||||
|
|
||||||
|
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Signing
|
||||||
|
|
||||||
|
Sign every API response before sending:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Sign an API response.
|
||||||
|
*
|
||||||
|
* @param array $responseData The response body (before JSON encoding)
|
||||||
|
* @param string $licenseKey The license key from the request
|
||||||
|
* @param string $serverSecret The server's master secret
|
||||||
|
* @return array Headers to add to the response
|
||||||
|
*/
|
||||||
|
function sign_response(array $responseData, string $licenseKey, string $serverSecret): array
|
||||||
|
{
|
||||||
|
$timestamp = time();
|
||||||
|
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||||
|
|
||||||
|
// Sort keys for consistent ordering
|
||||||
|
ksort($responseData);
|
||||||
|
|
||||||
|
// Build signature payload
|
||||||
|
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
$payload = $timestamp . ':' . $jsonBody;
|
||||||
|
|
||||||
|
// Generate HMAC signature
|
||||||
|
$signature = hash_hmac('sha256', $payload, $signingKey);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'X-License-Signature' => $signature,
|
||||||
|
'X-License-Timestamp' => (string) $timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WordPress REST API Integration
|
||||||
|
|
||||||
|
Example integration with WooCommerce REST API:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Add signature headers to license API responses.
|
||||||
|
*/
|
||||||
|
add_filter('rest_post_dispatch', function($response, $server, $request) {
|
||||||
|
// Only sign license API responses
|
||||||
|
if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the response data
|
||||||
|
$data = $response->get_data();
|
||||||
|
|
||||||
|
// Get the license key from the request
|
||||||
|
$licenseKey = $request->get_param('license_key');
|
||||||
|
|
||||||
|
if (empty($licenseKey) || !is_array($data)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the response
|
||||||
|
$serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||||
|
? WC_LICENSE_SERVER_SECRET
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (empty($serverSecret)) {
|
||||||
|
// Log warning: server secret not configured
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$signatureHeaders = sign_response($data, $licenseKey, $serverSecret);
|
||||||
|
|
||||||
|
// Add headers to response
|
||||||
|
foreach ($signatureHeaders as $name => $value) {
|
||||||
|
$response->header($name, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}, 10, 3);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete WordPress Plugin Example
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: WC Licensed Product Signature
|
||||||
|
* Description: Adds response signing to WC Licensed Product API
|
||||||
|
* Version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WcLicensedProduct\Security;
|
||||||
|
|
||||||
|
class ResponseSigner
|
||||||
|
{
|
||||||
|
private string $serverSecret;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||||
|
? WC_LICENSE_SERVER_SECRET
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function signResponse($response, $server, $request)
|
||||||
|
{
|
||||||
|
if (!$this->shouldSign($request)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->get_data();
|
||||||
|
$licenseKey = $request->get_param('license_key');
|
||||||
|
|
||||||
|
if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = $this->createSignatureHeaders($data, $licenseKey);
|
||||||
|
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$response->header($name, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldSign($request): bool
|
||||||
|
{
|
||||||
|
$route = $request->get_route();
|
||||||
|
|
||||||
|
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
||||||
|
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|
||||||
|
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSignatureHeaders(array $data, string $licenseKey): array
|
||||||
|
{
|
||||||
|
$timestamp = time();
|
||||||
|
$signingKey = $this->deriveKey($licenseKey);
|
||||||
|
|
||||||
|
ksort($data);
|
||||||
|
$payload = $timestamp . ':' . json_encode(
|
||||||
|
$data,
|
||||||
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
|
||||||
|
'X-License-Timestamp' => (string) $timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveKey(string $licenseKey): string
|
||||||
|
{
|
||||||
|
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||||
|
|
||||||
|
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
add_action('init', function() {
|
||||||
|
(new ResponseSigner())->register();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
Every signed response includes:
|
||||||
|
|
||||||
|
| Header | Description | Example |
|
||||||
|
| -------- | ------------- | --------- |
|
||||||
|
| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) |
|
||||||
|
| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` |
|
||||||
|
|
||||||
|
### Signature Algorithm
|
||||||
|
|
||||||
|
```text
|
||||||
|
signature = HMAC-SHA256(
|
||||||
|
key = derive_signing_key(license_key, server_secret),
|
||||||
|
message = timestamp + ":" + canonical_json(response_body)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
- `derive_signing_key` uses HKDF-like derivation (see above)
|
||||||
|
- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode
|
||||||
|
- Result is hex-encoded (64 characters)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Verify Signing Works
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Test script
|
||||||
|
$serverSecret = 'test-secret-key-for-development-only';
|
||||||
|
$licenseKey = 'ABCD-1234-EFGH-5678';
|
||||||
|
$responseData = [
|
||||||
|
'valid' => true,
|
||||||
|
'license' => [
|
||||||
|
'product_id' => 123,
|
||||||
|
'expires_at' => '2027-01-21',
|
||||||
|
'version_id' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$headers = sign_response($responseData, $licenseKey, $serverSecret);
|
||||||
|
|
||||||
|
echo "X-License-Signature: " . $headers['X-License-Signature'] . "\n";
|
||||||
|
echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test with Client
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
||||||
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
|
|
||||||
|
$client = new SecureLicenseClient(
|
||||||
|
httpClient: HttpClient::create(),
|
||||||
|
baseUrl: 'https://your-site.com',
|
||||||
|
serverSecret: 'same-secret-as-server',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$info = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
|
||||||
|
echo "License valid! Product ID: " . $info->productId;
|
||||||
|
} catch (SignatureException $e) {
|
||||||
|
echo "Signature verification failed - possible tampering!";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Timestamp Tolerance
|
||||||
|
|
||||||
|
The client allows a 5-minute window for timestamp verification. This:
|
||||||
|
|
||||||
|
- Prevents replay attacks (old responses rejected)
|
||||||
|
- Allows for reasonable clock skew between server and client
|
||||||
|
|
||||||
|
Adjust if needed:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Client-side: custom tolerance
|
||||||
|
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secret Key Rotation
|
||||||
|
|
||||||
|
To rotate the server secret:
|
||||||
|
|
||||||
|
1. Deploy new secret to server
|
||||||
|
2. Update client configurations
|
||||||
|
3. Old signatures become invalid immediately
|
||||||
|
|
||||||
|
For zero-downtime rotation, implement versioned secrets:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Server supports both old and new secrets during transition
|
||||||
|
$secrets = [
|
||||||
|
'v2' => 'new-secret',
|
||||||
|
'v1' => 'old-secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add version to signature header
|
||||||
|
$response->header('X-License-Signature-Version', 'v2');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
Sign error responses too! Otherwise attackers could craft fake error messages:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Sign both success and error responses
|
||||||
|
$errorData = [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => 'license_expired',
|
||||||
|
'message' => 'This license has expired.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$headers = sign_response($errorData, $licenseKey, $serverSecret);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Response is not signed by the server"
|
||||||
|
|
||||||
|
- Server not configured with `WC_LICENSE_SERVER_SECRET`
|
||||||
|
- Filter not registered (check plugin activation)
|
||||||
|
- Route mismatch (check `shouldSign()` paths)
|
||||||
|
|
||||||
|
### "Response signature verification failed"
|
||||||
|
|
||||||
|
- Different secrets on server/client
|
||||||
|
- Clock skew > 5 minutes
|
||||||
|
- Response body modified after signing (e.g., by caching plugin)
|
||||||
|
- JSON encoding differences (check `ksort` and flags)
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
Enable detailed logging:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Server-side
|
||||||
|
error_log('Signing response for: ' . $licenseKey);
|
||||||
|
error_log('Timestamp: ' . $timestamp);
|
||||||
|
error_log('Payload: ' . $payload);
|
||||||
|
error_log('Signature: ' . $signature);
|
||||||
|
|
||||||
|
// Client-side: use a PSR-3 logger
|
||||||
|
$client = new SecureLicenseClient(
|
||||||
|
// ...
|
||||||
|
logger: new YourDebugLogger(),
|
||||||
|
);
|
||||||
|
```
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,9 @@ final class AdminController
|
|||||||
{
|
{
|
||||||
// Check for our pages and WooCommerce Reports page with licenses tab
|
// Check for our pages and WooCommerce Reports page with licenses tab
|
||||||
$isLicensePage = in_array($hook, ['woocommerce_page_wc-licenses', 'woocommerce_page_wc-license-dashboard'], true);
|
$isLicensePage = in_array($hook, ['woocommerce_page_wc-licenses', 'woocommerce_page_wc-license-dashboard'], true);
|
||||||
$isReportsPage = $hook === 'woocommerce_page_wc-reports' && isset($_GET['tab']) && $_GET['tab'] === 'licenses';
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking current page context
|
||||||
|
$currentTab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : '';
|
||||||
|
$isReportsPage = $hook === 'woocommerce_page_wc-reports' && $currentTab === 'licenses';
|
||||||
|
|
||||||
if (!$isLicensePage && !$isReportsPage) {
|
if (!$isLicensePage && !$isReportsPage) {
|
||||||
return;
|
return;
|
||||||
@@ -1253,6 +1255,7 @@ final class AdminController
|
|||||||
<th><?php esc_html_e('Customer', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Customer', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
||||||
|
<th><?php esc_html_e('Created', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1260,7 +1263,7 @@ final class AdminController
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($enrichedLicenses)): ?>
|
<?php if (empty($enrichedLicenses)): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8"><?php esc_html_e('No licenses found.', 'wc-licensed-product'); ?></td>
|
<td colspan="9"><?php esc_html_e('No licenses found.', 'wc-licensed-product'); ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($enrichedLicenses as $item): ?>
|
<?php foreach ($enrichedLicenses as $item): ?>
|
||||||
@@ -1320,6 +1323,9 @@ final class AdminController
|
|||||||
<button type="button" class="wclp-cancel-btn button button-small"><?php esc_html_e('Cancel', 'wc-licensed-product'); ?></button>
|
<button type="button" class="wclp-cancel-btn button button-small"><?php esc_html_e('Cancel', 'wc-licensed-product'); ?></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="wclp-created-cell">
|
||||||
|
<?php echo esc_html($item['license']->getCreatedAt()->format(get_option('date_format'))); ?>
|
||||||
|
</td>
|
||||||
<td class="wclp-editable-cell" data-field="expiry" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>">
|
<td class="wclp-editable-cell" data-field="expiry" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>">
|
||||||
<?php $expiresAt = $item['license']->getExpiresAt(); ?>
|
<?php $expiresAt = $item['license']->getExpiresAt(); ?>
|
||||||
<span class="wclp-display-value">
|
<span class="wclp-display-value">
|
||||||
@@ -1387,6 +1393,7 @@ final class AdminController
|
|||||||
<th><?php esc_html_e('Customer', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Customer', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
||||||
|
<th><?php esc_html_e('Created', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
||||||
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
|
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -98,11 +98,11 @@ final class VersionAdminController
|
|||||||
<p class="description"><?php esc_html_e('Upload or select a file from the media library. Version will be auto-detected from filename (e.g., plugin-v1.2.3.zip).', 'wc-licensed-product'); ?></p>
|
<p class="description"><?php esc_html_e('Upload or select a file from the media library. Version will be auto-detected from filename (e.g., plugin-v1.2.3.zip).', 'wc-licensed-product'); ?></p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr id="sha256-hash-row" style="display: none;">
|
||||||
<th><label for="new_download_url"><?php esc_html_e('Or External URL', 'wc-licensed-product'); ?></label></th>
|
<th><label for="new_file_hash"><?php esc_html_e('SHA256 Hash', 'wc-licensed-product'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<input type="url" id="new_download_url" name="new_download_url" class="large-text" placeholder="https://" />
|
<input type="text" id="new_file_hash" name="new_file_hash" class="large-text" placeholder="<?php esc_attr_e('Enter SHA256 checksum...', 'wc-licensed-product'); ?>" pattern="[a-fA-F0-9]{64}" />
|
||||||
<p class="description"><?php esc_html_e('Alternative: Enter an external download URL instead of uploading a file.', 'wc-licensed-product'); ?></p>
|
<p class="description"><?php esc_html_e('SHA256 checksum of the uploaded file (optional but recommended for integrity verification).', 'wc-licensed-product'); ?></p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -242,9 +242,9 @@ final class VersionAdminController
|
|||||||
|
|
||||||
$productId = absint($_POST['product_id'] ?? 0);
|
$productId = absint($_POST['product_id'] ?? 0);
|
||||||
$version = sanitize_text_field($_POST['version'] ?? '');
|
$version = sanitize_text_field($_POST['version'] ?? '');
|
||||||
$downloadUrl = esc_url_raw($_POST['download_url'] ?? '');
|
|
||||||
$releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
|
$releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
|
||||||
$attachmentId = absint($_POST['attachment_id'] ?? 0);
|
$attachmentId = absint($_POST['attachment_id'] ?? 0);
|
||||||
|
$fileHash = sanitize_text_field($_POST['file_hash'] ?? '');
|
||||||
|
|
||||||
if (!$productId || !$version) {
|
if (!$productId || !$version) {
|
||||||
wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
|
wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
|
||||||
@@ -270,13 +270,17 @@ final class VersionAdminController
|
|||||||
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
|
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$newVersion = $this->versionManager->createVersion(
|
$newVersion = $this->versionManager->createVersion(
|
||||||
$productId,
|
$productId,
|
||||||
$version,
|
$version,
|
||||||
$releaseNotes ?: null,
|
$releaseNotes ?: null,
|
||||||
$downloadUrl ?: null,
|
$attachmentId ?: null,
|
||||||
$attachmentId ?: null
|
$fileHash ?: null
|
||||||
);
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
wp_send_json_error(['message' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$newVersion) {
|
if (!$newVersion) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|||||||
128
src/Api/ResponseSigner.php
Normal file
128
src/Api/ResponseSigner.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Response Signer
|
||||||
|
*
|
||||||
|
* Signs REST API responses to prevent tampering and replay attacks.
|
||||||
|
*
|
||||||
|
* @package Jeremias\WcLicensedProduct\Api
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Jeremias\WcLicensedProduct\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs license API responses using HMAC-SHA256
|
||||||
|
*
|
||||||
|
* The security model:
|
||||||
|
* 1. Server generates a unique signature for each response using HMAC-SHA256
|
||||||
|
* 2. Signature includes a timestamp to prevent replay attacks
|
||||||
|
* 3. Client verifies the signature using a shared secret
|
||||||
|
* 4. Invalid signatures cause the client to reject the response
|
||||||
|
*/
|
||||||
|
final class ResponseSigner
|
||||||
|
{
|
||||||
|
private string $serverSecret;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||||
|
? WC_LICENSE_SERVER_SECRET
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register WordPress hooks
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign REST API response
|
||||||
|
*
|
||||||
|
* @param \WP_REST_Response $response The response object
|
||||||
|
* @param \WP_REST_Server $server The REST server
|
||||||
|
* @param \WP_REST_Request $request The request object
|
||||||
|
* @return \WP_REST_Response
|
||||||
|
*/
|
||||||
|
public function signResponse($response, $server, $request)
|
||||||
|
{
|
||||||
|
// Only sign license API responses
|
||||||
|
if (!$this->shouldSign($request)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->get_data();
|
||||||
|
$licenseKey = $request->get_param('license_key');
|
||||||
|
|
||||||
|
if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = $this->createSignatureHeaders($data, $licenseKey);
|
||||||
|
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$response->header($name, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request should be signed
|
||||||
|
*/
|
||||||
|
private function shouldSign(\WP_REST_Request $request): bool
|
||||||
|
{
|
||||||
|
$route = $request->get_route();
|
||||||
|
|
||||||
|
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
||||||
|
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|
||||||
|
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create signature headers for response
|
||||||
|
*
|
||||||
|
* @param array $data The response data
|
||||||
|
* @param string $licenseKey The license key from the request
|
||||||
|
* @return array Associative array of headers
|
||||||
|
*/
|
||||||
|
private function createSignatureHeaders(array $data, string $licenseKey): array
|
||||||
|
{
|
||||||
|
$timestamp = time();
|
||||||
|
$signingKey = $this->deriveKey($licenseKey);
|
||||||
|
|
||||||
|
// Sort keys for consistent ordering
|
||||||
|
ksort($data);
|
||||||
|
|
||||||
|
// Build signature payload
|
||||||
|
$payload = $timestamp . ':' . json_encode(
|
||||||
|
$data,
|
||||||
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
|
||||||
|
'X-License-Timestamp' => (string) $timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a unique signing key for a license
|
||||||
|
*
|
||||||
|
* Uses HKDF-like key derivation to create a unique signing key
|
||||||
|
* for each license key, preventing cross-license signature attacks.
|
||||||
|
*
|
||||||
|
* @param string $licenseKey The license key
|
||||||
|
* @return string The derived signing key (hex encoded)
|
||||||
|
*/
|
||||||
|
private function deriveKey(string $licenseKey): string
|
||||||
|
{
|
||||||
|
// HKDF-like key derivation
|
||||||
|
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||||
|
|
||||||
|
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ final class Installer
|
|||||||
release_notes TEXT DEFAULT NULL,
|
release_notes TEXT DEFAULT NULL,
|
||||||
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,
|
||||||
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,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Jeremias\WcLicensedProduct\Admin\AdminController;
|
|||||||
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;
|
||||||
|
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
||||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||||
@@ -68,6 +69,14 @@ final class Plugin
|
|||||||
return self::$instance;
|
return self::$instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance (alias for instance())
|
||||||
|
*/
|
||||||
|
public static function getInstance(): Plugin
|
||||||
|
{
|
||||||
|
return self::instance();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor for singleton
|
* Private constructor for singleton
|
||||||
*/
|
*/
|
||||||
@@ -120,6 +129,11 @@ final class Plugin
|
|||||||
new RestApiController($this->licenseManager);
|
new RestApiController($this->licenseManager);
|
||||||
new LicenseEmailController($this->licenseManager);
|
new LicenseEmailController($this->licenseManager);
|
||||||
|
|
||||||
|
// Initialize response signing if server secret is configured
|
||||||
|
if (defined('WC_LICENSE_SERVER_SECRET') && WC_LICENSE_SERVER_SECRET !== '') {
|
||||||
|
(new ResponseSigner())->register();
|
||||||
|
}
|
||||||
|
|
||||||
if (is_admin()) {
|
if (is_admin()) {
|
||||||
new AdminController($this->twig, $this->licenseManager);
|
new AdminController($this->twig, $this->licenseManager);
|
||||||
new VersionAdminController($this->versionManager);
|
new VersionAdminController($this->versionManager);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ProductVersion
|
|||||||
private ?string $releaseNotes;
|
private ?string $releaseNotes;
|
||||||
private ?string $downloadUrl;
|
private ?string $downloadUrl;
|
||||||
private ?int $attachmentId;
|
private ?int $attachmentId;
|
||||||
|
private ?string $fileHash;
|
||||||
private bool $isActive;
|
private bool $isActive;
|
||||||
private \DateTimeInterface $releasedAt;
|
private \DateTimeInterface $releasedAt;
|
||||||
private \DateTimeInterface $createdAt;
|
private \DateTimeInterface $createdAt;
|
||||||
@@ -42,6 +43,7 @@ class ProductVersion
|
|||||||
$version->releaseNotes = $data['release_notes'] ?: null;
|
$version->releaseNotes = $data['release_notes'] ?: null;
|
||||||
$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->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']);
|
||||||
@@ -137,15 +139,20 @@ class ProductVersion
|
|||||||
return $this->attachmentId;
|
return $this->attachmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getFileHash(): ?string
|
||||||
|
{
|
||||||
|
return $this->fileHash;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the effective download URL (from attachment or direct URL)
|
* Get the download URL from attachment
|
||||||
*/
|
*/
|
||||||
public function getEffectiveDownloadUrl(): ?string
|
public function getEffectiveDownloadUrl(): ?string
|
||||||
{
|
{
|
||||||
if ($this->attachmentId) {
|
if ($this->attachmentId) {
|
||||||
return wp_get_attachment_url($this->attachmentId) ?: null;
|
return wp_get_attachment_url($this->attachmentId) ?: null;
|
||||||
}
|
}
|
||||||
return $this->downloadUrl;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,9 +163,6 @@ class ProductVersion
|
|||||||
if ($this->attachmentId) {
|
if ($this->attachmentId) {
|
||||||
return wp_basename(get_attached_file($this->attachmentId) ?: '');
|
return wp_basename(get_attached_file($this->attachmentId) ?: '');
|
||||||
}
|
}
|
||||||
if ($this->downloadUrl) {
|
|
||||||
return wp_basename($this->downloadUrl);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +196,7 @@ class ProductVersion
|
|||||||
'release_notes' => $this->releaseNotes,
|
'release_notes' => $this->releaseNotes,
|
||||||
'download_url' => $this->downloadUrl,
|
'download_url' => $this->downloadUrl,
|
||||||
'attachment_id' => $this->attachmentId,
|
'attachment_id' => $this->attachmentId,
|
||||||
|
'file_hash' => $this->fileHash,
|
||||||
'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'),
|
||||||
|
|||||||
@@ -92,16 +92,27 @@ class VersionManager
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new version
|
* Create a new version
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If file hash validation fails
|
||||||
*/
|
*/
|
||||||
public function createVersion(
|
public function createVersion(
|
||||||
int $productId,
|
int $productId,
|
||||||
string $version,
|
string $version,
|
||||||
?string $releaseNotes = null,
|
?string $releaseNotes = null,
|
||||||
?string $downloadUrl = null,
|
?int $attachmentId = null,
|
||||||
?int $attachmentId = null
|
?string $fileHash = null
|
||||||
): ?ProductVersion {
|
): ?ProductVersion {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
|
// Validate file hash if both attachment and hash are provided
|
||||||
|
if ($attachmentId !== null && $attachmentId > 0 && $fileHash !== null && $fileHash !== '') {
|
||||||
|
$validatedHash = $this->validateFileHash($attachmentId, $fileHash);
|
||||||
|
if ($validatedHash === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$fileHash = $validatedHash;
|
||||||
|
}
|
||||||
|
|
||||||
$parsed = ProductVersion::parseVersion($version);
|
$parsed = ProductVersion::parseVersion($version);
|
||||||
|
|
||||||
$tableName = Installer::getVersionsTable();
|
$tableName = Installer::getVersionsTable();
|
||||||
@@ -114,10 +125,9 @@ class VersionManager
|
|||||||
'minor_version' => $parsed['minor'],
|
'minor_version' => $parsed['minor'],
|
||||||
'patch_version' => $parsed['patch'],
|
'patch_version' => $parsed['patch'],
|
||||||
'release_notes' => $releaseNotes,
|
'release_notes' => $releaseNotes,
|
||||||
'download_url' => $downloadUrl,
|
|
||||||
'is_active' => 1,
|
'is_active' => 1,
|
||||||
];
|
];
|
||||||
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d'];
|
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%d'];
|
||||||
|
|
||||||
// Only include attachment_id if it's set
|
// Only include attachment_id if it's set
|
||||||
if ($attachmentId !== null && $attachmentId > 0) {
|
if ($attachmentId !== null && $attachmentId > 0) {
|
||||||
@@ -125,6 +135,12 @@ class VersionManager
|
|||||||
$formats[] = '%d';
|
$formats[] = '%d';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only include file_hash if it's set
|
||||||
|
if ($fileHash !== null && $fileHash !== '') {
|
||||||
|
$data['file_hash'] = $fileHash;
|
||||||
|
$formats[] = '%s';
|
||||||
|
}
|
||||||
|
|
||||||
$result = $wpdb->insert($tableName, $data, $formats);
|
$result = $wpdb->insert($tableName, $data, $formats);
|
||||||
|
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
@@ -136,13 +152,44 @@ class VersionManager
|
|||||||
return $this->getVersionById((int) $wpdb->insert_id);
|
return $this->getVersionById((int) $wpdb->insert_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file hash against attachment
|
||||||
|
*
|
||||||
|
* @return string|false The validated hash (lowercase) or false on mismatch
|
||||||
|
* @throws \InvalidArgumentException If hash doesn't match
|
||||||
|
*/
|
||||||
|
private function validateFileHash(int $attachmentId, string $providedHash): string|false
|
||||||
|
{
|
||||||
|
$filePath = get_attached_file($attachmentId);
|
||||||
|
if (!$filePath || !file_exists($filePath)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
__('Attachment file not found.', 'wc-licensed-product')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$calculatedHash = hash_file('sha256', $filePath);
|
||||||
|
$providedHash = strtolower(trim($providedHash));
|
||||||
|
|
||||||
|
if (!hash_equals($calculatedHash, $providedHash)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: provided hash, 2: calculated hash */
|
||||||
|
__('File checksum does not match. Expected: %1$s, Got: %2$s', 'wc-licensed-product'),
|
||||||
|
$providedHash,
|
||||||
|
$calculatedHash
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $calculatedHash;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a version
|
* Update a version
|
||||||
*/
|
*/
|
||||||
public function updateVersion(
|
public function updateVersion(
|
||||||
int $versionId,
|
int $versionId,
|
||||||
?string $releaseNotes = null,
|
?string $releaseNotes = null,
|
||||||
?string $downloadUrl = null,
|
|
||||||
?bool $isActive = null,
|
?bool $isActive = null,
|
||||||
?int $attachmentId = null
|
?int $attachmentId = null
|
||||||
): bool {
|
): bool {
|
||||||
@@ -156,19 +203,26 @@ class VersionManager
|
|||||||
$formats[] = '%s';
|
$formats[] = '%s';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($downloadUrl !== null) {
|
|
||||||
$data['download_url'] = $downloadUrl;
|
|
||||||
$formats[] = '%s';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($isActive !== null) {
|
if ($isActive !== null) {
|
||||||
$data['is_active'] = $isActive ? 1 : 0;
|
$data['is_active'] = $isActive ? 1 : 0;
|
||||||
$formats[] = '%d';
|
$formats[] = '%d';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($attachmentId !== null) {
|
if ($attachmentId !== null) {
|
||||||
$data['attachment_id'] = $attachmentId > 0 ? $attachmentId : null;
|
if ($attachmentId > 0) {
|
||||||
$formats[] = $attachmentId > 0 ? '%d' : null;
|
$data['attachment_id'] = $attachmentId;
|
||||||
|
$formats[] = '%d';
|
||||||
|
} else {
|
||||||
|
// Set to NULL using raw SQL instead of adding to $data
|
||||||
|
global $wpdb;
|
||||||
|
$tableName = Installer::getVersionsTable();
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE {$tableName} SET attachment_id = NULL WHERE id = %d",
|
||||||
|
$versionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($data)) {
|
if (empty($data)) {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
<th>{{ __('Customer') }}</th>
|
<th>{{ __('Customer') }}</th>
|
||||||
<th>{{ __('Domain') }}</th>
|
<th>{{ __('Domain') }}</th>
|
||||||
<th>{{ __('Status') }}</th>
|
<th>{{ __('Status') }}</th>
|
||||||
|
<th>{{ __('Created') }}</th>
|
||||||
<th>{{ __('Expires') }}</th>
|
<th>{{ __('Expires') }}</th>
|
||||||
<th>{{ __('Actions') }}</th>
|
<th>{{ __('Actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% if licenses is empty %}
|
{% if licenses is empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8">{{ __('No licenses found.') }}</td>
|
<td colspan="9">{{ __('No licenses found.') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for item in licenses %}
|
{% for item in licenses %}
|
||||||
@@ -160,6 +161,9 @@
|
|||||||
<button type="button" class="wclp-cancel-btn button button-small">{{ __('Cancel') }}</button>
|
<button type="button" class="wclp-cancel-btn button button-small">{{ __('Cancel') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="wclp-created-cell">
|
||||||
|
{{ item.license.createdAt|date('Y-m-d') }}
|
||||||
|
</td>
|
||||||
<td class="wclp-editable-cell" data-field="expiry" data-license-id="{{ item.license.id }}">
|
<td class="wclp-editable-cell" data-field="expiry" data-license-id="{{ item.license.id }}">
|
||||||
<span class="wclp-display-value">
|
<span class="wclp-display-value">
|
||||||
{% if item.license.expiresAt %}
|
{% if item.license.expiresAt %}
|
||||||
@@ -219,6 +223,7 @@
|
|||||||
<th>{{ __('Customer') }}</th>
|
<th>{{ __('Customer') }}</th>
|
||||||
<th>{{ __('Domain') }}</th>
|
<th>{{ __('Domain') }}</th>
|
||||||
<th>{{ __('Status') }}</th>
|
<th>{{ __('Status') }}</th>
|
||||||
|
<th>{{ __('Created') }}</th>
|
||||||
<th>{{ __('Expires') }}</th>
|
<th>{{ __('Expires') }}</th>
|
||||||
<th>{{ __('Actions') }}</th>
|
<th>{{ __('Actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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.0.10
|
* Version: 0.2.0
|
||||||
* 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.0.10');
|
define('WC_LICENSED_PRODUCT_VERSION', '0.2.0');
|
||||||
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