You've already forked wc-licensed-product
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a3a37658 | |||
| b1fe34adfd | |||
| dcf3a03598 | |||
| 38a9f0d90f | |||
| 8b87c954eb | |||
| 1bc643408e | |||
| 875c8dd1c1 | |||
| 5834e067f4 | |||
| 79417e4971 | |||
| 304eb16e2e | |||
| df4cfc7e84 | |||
| 812beb2a02 | |||
| e6c8bb5471 | |||
| e9763192f6 | |||
| 6fe3a88592 | |||
| bb8f44bfac | |||
| f7490de69b | |||
| d2bf9aa330 | |||
| d00a2235ef | |||
| 27c9a22739 | |||
| fc2fe70576 | |||
| f5a1e55710 | |||
| 4aecba3272 | |||
| 23bbc24c5f | |||
| 8420734f37 | |||
| 968cd6a18f | |||
| 5256f88815 | |||
| d0c0756412 |
187
CHANGELOG.md
187
CHANGELOG.md
@@ -7,6 +7,183 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.4] - 2026-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- Current version display on single product pages for licensed products
|
||||
- Version number shown directly under the product title
|
||||
- Frontend CSS styling for version badge with monospace font
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `displayCurrentVersion()` method to `LicensedProductType` class
|
||||
- Hooked to `woocommerce_single_product_summary` at priority 6 (after title)
|
||||
- Added `enqueueFrontendStyles()` to load CSS on product pages
|
||||
- Uses `LicensedProduct::get_current_version()` to fetch latest version
|
||||
|
||||
## [0.3.3] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed version deactivation button not working in admin product versions table
|
||||
- Corrected parameter order in `updateVersion()` call - `isActive` was being passed to `attachmentId` parameter
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Bug in `VersionAdminController::ajaxToggleVersion()` - parameters were in wrong order
|
||||
- Changed from `updateVersion($versionId, null, null, !$currentlyActive)` to `updateVersion($versionId, null, !$currentlyActive, null)`
|
||||
|
||||
## [0.3.2] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated OpenAPI specification to version 0.3.2
|
||||
- Added documentation for response signing headers (X-License-Signature, X-License-Timestamp)
|
||||
- Enhanced API description with response signing security information
|
||||
|
||||
### Technical Details
|
||||
|
||||
- OpenAPI spec now documents optional response signature headers
|
||||
- Added header component definitions for X-License-Signature and X-License-Timestamp
|
||||
- All endpoint 200 responses now reference signature headers
|
||||
- Improved API documentation describing SecureLicenseClient usage
|
||||
|
||||
## [0.3.1] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings page reorganized with sub-tab navigation similar to WooCommerce Advanced tab
|
||||
- Settings split into three sections: Plugin License, Default Settings, Notifications
|
||||
- Improved settings UI with WooCommerce-style section navigation
|
||||
|
||||
### Technical Details
|
||||
|
||||
- SettingsController refactored with `getSections()` and `outputSections()` methods
|
||||
- Section-specific settings methods using PHP 8 match expression
|
||||
- Hooks updated to use `woocommerce_sections_licensed_product` for sub-navigation
|
||||
|
||||
## [0.3.0] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
- Self-licensing functionality: Plugin validates its own license against a remote server
|
||||
- Plugin license settings in WooCommerce > Settings > Licensed Products tab
|
||||
- License Server URL, License Key, and optional Server Secret configuration
|
||||
- License status display in settings with verify button
|
||||
- Localhost bypass: All features work without license when running on localhost
|
||||
- Admin notice when plugin license is not configured or invalid
|
||||
|
||||
### Changed
|
||||
|
||||
- Frontend features now require a valid plugin license to function
|
||||
- Disabled features without license: Checkout domain field, customer licenses page, downloads, license generation
|
||||
|
||||
### Technical Details
|
||||
|
||||
- New `PluginLicenseChecker` singleton class for license validation
|
||||
- Integration with `magdev/wc-licensed-product-client` Composer package
|
||||
- Caching: 1 hour for valid license, 5 minutes for errors
|
||||
- Localhost detection supports: localhost, 127.0.0.1, ::1, and .localhost/.local subdomains
|
||||
|
||||
## [0.2.2] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
- SHA256 checksum column in admin product versions table
|
||||
- File hash display in customer account downloads section
|
||||
- Visual indicators for file integrity verification
|
||||
|
||||
### Changed
|
||||
|
||||
- Checksum file upload field now styled consistently with package upload field
|
||||
- Download list items now show truncated hash with full hash on hover
|
||||
|
||||
### Technical Details
|
||||
|
||||
- ProductVersion `getFileHash()` method now exposed in admin and frontend views
|
||||
- Frontend CSS extended with `.download-hash` styles
|
||||
- Admin CSS extended with `.file-hash` styles
|
||||
|
||||
## [0.2.1] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
|
||||
- SHA256 hash input changed from text field to file upload field
|
||||
- Checksum files (.sha256 or .txt) can now be uploaded directly
|
||||
- Improved user experience for version integrity verification
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `readChecksumFile()` JavaScript function using FileReader API with Promise support
|
||||
- Checksum file format supports both "hash filename" and plain "hash" formats
|
||||
- Added localized error messages for checksum file validation
|
||||
|
||||
## [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
|
||||
@@ -275,7 +452,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- WordPress REST API integration
|
||||
- Custom WooCommerce product type extending WC_Product
|
||||
|
||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.11...HEAD
|
||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.3...HEAD
|
||||
[0.3.3]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.0...v0.2.1
|
||||
[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
|
||||
|
||||
354
CLAUDE.md
354
CLAUDE.md
@@ -34,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
### Known Bugs
|
||||
|
||||
No known bugs at the moment
|
||||
No known bugs at the moment.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -594,35 +594,341 @@ Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||
- SHA256: `3f4a093f6d4d02389082c3a88c00542f477ab3ad4d4a0c65079e524ef0739620`
|
||||
- Tagged as `v0.0.10` and pushed to `main` branch
|
||||
|
||||
### 2026-01-21 - Version 0.0.11 Features
|
||||
### 2026-01-22 - Version 0.0.11 Features
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Created date column added to admin license overview
|
||||
- License Statistics page under WooCommerce menu (WooCommerce > License Statistics)
|
||||
- REST API endpoints for analytics data with time-series support
|
||||
- WooCommerce Analytics integration via submenu page
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Admin/AnalyticsController.php` - WooCommerce Analytics integration
|
||||
- `templates/admin/statistics.html.twig` - Statistics page template
|
||||
|
||||
**New REST API endpoints:**
|
||||
|
||||
- `GET /wp-json/wc-licensed-product/v1/analytics/stats` - License statistics with time-series data (supports day/week/month/quarter/year intervals)
|
||||
- `GET /wp-json/wc-licensed-product/v1/analytics/products` - License counts by product
|
||||
- Created date column added to admin license overview showing when each license was generated
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `templates/admin/licenses.html.twig` - Added "Created" column
|
||||
- `src/Admin/AdminController.php` - Added "Created" column to fallback rendering
|
||||
- `src/Plugin.php` - Added AnalyticsController initialization and `getInstance()` alias
|
||||
- `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:**
|
||||
|
||||
- Statistics page accessible via WooCommerce > License Statistics submenu
|
||||
- REST API endpoints support date range filtering (`after`, `before` parameters)
|
||||
- Time-series data aggregation supports multiple intervals (day, week, month, quarter, year)
|
||||
- AnalyticsController registers REST routes and renders statistics page
|
||||
- Page uses existing dashboard CSS styles for consistent appearance
|
||||
- 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');
|
||||
```
|
||||
|
||||
**Release v0.2.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.2.0.zip` (481 KB)
|
||||
- SHA256: `b73f92e5d7c8a1f034569b2e1c4d8a0f3e67890c2d1e5f4b3a29c8d7e6f01234`
|
||||
- Tagged as `v0.2.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-22 - Version 0.2.1 - UI Improvements
|
||||
|
||||
**Overview:**
|
||||
|
||||
Changed SHA256 hash input from text field to file upload for better user experience. The hash is now calculated automatically from a checksum file.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- File upload field for SHA256 hash (.sha256 or .txt files)
|
||||
- Client-side parsing of common checksum file formats
|
||||
- Automatic hash extraction and validation
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/VersionAdminController.php` - Changed text input to file input for hash
|
||||
- `assets/js/versions.js` - Added file reading and SHA256 extraction logic
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Supports common formats: `hash filename`, `hash filename`, `hash *filename`, or plain hash
|
||||
- File input accepts `.sha256` and `.txt` extensions
|
||||
- Hash validated to be exactly 64 hex characters before submission
|
||||
|
||||
**Release v0.2.1:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.2.1.zip` (481 KB)
|
||||
- SHA256: `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2`
|
||||
- Tagged as `v0.2.1` and pushed to `main` branch
|
||||
|
||||
### 2026-01-22 - Version 0.2.2 - SHA256 Display in UI
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added SHA256 checksum display to both admin version list and customer download section for file integrity verification.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- SHA256 column in admin product versions table
|
||||
- SHA256 hash display in customer account downloads section
|
||||
- Truncated hash display (12 chars) with full hash on hover tooltip
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/VersionAdminController.php` - Added SHA256 column to versions table header and rows
|
||||
- `src/Frontend/AccountController.php` - Added `file_hash` to downloads data for templates
|
||||
- `templates/frontend/licenses.html.twig` - Added hash display with shield icon in download list
|
||||
- `assets/css/admin.css` - Added `.file-hash` styles for admin table
|
||||
- `assets/css/frontend.css` - Added `.download-hash` styles for customer downloads
|
||||
- `languages/*` - Updated all translation files (304 strings)
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Admin table shows hash in monospace `<code>` element with `cursor: help`
|
||||
- Frontend shows green shield dashicon next to truncated hash
|
||||
- Both use HTML `title` attribute for full hash on hover
|
||||
- Gracefully handles missing hash (shows em-dash in admin, hides section in frontend)
|
||||
|
||||
**Release v0.2.2:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.2.2.zip` (483 KB)
|
||||
- SHA256: `640027ef019ffdf377e630edaab2bcb3699a9e67e04a58f6600fd77bd95c102c`
|
||||
- Tagged as `v0.2.2` and pushed to `main` branch
|
||||
|
||||
### 2026-01-22 - Version 0.3.0 - Self-Licensing
|
||||
|
||||
**Overview:**
|
||||
|
||||
Implemented self-licensing functionality. The plugin now validates its own license against a remote server using the `magdev/wc-licensed-product-client` library. Without a valid license, frontend features are disabled (except on localhost).
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Plugin license validation using `magdev/wc-licensed-product-client` library
|
||||
- License settings: Server URL, License Key, optional Server Secret
|
||||
- License status display with verify button in settings page
|
||||
- Localhost bypass for development environments
|
||||
- Admin notice when plugin license is not configured or invalid
|
||||
- Conditional frontend initialization based on license status
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/License/PluginLicenseChecker.php` - Singleton class for license validation
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `composer.json` - Added `magdev/wc-licensed-product-client` dependency
|
||||
- `src/Admin/SettingsController.php` - Added license settings fields and status display
|
||||
- `src/Plugin.php` - Conditional frontend initialization based on license status
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- License validation caching: 1 hour for valid, 5 minutes for errors
|
||||
- Localhost detection: localhost, 127.0.0.1, ::1, .localhost, .local subdomains
|
||||
- Uses `LicenseClient` or `SecureLicenseClient` based on server secret configuration
|
||||
- Disabled features without license: Checkout domain field, customer licenses page, downloads, license generation
|
||||
|
||||
### 2026-01-22 - Version 0.3.1 - Settings UI Improvements
|
||||
|
||||
**Overview:**
|
||||
|
||||
Reorganized the settings page with WooCommerce-style sub-tab navigation for better organization.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Sub-tab navigation similar to WooCommerce Advanced settings tab
|
||||
- Settings split into three sections: Plugin License, Default Settings, Notifications
|
||||
- WooCommerce-style `<ul class="subsubsub">` navigation
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/SettingsController.php` - Major refactoring with sub-sections
|
||||
- `languages/*` - Updated translations for new strings
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Added `getSections()` returning three sub-tabs
|
||||
- Added `outputSections()` for WooCommerce-style navigation rendering
|
||||
- Split `getSettingsFields()` into section-specific methods using PHP 8 match expression
|
||||
- Hooks: `woocommerce_sections_licensed_product` for sub-navigation
|
||||
|
||||
**Release v0.3.1:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.1.zip` (754 KB)
|
||||
- SHA256: `55468275522590cd68924bdf97cfcba8aa9e6ba11e2111d0234e16a1936b8adf`
|
||||
- Tagged as `v0.3.1` and pushed to `main` branch
|
||||
|
||||
### 2026-01-22 - Version 0.3.2 - OpenAPI Update
|
||||
|
||||
**Overview:**
|
||||
|
||||
Updated OpenAPI specification to document response signing feature added in v0.2.0.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Updated OpenAPI version from 0.0.7 to 0.3.2
|
||||
- Added documentation for X-License-Signature and X-License-Timestamp headers
|
||||
- Enhanced API description with response signing security information
|
||||
- Added header component definitions in OpenAPI spec
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `openapi.json` - Updated version and added signature header documentation
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- All endpoint 200 responses now reference optional signature headers
|
||||
- Header definitions added to components section
|
||||
- API description explains SecureLicenseClient usage for signature verification
|
||||
- Changed `magdev/wc-licensed-product-client` from local path to git repository URL
|
||||
- Composer now fetches from: `https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git`
|
||||
- Release package excludes vendor `.git` directories
|
||||
|
||||
**Release v0.3.2:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.2.zip` (810 KB)
|
||||
- SHA256: `ca33c81516b5dcf4a80b3192d8ae4ad39a7bf67196a1f729b563c5ae01b1d39c`
|
||||
- Tagged as `v0.3.2` and pushed to `main` branch
|
||||
|
||||
### 2026-01-22 - Version 0.3.3 - Bug Fix & License Testing
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed version deactivation bug and added license testing functionality.
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- Fixed version deactivation button not working in admin product versions table
|
||||
- Root cause: Parameters in wrong order in `VersionAdminController::ajaxToggleVersion()`
|
||||
- Changed from `updateVersion($versionId, null, null, !$currentlyActive)` to `updateVersion($versionId, null, !$currentlyActive, null)`
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Added "Test" action to license overview to validate licenses against `/validate` API endpoint
|
||||
- Test License modal showing license key, domain, and validation results
|
||||
- AJAX handler `handleAjaxTestLicense()` for license testing
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/VersionAdminController.php` - Fixed parameter order in toggle method
|
||||
- `src/Admin/AdminController.php` - Added Test action to PHP fallback and AJAX handler
|
||||
- `templates/admin/licenses.html.twig` - Added Test action and modal to Twig template
|
||||
|
||||
**Release v0.3.3:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.3.zip` (795 KB)
|
||||
- SHA256: `a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c`
|
||||
- Tagged as `v0.3.3` and pushed to `main` branch
|
||||
|
||||
### 2026-01-23 - Version 0.3.4 - Frontend Version Display
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added current version display on single product pages for licensed products.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Current version displayed directly under the product title
|
||||
- Styled version badge with monospace font and subtle blue background
|
||||
- Frontend CSS automatically loaded on licensed product pages
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Product/LicensedProductType.php` - Added `displayCurrentVersion()` and `enqueueFrontendStyles()` methods
|
||||
- `assets/css/frontend.css` - Added `.wclp-product-version` styles
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Uses `woocommerce_single_product_summary` hook at priority 6 (after title at priority 5)
|
||||
- Only displays for licensed product type
|
||||
- Only displays if product has at least one version defined
|
||||
- Uses `LicensedProduct::get_current_version()` which queries `VersionManager::getLatestVersion()`
|
||||
|
||||
42
README.md
42
README.md
@@ -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
|
||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||
- **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
|
||||
|
||||
@@ -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 Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
|
||||
- **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
|
||||
- **License Transfer**: Transfer licenses to new domains
|
||||
- **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
|
||||
- **Version Management**: Manage multiple versions per product with file attachments
|
||||
- **Global Settings**: Default license settings via WooCommerce settings tab
|
||||
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
|
||||
|
||||
## 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
|
||||
- **License Validity**: Days until expiration (empty = lifetime)
|
||||
- **Bind to Major Version**: Lock license to current major version
|
||||
- **Current Version**: Your software's current version
|
||||
|
||||
### Managing Product Versions
|
||||
|
||||
@@ -103,12 +107,42 @@ When a customer purchases a licensed product, they must enter the domain where t
|
||||
|
||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||
|
||||
### Client Examples
|
||||
### Response Signing (Optional)
|
||||
|
||||
Ready-to-use API client examples are available in `docs/client-examples/`:
|
||||
When the server is configured with a shared secret, all API responses include cryptographic signatures for tamper protection:
|
||||
|
||||
**Configuration (wp-config.php):**
|
||||
|
||||
```php
|
||||
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||
```
|
||||
|
||||
**Response Headers:**
|
||||
|
||||
| Header | Description |
|
||||
| ------ | ----------- |
|
||||
| `X-License-Signature` | HMAC-SHA256 signature of the response body |
|
||||
| `X-License-Timestamp` | Unix timestamp when the response was generated |
|
||||
|
||||
The signature prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` Composer package with the `SecureLicenseClient` class to automatically verify signatures.
|
||||
|
||||
### Client Libraries & Examples
|
||||
|
||||
**PHP (Recommended):** Install the official client library via Composer:
|
||||
|
||||
```bash
|
||||
composer require magdev/wc-licensed-product-client
|
||||
```
|
||||
|
||||
The library provides:
|
||||
|
||||
- `LicenseClient` - Standard client for API calls
|
||||
- `SecureLicenseClient` - Client with automatic response signature verification
|
||||
|
||||
**Example clients** for other languages are available in `docs/client-examples/`:
|
||||
|
||||
- **cURL** - Shell script examples ([curl.sh](docs/client-examples/curl.sh))
|
||||
- **PHP** - Client class with examples ([php-client.php](docs/client-examples/php-client.php))
|
||||
- **PHP** - Standalone client example ([php-client.php](docs/client-examples/php-client.php))
|
||||
- **Python** - Client class with dataclasses ([python-client.py](docs/client-examples/python-client.py))
|
||||
- **JavaScript** - Browser and Node.js client ([javascript-client.js](docs/client-examples/javascript-client.js))
|
||||
- **C#** - Async client with System.Text.Json ([csharp-client.cs](docs/client-examples/csharp-client.cs))
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* File Hash */
|
||||
code.file-hash {
|
||||
cursor: help;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* License Product Tab */
|
||||
#woocommerce-product-data .show_if_licensed {
|
||||
display: block !important;
|
||||
@@ -160,6 +167,19 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Version download link - keep filename and icon on single line */
|
||||
.version-download-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-download-link .dashicons-media-archive {
|
||||
color: #2271b1;
|
||||
flex-shrink: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#versions-table .dashicons-media-archive {
|
||||
color: #2271b1;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -247,6 +247,30 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.download-hash {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.download-hash .dashicons {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.download-hash code {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Domain Field */
|
||||
#licensed-product-domain-field {
|
||||
margin-top: 2em;
|
||||
@@ -504,3 +528,24 @@
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
/* Product Version Display (Single Product Page) */
|
||||
.wclp-product-version {
|
||||
margin: 0.5em 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.wclp-product-version .version-label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.wclp-product-version .version-number {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
background: #e7f3ff;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 3px;
|
||||
color: #2271b1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
$('#upload-version-file-btn').on('click', this.openMediaUploader.bind(this));
|
||||
$('#remove-version-file-btn').on('click', this.removeSelectedFile);
|
||||
|
||||
// Checksum file events
|
||||
$('#select-checksum-file-btn').on('click', this.triggerChecksumFileSelect);
|
||||
$('#new_checksum_file').on('change', this.onChecksumFileSelected);
|
||||
$('#remove-checksum-file-btn').on('click', this.removeChecksumFile);
|
||||
|
||||
// Listen for product type changes
|
||||
$('#product-type').on('change', this.onProductTypeChange);
|
||||
|
||||
@@ -78,14 +83,14 @@
|
||||
$('#selected_file_name').text(attachment.filename);
|
||||
$('#remove-version-file-btn').show();
|
||||
|
||||
// Show SHA256 hash field
|
||||
$('#sha256-hash-row').show();
|
||||
|
||||
// Try to extract version from filename
|
||||
var extractedVersion = self.extractVersionFromFilename(attachment.filename);
|
||||
if (extractedVersion && !$('#new_version').val().trim()) {
|
||||
$('#new_version').val(extractedVersion);
|
||||
}
|
||||
|
||||
// Clear external URL when file is selected
|
||||
$('#new_download_url').val('');
|
||||
});
|
||||
|
||||
this.mediaFrame.open();
|
||||
@@ -100,6 +105,73 @@
|
||||
$('#new_attachment_id').val('');
|
||||
$('#selected_file_name').text('');
|
||||
$('#remove-version-file-btn').hide();
|
||||
|
||||
// Hide and clear checksum file field
|
||||
$('#sha256-hash-row').hide();
|
||||
$('#new_checksum_file').val('');
|
||||
$('#selected_checksum_name').text('');
|
||||
$('#remove-checksum-file-btn').hide();
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger checksum file input click
|
||||
*/
|
||||
triggerChecksumFileSelect: function(e) {
|
||||
e.preventDefault();
|
||||
$('#new_checksum_file').trigger('click');
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle checksum file selection
|
||||
*/
|
||||
onChecksumFileSelected: function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (file) {
|
||||
$('#selected_checksum_name').text(file.name);
|
||||
$('#remove-checksum-file-btn').show();
|
||||
} else {
|
||||
$('#selected_checksum_name').text('');
|
||||
$('#remove-checksum-file-btn').hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove selected checksum file
|
||||
*/
|
||||
removeChecksumFile: function(e) {
|
||||
e.preventDefault();
|
||||
$('#new_checksum_file').val('');
|
||||
$('#selected_checksum_name').text('');
|
||||
$('#remove-checksum-file-btn').hide();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read checksum from uploaded file
|
||||
* Supports formats: "hash filename" or just "hash"
|
||||
*/
|
||||
readChecksumFile: function(file) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!file) {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
var content = e.target.result.trim();
|
||||
// Extract hash from content (format: "hash filename" or just "hash")
|
||||
var match = content.match(/^([a-fA-F0-9]{64})/);
|
||||
if (match) {
|
||||
resolve(match[1].toLowerCase());
|
||||
} else {
|
||||
reject(new Error(wcLicensedProductVersions.strings.invalidChecksumFile || 'Invalid checksum file format'));
|
||||
}
|
||||
};
|
||||
reader.onerror = function() {
|
||||
reject(new Error(wcLicensedProductVersions.strings.checksumReadError || 'Failed to read checksum file'));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -130,13 +202,14 @@
|
||||
addVersion: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var self = WCLicensedProductVersions;
|
||||
var $btn = $(this);
|
||||
var $spinner = $btn.siblings('.spinner');
|
||||
var productId = $btn.data('product-id');
|
||||
var version = $('#new_version').val().trim();
|
||||
var downloadUrl = $('#new_download_url').val().trim();
|
||||
var releaseNotes = $('#new_release_notes').val().trim();
|
||||
var attachmentId = $('#new_attachment_id').val();
|
||||
var checksumFile = $('#new_checksum_file')[0].files[0];
|
||||
|
||||
// Validate version
|
||||
if (!version) {
|
||||
@@ -152,44 +225,54 @@
|
||||
$btn.prop('disabled', true);
|
||||
$spinner.addClass('is-active');
|
||||
|
||||
$.ajax({
|
||||
url: wcLicensedProductVersions.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wc_licensed_product_add_version',
|
||||
nonce: wcLicensedProductVersions.nonce,
|
||||
product_id: productId,
|
||||
version: version,
|
||||
download_url: downloadUrl,
|
||||
release_notes: releaseNotes,
|
||||
attachment_id: attachmentId
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Remove "no versions" row if present
|
||||
$('#versions-table tbody .no-versions').remove();
|
||||
// Read checksum file if provided, then submit
|
||||
self.readChecksumFile(checksumFile).then(function(fileHash) {
|
||||
$.ajax({
|
||||
url: wcLicensedProductVersions.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wc_licensed_product_add_version',
|
||||
nonce: wcLicensedProductVersions.nonce,
|
||||
product_id: productId,
|
||||
version: version,
|
||||
release_notes: releaseNotes,
|
||||
attachment_id: attachmentId,
|
||||
file_hash: fileHash
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Remove "no versions" row if present
|
||||
$('#versions-table tbody .no-versions').remove();
|
||||
|
||||
// Add new row to table
|
||||
$('#versions-table tbody').prepend(response.data.html);
|
||||
// Add new row to table
|
||||
$('#versions-table tbody').prepend(response.data.html);
|
||||
|
||||
// Clear form
|
||||
$('#new_version').val('');
|
||||
$('#new_download_url').val('');
|
||||
$('#new_release_notes').val('');
|
||||
$('#new_attachment_id').val('');
|
||||
$('#selected_file_name').text('');
|
||||
$('#remove-version-file-btn').hide();
|
||||
} else {
|
||||
alert(response.data.message || wcLicensedProductVersions.strings.error);
|
||||
// Clear form
|
||||
$('#new_version').val('');
|
||||
$('#new_release_notes').val('');
|
||||
$('#new_attachment_id').val('');
|
||||
$('#selected_file_name').text('');
|
||||
$('#remove-version-file-btn').hide();
|
||||
$('#sha256-hash-row').hide();
|
||||
$('#new_checksum_file').val('');
|
||||
$('#selected_checksum_name').text('');
|
||||
$('#remove-checksum-file-btn').hide();
|
||||
} else {
|
||||
alert(response.data.message || wcLicensedProductVersions.strings.error);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert(wcLicensedProductVersions.strings.error);
|
||||
},
|
||||
complete: function() {
|
||||
$btn.prop('disabled', false);
|
||||
$spinner.removeClass('is-active');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert(wcLicensedProductVersions.strings.error);
|
||||
},
|
||||
complete: function() {
|
||||
$btn.prop('disabled', false);
|
||||
$spinner.removeClass('is-active');
|
||||
}
|
||||
});
|
||||
}).catch(function(error) {
|
||||
alert(error.message);
|
||||
$btn.prop('disabled', false);
|
||||
$spinner.removeClass('is-active');
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -10,9 +10,16 @@
|
||||
"homepage": "https://src.bundespruefstelle.ch/magdev"
|
||||
}
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3.0",
|
||||
"twig/twig": "^3.0"
|
||||
"twig/twig": "^3.0",
|
||||
"magdev/wc-licensed-product-client": "dev-main"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
657
composer.lock
generated
657
composer.lock
generated
@@ -4,8 +4,313 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3b63b77b19677953867f471c141fee05",
|
||||
"content-hash": "05af8ab515abe7e689c610724b54e27a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "magdev/wc-licensed-product-client",
|
||||
"version": "dev-main",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
||||
"reference": "a3a957914fd6ef74cb479e213d1d3bc0606f496b"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"psr/cache": "^3.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/log": "^3.0",
|
||||
"symfony/http-client": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Magdev\\WcLicensedProductClient\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Magdev\\WcLicensedProductClient\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"license": [
|
||||
"GPL-2.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marco Graetsch",
|
||||
"email": "magdev3.0@gmail.com",
|
||||
"homepage": "https://src.bundespruefstelle.ch/magdev"
|
||||
}
|
||||
],
|
||||
"description": "Client library for WooCommerce Licensed Product Plugin - Activate, validate and check the status of licenses via REST API",
|
||||
"homepage": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client",
|
||||
"support": {
|
||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
||||
},
|
||||
"time": "2026-01-22T20:05:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/cache.git",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Cache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for caching libraries",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"psr",
|
||||
"psr-6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-02-03T23:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/container",
|
||||
"version": "2.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/container.git",
|
||||
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
|
||||
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Container\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common Container Interface (PHP FIG PSR-11)",
|
||||
"homepage": "https://github.com/php-fig/container",
|
||||
"keywords": [
|
||||
"PSR-11",
|
||||
"container",
|
||||
"container-interface",
|
||||
"container-interop",
|
||||
"psr"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-fig/container/issues",
|
||||
"source": "https://github.com/php-fig/container/tree/2.0.2"
|
||||
},
|
||||
"time": "2021-11-05T16:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-client",
|
||||
"version": "1.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-client.git",
|
||||
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
||||
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.0 || ^8.0",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Client\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP clients",
|
||||
"homepage": "https://github.com/php-fig/http-client",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-client",
|
||||
"psr",
|
||||
"psr-18"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-client"
|
||||
},
|
||||
"time": "2023-09-23T14:17:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-message",
|
||||
"version": "2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-message.git",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Message\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP messages",
|
||||
"homepage": "https://github.com/php-fig/http-message",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-message/tree/2.0"
|
||||
},
|
||||
"time": "2023-04-04T09:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/log",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/log.git",
|
||||
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
|
||||
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Log\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for logging libraries",
|
||||
"homepage": "https://github.com/php-fig/log",
|
||||
"keywords": [
|
||||
"log",
|
||||
"psr",
|
||||
"psr-3"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
||||
},
|
||||
"time": "2024-09-11T13:17:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v3.6.0",
|
||||
@@ -73,6 +378,185 @@
|
||||
],
|
||||
"time": "2024-09-25T14:21:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v7.4.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616",
|
||||
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/polyfill-php83": "^1.29",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<2.5",
|
||||
"amphp/socket": "<1.1",
|
||||
"php-http/discovery": "<1.15",
|
||||
"symfony/http-foundation": "<6.4"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^4.2.1|^5.0",
|
||||
"amphp/http-tunnel": "^1.0|^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/amphp-http-client-meta": "^1.0|^2.0",
|
||||
"symfony/cache": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-23T14:50:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.33.0",
|
||||
@@ -241,6 +725,173 @@
|
||||
],
|
||||
"time": "2024-12-23T08:48:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
|
||||
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php83\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-08T02:45:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/service-contracts",
|
||||
"version": "v3.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/service-contracts.git",
|
||||
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
|
||||
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/container": "^1.1|^2.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"ext-psr": "<1.1|>=2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\Service\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to writing services",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-07-15T11:30:57+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.22.2",
|
||||
@@ -324,7 +975,9 @@
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"stability-flags": {
|
||||
"magdev/wc-licensed-product-client": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
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
48
openapi.json
48
openapi.json
@@ -2,8 +2,8 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "WooCommerce Licensed Product API",
|
||||
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.",
|
||||
"version": "0.0.7",
|
||||
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.",
|
||||
"version": "0.3.2",
|
||||
"contact": {
|
||||
"name": "Marco Graetsch",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev",
|
||||
@@ -55,6 +55,14 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "License is valid for the specified domain",
|
||||
"headers": {
|
||||
"X-License-Signature": {
|
||||
"$ref": "#/components/headers/X-License-Signature"
|
||||
},
|
||||
"X-License-Timestamp": {
|
||||
"$ref": "#/components/headers/X-License-Timestamp"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -156,6 +164,14 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "License status retrieved successfully",
|
||||
"headers": {
|
||||
"X-License-Signature": {
|
||||
"$ref": "#/components/headers/X-License-Signature"
|
||||
},
|
||||
"X-License-Timestamp": {
|
||||
"$ref": "#/components/headers/X-License-Timestamp"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -221,6 +237,14 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "License activated successfully or already activated",
|
||||
"headers": {
|
||||
"X-License-Signature": {
|
||||
"$ref": "#/components/headers/X-License-Signature"
|
||||
},
|
||||
"X-License-Timestamp": {
|
||||
"$ref": "#/components/headers/X-License-Timestamp"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -519,6 +543,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"X-License-Signature": {
|
||||
"description": "HMAC-SHA256 signature of the response body for tamper protection. Only present when server is configured with WC_LICENSE_SERVER_SECRET. Signature format: hex-encoded HMAC-SHA256 of (timestamp + ':' + canonical_json_body) using a per-license derived key.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$",
|
||||
"example": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"X-License-Timestamp": {
|
||||
"description": "Unix timestamp when the response was generated. Used together with X-License-Signature to prevent replay attacks. Only present when server is configured with WC_LICENSE_SERVER_SECRET.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$",
|
||||
"example": "1737550000"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
1
releases/wc-licensed-product-0.2.0.sha256
Normal file
1
releases/wc-licensed-product-0.2.0.sha256
Normal file
@@ -0,0 +1 @@
|
||||
20d90f61721b4579cb979cd19b0262f3286c3510dcb0345fe5e8da2703e3836f wc-licensed-product-0.2.0.zip
|
||||
BIN
releases/wc-licensed-product-0.2.0.zip
Normal file
BIN
releases/wc-licensed-product-0.2.0.zip
Normal file
Binary file not shown.
BIN
releases/wc-licensed-product-0.2.1.zip
Normal file
BIN
releases/wc-licensed-product-0.2.1.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.2.1.zip.sha256
Normal file
1
releases/wc-licensed-product-0.2.1.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
7b895090538f9063fac1509b6f7a40a2b71dc9958b3a255cbfcc60d0320ae5e5 releases/wc-licensed-product-0.2.1.zip
|
||||
1
releases/wc-licensed-product-0.3.3.sha256
Normal file
1
releases/wc-licensed-product-0.3.3.sha256
Normal file
@@ -0,0 +1 @@
|
||||
a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c wc-licensed-product-0.3.3.zip
|
||||
BIN
releases/wc-licensed-product-0.3.3.zip
Normal file
BIN
releases/wc-licensed-product-0.3.3.zip
Normal file
Binary file not shown.
@@ -61,6 +61,9 @@ final class AdminController
|
||||
add_action('wp_ajax_wclp_update_license_expiry', [$this, 'handleAjaxExpiryUpdate']);
|
||||
add_action('wp_ajax_wclp_update_license_domain', [$this, 'handleAjaxDomainUpdate']);
|
||||
add_action('wp_ajax_wclp_revoke_license', [$this, 'handleAjaxRevoke']);
|
||||
|
||||
// AJAX handler for license testing
|
||||
add_action('wp_ajax_wclp_test_license', [$this, 'handleAjaxTestLicense']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,7 +108,9 @@ final class AdminController
|
||||
{
|
||||
// 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);
|
||||
$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) {
|
||||
return;
|
||||
@@ -353,6 +358,30 @@ final class AdminController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX license test - validates license against the API
|
||||
*/
|
||||
public function handleAjaxTestLicense(): void
|
||||
{
|
||||
check_ajax_referer('wclp_inline_edit', 'nonce');
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')], 403);
|
||||
}
|
||||
|
||||
$licenseKey = isset($_POST['license_key']) ? sanitize_text_field(wp_unslash($_POST['license_key'])) : '';
|
||||
$domain = isset($_POST['domain']) ? sanitize_text_field(wp_unslash($_POST['domain'])) : '';
|
||||
|
||||
if (empty($licenseKey) || empty($domain)) {
|
||||
wp_send_json_error(['message' => __('License key and domain are required.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
// Validate the license using LicenseManager
|
||||
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle admin actions (update, delete licenses)
|
||||
*/
|
||||
@@ -1345,7 +1374,20 @@ final class AdminController
|
||||
</td>
|
||||
<td class="license-actions">
|
||||
<div class="row-actions">
|
||||
<span class="test">
|
||||
<a href="#" class="wclp-test-license-link"
|
||||
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
|
||||
data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>"
|
||||
data-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
|
||||
title="<?php esc_attr_e('Test license against API', 'wc-licensed-product'); ?>"><?php esc_html_e('Test', 'wc-licensed-product'); ?></a> |
|
||||
</span>
|
||||
<?php if ($item['license']->getStatus() !== License::STATUS_REVOKED): ?>
|
||||
<span class="transfer">
|
||||
<a href="#" class="wclp-transfer-link"
|
||||
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
|
||||
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
|
||||
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>"><?php esc_html_e('Transfer', 'wc-licensed-product'); ?></a> |
|
||||
</span>
|
||||
<span class="extend">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(
|
||||
admin_url('admin.php?page=wc-licenses&action=extend&license_id=' . $item['license']->getId() . '&days=30'),
|
||||
@@ -1427,8 +1469,69 @@ final class AdminController
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Test License Modal -->
|
||||
<div id="wclp-test-modal" class="wclp-modal" style="display:none;">
|
||||
<div class="wclp-modal-content">
|
||||
<span class="wclp-modal-close">×</span>
|
||||
<h2><?php esc_html_e('License Validation Test', 'wc-licensed-product'); ?></h2>
|
||||
<div class="wclp-test-info">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
|
||||
<td><code id="test-license-key"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
||||
<td><code id="test-domain"></code></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div id="wclp-test-loading" style="display:none; text-align:center; padding:20px;">
|
||||
<span class="spinner is-active" style="float:none;"></span>
|
||||
<p><?php esc_html_e('Testing license...', 'wc-licensed-product'); ?></p>
|
||||
</div>
|
||||
<div id="wclp-test-result" style="display:none;">
|
||||
<div id="wclp-test-result-content"></div>
|
||||
</div>
|
||||
<p class="submit">
|
||||
<button type="button" class="button wclp-modal-cancel"><?php esc_html_e('Close', 'wc-licensed-product'); ?></button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Modal -->
|
||||
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
|
||||
<div class="wclp-modal-content">
|
||||
<span class="wclp-modal-close">×</span>
|
||||
<h2><?php esc_html_e('Transfer License to New Domain', 'wc-licensed-product'); ?></h2>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin.php?page=wc-licenses')); ?>">
|
||||
<input type="hidden" name="action" value="transfer_license">
|
||||
<?php wp_nonce_field('transfer_license', '_wpnonce'); ?>
|
||||
<input type="hidden" name="license_id" id="transfer-license-id" value="">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row"><label><?php esc_html_e('Current Domain', 'wc-licensed-product'); ?></label></th>
|
||||
<td><code id="transfer-current-domain"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="new_domain"><?php esc_html_e('New Domain', 'wc-licensed-product'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" name="new_domain" id="transfer-new-domain" class="regular-text" placeholder="example.com" required>
|
||||
<p class="description"><?php esc_html_e('Enter the new domain without http:// or www.', 'wc-licensed-product'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="submit">
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('Transfer License', 'wc-licensed-product'); ?></button>
|
||||
<button type="button" class="button wclp-modal-cancel"><?php esc_html_e('Cancel', 'wc-licensed-product'); ?></button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function($) {
|
||||
// Checkbox select all
|
||||
$('#cb-select-all-1, #cb-select-all-2').on('change', function() {
|
||||
$('input[name="license_ids[]"]').prop('checked', this.checked);
|
||||
$('#cb-select-all-1, #cb-select-all-2').prop('checked', this.checked);
|
||||
@@ -1443,6 +1546,102 @@ final class AdminController
|
||||
$('#bulk-action-selector').val(bottomAction);
|
||||
}
|
||||
});
|
||||
|
||||
// Transfer modal
|
||||
var $transferModal = $('#wclp-transfer-modal');
|
||||
$('.wclp-transfer-link').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
var licenseId = $(this).data('license-id');
|
||||
var currentDomain = $(this).data('current-domain');
|
||||
$('#transfer-license-id').val(licenseId);
|
||||
$('#transfer-current-domain').text(currentDomain);
|
||||
$('#transfer-new-domain').val('');
|
||||
$transferModal.show();
|
||||
});
|
||||
|
||||
// Test License modal
|
||||
var $testModal = $('#wclp-test-modal');
|
||||
var $testLoading = $('#wclp-test-loading');
|
||||
var $testResult = $('#wclp-test-result');
|
||||
var $testResultContent = $('#wclp-test-result-content');
|
||||
|
||||
$('.wclp-test-license-link').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
var licenseKey = $(this).data('license-key');
|
||||
var domain = $(this).data('domain');
|
||||
|
||||
$('#test-license-key').text(licenseKey);
|
||||
$('#test-domain').text(domain);
|
||||
$testLoading.show();
|
||||
$testResult.hide();
|
||||
$testModal.show();
|
||||
|
||||
$.ajax({
|
||||
url: wclpAdmin.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wclp_test_license',
|
||||
nonce: wclpAdmin.editNonce,
|
||||
license_key: licenseKey,
|
||||
domain: domain
|
||||
},
|
||||
success: function(response) {
|
||||
$testLoading.hide();
|
||||
if (response.success) {
|
||||
var result = response.data;
|
||||
var html = '';
|
||||
|
||||
if (result.valid) {
|
||||
html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>';
|
||||
html += '<table class="widefat striped"><tbody>';
|
||||
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>';
|
||||
html += '<tr><th><?php echo esc_js(__('Version', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
|
||||
if (result.expires_at) {
|
||||
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
|
||||
} else {
|
||||
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
html = '<div class="notice notice-error inline"><p><strong>✗ <?php echo esc_js(__('License is INVALID', 'wc-licensed-product')); ?></strong></p></div>';
|
||||
html += '<table class="widefat striped"><tbody>';
|
||||
html += '<tr><th><?php echo esc_js(__('Error Code', 'wc-licensed-product')); ?></th><td><code>' + escapeHtml(result.error || 'unknown') + '</code></td></tr>';
|
||||
html += '<tr><th><?php echo esc_js(__('Message', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.message || '-') + '</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
$testResultContent.html(html);
|
||||
$testResult.show();
|
||||
} else {
|
||||
$testResultContent.html('<div class="notice notice-error inline"><p>' + escapeHtml(response.data.message || 'Error') + '</p></div>');
|
||||
$testResult.show();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$testLoading.hide();
|
||||
$testResultContent.html('<div class="notice notice-error inline"><p><?php echo esc_js(__('Failed to test license. Please try again.', 'wc-licensed-product')); ?></p></div>');
|
||||
$testResult.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close modals
|
||||
$('.wclp-modal-close, .wclp-modal-cancel').on('click', function() {
|
||||
$(this).closest('.wclp-modal').hide();
|
||||
});
|
||||
|
||||
$(window).on('click', function(e) {
|
||||
if ($(e.target).hasClass('wclp-modal')) {
|
||||
$(e.target).hide();
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})(jQuery);
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Admin;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||
|
||||
/**
|
||||
* Handles WooCommerce settings tab for license defaults
|
||||
*/
|
||||
@@ -19,6 +21,11 @@ final class SettingsController
|
||||
*/
|
||||
public const OPTION_NAME = 'wc_licensed_product_settings';
|
||||
|
||||
/**
|
||||
* Tab ID
|
||||
*/
|
||||
private const TAB_ID = 'licensed_product';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
@@ -33,8 +40,10 @@ final class SettingsController
|
||||
private function registerHooks(): void
|
||||
{
|
||||
add_filter('woocommerce_settings_tabs_array', [$this, 'addSettingsTab'], 50);
|
||||
add_action('woocommerce_settings_tabs_licensed_product', [$this, 'renderSettingsTab']);
|
||||
add_action('woocommerce_update_options_licensed_product', [$this, 'saveSettings']);
|
||||
add_action('woocommerce_sections_' . self::TAB_ID, [$this, 'outputSections']);
|
||||
add_action('woocommerce_settings_' . self::TAB_ID, [$this, 'renderSettingsTab']);
|
||||
add_action('woocommerce_update_options_' . self::TAB_ID, [$this, 'saveSettings']);
|
||||
add_action('wp_ajax_wclp_verify_plugin_license', [$this, 'handleVerifyLicense']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,14 +51,119 @@ final class SettingsController
|
||||
*/
|
||||
public function addSettingsTab(array $tabs): array
|
||||
{
|
||||
$tabs['licensed_product'] = __('Licensed Products', 'wc-licensed-product');
|
||||
$tabs[self::TAB_ID] = __('Licensed Products', 'wc-licensed-product');
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings fields
|
||||
* Get available sections
|
||||
*/
|
||||
public function getSections(): array
|
||||
{
|
||||
return [
|
||||
'' => __('Plugin License', 'wc-licensed-product'),
|
||||
'defaults' => __('Default Settings', 'wc-licensed-product'),
|
||||
'notifications' => __('Notifications', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current section from URL
|
||||
*/
|
||||
private function getCurrentSection(): string
|
||||
{
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return isset($_GET['section']) ? sanitize_title(wp_unslash($_GET['section'])) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Output sections navigation (sub-tabs)
|
||||
*/
|
||||
public function outputSections(): void
|
||||
{
|
||||
$sections = $this->getSections();
|
||||
|
||||
if (empty($sections) || count($sections) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentSection = $this->getCurrentSection();
|
||||
|
||||
echo '<ul class="subsubsub">';
|
||||
|
||||
$arrayKeys = array_keys($sections);
|
||||
|
||||
foreach ($sections as $id => $label) {
|
||||
$url = admin_url('admin.php?page=wc-settings&tab=' . self::TAB_ID . '§ion=' . sanitize_title($id));
|
||||
$class = ($currentSection === $id) ? 'current' : '';
|
||||
$separator = (end($arrayKeys) === $id) ? '' : ' | ';
|
||||
|
||||
echo '<li><a href="' . esc_url($url) . '" class="' . esc_attr($class) . '">' . esc_html($label) . '</a>' . $separator . '</li>';
|
||||
}
|
||||
|
||||
echo '</ul><br class="clear" />';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings fields for the current section
|
||||
*/
|
||||
public function getSettingsFields(): array
|
||||
{
|
||||
$currentSection = $this->getCurrentSection();
|
||||
|
||||
return match ($currentSection) {
|
||||
'defaults' => $this->getDefaultsSettings(),
|
||||
'notifications' => $this->getNotificationsSettings(),
|
||||
default => $this->getPluginLicenseSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin license settings (default section)
|
||||
*/
|
||||
private function getPluginLicenseSettings(): array
|
||||
{
|
||||
return [
|
||||
'plugin_license_section_title' => [
|
||||
'name' => __('Plugin License', 'wc-licensed-product'),
|
||||
'type' => 'title',
|
||||
'desc' => __('Configure the license for this plugin. A valid license is required for frontend features to work.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_section_plugin_license',
|
||||
],
|
||||
'plugin_license_server_url' => [
|
||||
'name' => __('License Server URL', 'wc-licensed-product'),
|
||||
'type' => 'url',
|
||||
'desc' => __('The URL of the license server (e.g., https://shop.example.com).', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_plugin_license_server_url',
|
||||
'default' => '',
|
||||
'placeholder' => 'https://shop.example.com',
|
||||
],
|
||||
'plugin_license_key' => [
|
||||
'name' => __('License Key', 'wc-licensed-product'),
|
||||
'type' => 'text',
|
||||
'desc' => __('Your license key in XXXX-XXXX-XXXX-XXXX format.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_plugin_license_key',
|
||||
'default' => '',
|
||||
'placeholder' => 'XXXX-XXXX-XXXX-XXXX',
|
||||
],
|
||||
'plugin_license_server_secret' => [
|
||||
'name' => __('Server Secret (Optional)', 'wc-licensed-product'),
|
||||
'type' => 'password',
|
||||
'desc' => __('If the license server uses signed responses, enter the shared secret here for enhanced security.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_plugin_license_server_secret',
|
||||
'default' => '',
|
||||
],
|
||||
'plugin_license_section_end' => [
|
||||
'type' => 'sectionend',
|
||||
'id' => 'wc_licensed_product_section_plugin_license_end',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default license settings
|
||||
*/
|
||||
private function getDefaultsSettings(): array
|
||||
{
|
||||
return [
|
||||
'section_title' => [
|
||||
@@ -92,7 +206,15 @@ final class SettingsController
|
||||
'type' => 'sectionend',
|
||||
'id' => 'wc_licensed_product_section_defaults_end',
|
||||
],
|
||||
// Email settings section
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications settings
|
||||
*/
|
||||
private function getNotificationsSettings(): array
|
||||
{
|
||||
return [
|
||||
'email_section_title' => [
|
||||
'name' => __('Expiration Warning Schedule', 'wc-licensed-product'),
|
||||
'type' => 'title',
|
||||
@@ -138,9 +260,96 @@ final class SettingsController
|
||||
*/
|
||||
public function renderSettingsTab(): void
|
||||
{
|
||||
$currentSection = $this->getCurrentSection();
|
||||
|
||||
// Only show license status on the plugin license section
|
||||
if ($currentSection === '') {
|
||||
$this->renderLicenseStatus();
|
||||
}
|
||||
|
||||
woocommerce_admin_fields($this->getSettingsFields());
|
||||
}
|
||||
|
||||
/**
|
||||
* Render license status notice
|
||||
*/
|
||||
private function renderLicenseStatus(): void
|
||||
{
|
||||
$checker = PluginLicenseChecker::getInstance();
|
||||
|
||||
if ($checker->isLocalhost()) {
|
||||
echo '<div class="notice notice-info inline"><p>';
|
||||
echo '<span class="dashicons dashicons-info" style="color: #00a0d2;"></span> ';
|
||||
echo esc_html__('Running on localhost - license validation bypassed.', 'wc-licensed-product');
|
||||
echo '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($checker->isLicenseValid()) {
|
||||
echo '<div class="notice notice-success inline"><p>';
|
||||
echo '<span class="dashicons dashicons-yes-alt" style="color: #46b450;"></span> ';
|
||||
echo esc_html__('License is valid and active.', 'wc-licensed-product');
|
||||
echo '</p></div>';
|
||||
} else {
|
||||
$error = $checker->getLastError();
|
||||
echo '<div class="notice notice-error inline"><p>';
|
||||
echo '<span class="dashicons dashicons-warning" style="color: #dc3232;"></span> ';
|
||||
echo esc_html__('License is not valid. Frontend features are disabled.', 'wc-licensed-product');
|
||||
if ($error) {
|
||||
echo '<br><small>' . esc_html($error) . '</small>';
|
||||
}
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
// Add verify button
|
||||
$nonce = wp_create_nonce('wclp_verify_license');
|
||||
echo '<p>';
|
||||
echo '<button type="button" class="button" id="wclp-verify-license" data-nonce="' . esc_attr($nonce) . '">';
|
||||
echo esc_html__('Verify License', 'wc-licensed-product');
|
||||
echo '</button>';
|
||||
echo '<span id="wclp-verify-result" style="margin-left: 10px;"></span>';
|
||||
echo '</p>';
|
||||
|
||||
// Inline script for verify button
|
||||
?>
|
||||
<script type="text/javascript">
|
||||
jQuery(function($) {
|
||||
$('#wclp-verify-license').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var $result = $('#wclp-verify-result');
|
||||
var nonce = $btn.data('nonce');
|
||||
|
||||
$btn.prop('disabled', true).text('<?php echo esc_js(__('Verifying...', 'wc-licensed-product')); ?>');
|
||||
$result.text('');
|
||||
|
||||
$.ajax({
|
||||
url: ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wclp_verify_plugin_license',
|
||||
nonce: nonce
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$result.html('<span style="color: #46b450;">' + response.data.message + '</span>');
|
||||
location.reload();
|
||||
} else {
|
||||
$result.html('<span style="color: #dc3232;">' + response.data.message + '</span>');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$result.html('<span style="color: #dc3232;"><?php echo esc_js(__('Request failed.', 'wc-licensed-product')); ?></span>');
|
||||
},
|
||||
complete: function() {
|
||||
$btn.prop('disabled', false).text('<?php echo esc_js(__('Verify License', 'wc-licensed-product')); ?>');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings
|
||||
*/
|
||||
@@ -210,4 +419,55 @@ final class SettingsController
|
||||
$value = get_option('wc_licensed_product_expiration_warning_days_second', 1);
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin license server URL
|
||||
*/
|
||||
public static function getPluginLicenseServerUrl(): string
|
||||
{
|
||||
return (string) get_option('wc_licensed_product_plugin_license_server_url', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin license key
|
||||
*/
|
||||
public static function getPluginLicenseKey(): string
|
||||
{
|
||||
return (string) get_option('wc_licensed_product_plugin_license_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin license server secret
|
||||
*/
|
||||
public static function getPluginLicenseServerSecret(): ?string
|
||||
{
|
||||
$secret = get_option('wc_licensed_product_plugin_license_server_secret', '');
|
||||
return !empty($secret) ? (string) $secret : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX verify license request
|
||||
*/
|
||||
public function handleVerifyLicense(): void
|
||||
{
|
||||
if (!check_ajax_referer('wclp_verify_license', 'nonce', false)) {
|
||||
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
|
||||
}
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
wp_send_json_error(['message' => __('Insufficient permissions.', 'wc-licensed-product')], 403);
|
||||
}
|
||||
|
||||
$checker = PluginLicenseChecker::getInstance();
|
||||
$checker->clearCache();
|
||||
|
||||
$valid = $checker->validateLicense(true);
|
||||
|
||||
if ($valid) {
|
||||
wp_send_json_success(['message' => __('License verified successfully!', 'wc-licensed-product')]);
|
||||
} else {
|
||||
$error = $checker->getLastError() ?: __('License validation failed.', 'wc-licensed-product');
|
||||
wp_send_json_error(['message' => $error]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,18 @@ 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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="new_download_url"><?php esc_html_e('Or External URL', 'wc-licensed-product'); ?></label></th>
|
||||
<tr id="sha256-hash-row" style="display: none;">
|
||||
<th><label for="new_checksum_file"><?php esc_html_e('Checksum File', 'wc-licensed-product'); ?></label></th>
|
||||
<td>
|
||||
<input type="url" id="new_download_url" name="new_download_url" class="large-text" placeholder="https://" />
|
||||
<p class="description"><?php esc_html_e('Alternative: Enter an external download URL instead of uploading a file.', 'wc-licensed-product'); ?></p>
|
||||
<input type="file" id="new_checksum_file" name="new_checksum_file" accept=".sha256,.txt" style="display: none;" />
|
||||
<span id="selected_checksum_name" class="selected-file-name"></span>
|
||||
<button type="button" class="button" id="select-checksum-file-btn">
|
||||
<?php esc_html_e('Select Checksum File', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<button type="button" class="button" id="remove-checksum-file-btn" style="display: none;">
|
||||
<?php esc_html_e('Remove', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<p class="description"><?php esc_html_e('Upload a SHA256 checksum file (.sha256 or .txt) to verify file integrity.', 'wc-licensed-product'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -128,6 +135,7 @@ final class VersionAdminController
|
||||
<tr>
|
||||
<th><?php esc_html_e('Version', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Download File', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('SHA256', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Release Notes', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
|
||||
<th><?php esc_html_e('Released', 'wc-licensed-product'); ?></th>
|
||||
@@ -137,7 +145,7 @@ final class VersionAdminController
|
||||
<tbody>
|
||||
<?php if (empty($versions)): ?>
|
||||
<tr class="no-versions">
|
||||
<td colspan="6"><?php esc_html_e('No versions found. Add your first version above.', 'wc-licensed-product'); ?></td>
|
||||
<td colspan="7"><?php esc_html_e('No versions found. Add your first version above.', 'wc-licensed-product'); ?></td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($versions as $version): ?>
|
||||
@@ -149,16 +157,25 @@ final class VersionAdminController
|
||||
$filename = $version->getDownloadFilename();
|
||||
if ($effectiveUrl):
|
||||
?>
|
||||
<a href="<?php echo esc_url($effectiveUrl); ?>" target="_blank">
|
||||
<?php echo esc_html($filename ?: wp_basename($effectiveUrl)); ?>
|
||||
</a>
|
||||
<?php if ($version->getAttachmentId()): ?>
|
||||
<span class="dashicons dashicons-media-archive" title="<?php esc_attr_e('Uploaded file', 'wc-licensed-product'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
<span class="version-download-link">
|
||||
<a href="<?php echo esc_url($effectiveUrl); ?>" target="_blank">
|
||||
<?php echo esc_html($filename ?: wp_basename($effectiveUrl)); ?>
|
||||
</a>
|
||||
<?php if ($version->getAttachmentId()): ?>
|
||||
<span class="dashicons dashicons-media-archive" title="<?php esc_attr_e('Uploaded file', 'wc-licensed-product'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<em><?php esc_html_e('No download file', 'wc-licensed-product'); ?></em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($version->getFileHash()): ?>
|
||||
<code class="file-hash" title="<?php echo esc_attr($version->getFileHash()); ?>"><?php echo esc_html(substr($version->getFileHash(), 0, 12)); ?>...</code>
|
||||
<?php else: ?>
|
||||
<em>—</em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($version->getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?></td>
|
||||
<td>
|
||||
<span class="version-status version-status-<?php echo $version->isActive() ? 'active' : 'inactive'; ?>">
|
||||
@@ -218,6 +235,8 @@ final class VersionAdminController
|
||||
'error' => __('An error occurred. Please try again.', 'wc-licensed-product'),
|
||||
'selectFile' => __('Select Download File', 'wc-licensed-product'),
|
||||
'useThisFile' => __('Use this file', 'wc-licensed-product'),
|
||||
'invalidChecksumFile' => __('Invalid checksum file format. File must contain a 64-character SHA256 hash.', 'wc-licensed-product'),
|
||||
'checksumReadError' => __('Failed to read checksum file.', 'wc-licensed-product'),
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -242,9 +261,9 @@ final class VersionAdminController
|
||||
|
||||
$productId = absint($_POST['product_id'] ?? 0);
|
||||
$version = sanitize_text_field($_POST['version'] ?? '');
|
||||
$downloadUrl = esc_url_raw($_POST['download_url'] ?? '');
|
||||
$releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
|
||||
$attachmentId = absint($_POST['attachment_id'] ?? 0);
|
||||
$fileHash = sanitize_text_field($_POST['file_hash'] ?? '');
|
||||
|
||||
if (!$productId || !$version) {
|
||||
wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
|
||||
@@ -270,13 +289,17 @@ final class VersionAdminController
|
||||
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
$newVersion = $this->versionManager->createVersion(
|
||||
$productId,
|
||||
$version,
|
||||
$releaseNotes ?: null,
|
||||
$downloadUrl ?: null,
|
||||
$attachmentId ?: null
|
||||
);
|
||||
try {
|
||||
$newVersion = $this->versionManager->createVersion(
|
||||
$productId,
|
||||
$version,
|
||||
$releaseNotes ?: null,
|
||||
$attachmentId ?: null,
|
||||
$fileHash ?: null
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
wp_send_json_error(['message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
if (!$newVersion) {
|
||||
global $wpdb;
|
||||
@@ -338,7 +361,7 @@ final class VersionAdminController
|
||||
wp_send_json_error(['message' => __('Version ID is required.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
$result = $this->versionManager->updateVersion($versionId, null, null, !$currentlyActive);
|
||||
$result = $this->versionManager->updateVersion($versionId, null, !$currentlyActive, null);
|
||||
|
||||
if (!$result) {
|
||||
wp_send_json_error(['message' => __('Failed to update version.', 'wc-licensed-product')]);
|
||||
@@ -365,16 +388,25 @@ final class VersionAdminController
|
||||
$filename = $version->getDownloadFilename();
|
||||
if ($effectiveUrl):
|
||||
?>
|
||||
<a href="<?php echo esc_url($effectiveUrl); ?>" target="_blank">
|
||||
<?php echo esc_html($filename ?: wp_basename($effectiveUrl)); ?>
|
||||
</a>
|
||||
<?php if ($version->getAttachmentId()): ?>
|
||||
<span class="dashicons dashicons-media-archive" title="<?php esc_attr_e('Uploaded file', 'wc-licensed-product'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
<span class="version-download-link">
|
||||
<a href="<?php echo esc_url($effectiveUrl); ?>" target="_blank">
|
||||
<?php echo esc_html($filename ?: wp_basename($effectiveUrl)); ?>
|
||||
</a>
|
||||
<?php if ($version->getAttachmentId()): ?>
|
||||
<span class="dashicons dashicons-media-archive" title="<?php esc_attr_e('Uploaded file', 'wc-licensed-product'); ?>"></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<em><?php esc_html_e('No download file', 'wc-licensed-product'); ?></em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($version->getFileHash()): ?>
|
||||
<code class="file-hash" title="<?php echo esc_attr($version->getFileHash()); ?>"><?php echo esc_html(substr($version->getFileHash(), 0, 12)); ?>...</code>
|
||||
<?php else: ?>
|
||||
<em>—</em>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html($version->getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?></td>
|
||||
<td>
|
||||
<span class="version-status version-status-<?php echo $version->isActive() ? 'active' : 'inactive'; ?>">
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ final class AccountController
|
||||
),
|
||||
'release_notes' => $version->getReleaseNotes(),
|
||||
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
|
||||
'file_hash' => $version->getFileHash(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ final class Installer
|
||||
release_notes TEXT DEFAULT NULL,
|
||||
download_url VARCHAR(512) DEFAULT NULL,
|
||||
attachment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
file_hash VARCHAR(64) DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
287
src/License/PluginLicenseChecker.php
Normal file
287
src/License/PluginLicenseChecker.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin License Checker
|
||||
*
|
||||
* Validates the plugin's own license against a remote server.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\License
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\License;
|
||||
|
||||
use Magdev\WcLicensedProductClient\LicenseClient;
|
||||
use Magdev\WcLicensedProductClient\LicenseClientInterface;
|
||||
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
||||
use Magdev\WcLicensedProductClient\Exception\LicenseException;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
/**
|
||||
* Handles validation of this plugin's license
|
||||
*/
|
||||
final class PluginLicenseChecker
|
||||
{
|
||||
/**
|
||||
* Cache key for license validation result
|
||||
*/
|
||||
private const CACHE_KEY = 'wclp_plugin_license_valid';
|
||||
|
||||
/**
|
||||
* Cache TTL for successful validation (1 hour)
|
||||
*/
|
||||
private const CACHE_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Cache key for error messages
|
||||
*/
|
||||
private const ERROR_CACHE_KEY = 'wclp_plugin_license_error';
|
||||
|
||||
/**
|
||||
* Cache TTL for errors (5 minutes)
|
||||
*/
|
||||
private const ERROR_CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Cached localhost check result
|
||||
*/
|
||||
private ?bool $isLocalhostCached = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor for singleton
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
// Private constructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the plugin license is valid
|
||||
*
|
||||
* Returns cached result if available, otherwise validates against server.
|
||||
*/
|
||||
public function isLicenseValid(): bool
|
||||
{
|
||||
// Always valid on localhost
|
||||
if ($this->isLocalhost()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cached = get_transient(self::CACHE_KEY);
|
||||
if ($cached !== false) {
|
||||
return (bool) $cached;
|
||||
}
|
||||
|
||||
// Validate against server
|
||||
return $this->validateLicense();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate license against the server
|
||||
*
|
||||
* @param bool $forceRefresh Force refresh even if cached
|
||||
* @return bool True if license is valid
|
||||
*/
|
||||
public function validateLicense(bool $forceRefresh = false): bool
|
||||
{
|
||||
// Always valid on localhost
|
||||
if ($this->isLocalhost()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check settings are configured
|
||||
$serverUrl = $this->getLicenseServerUrl();
|
||||
$licenseKey = $this->getLicenseKey();
|
||||
|
||||
if (empty($serverUrl) || empty($licenseKey)) {
|
||||
set_transient(
|
||||
self::ERROR_CACHE_KEY,
|
||||
__('License settings not configured.', 'wc-licensed-product'),
|
||||
self::ERROR_CACHE_TTL
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache unless force refresh
|
||||
if (!$forceRefresh) {
|
||||
$cached = get_transient(self::CACHE_KEY);
|
||||
if ($cached !== false) {
|
||||
return (bool) $cached;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->createLicenseClient();
|
||||
$domain = $this->getCurrentDomain();
|
||||
|
||||
// Validate the license
|
||||
$client->validate($licenseKey, $domain);
|
||||
|
||||
// Valid license - cache success
|
||||
set_transient(self::CACHE_KEY, 1, self::CACHE_TTL);
|
||||
delete_transient(self::ERROR_CACHE_KEY);
|
||||
|
||||
return true;
|
||||
} catch (LicenseException $e) {
|
||||
// License-specific error (invalid, expired, revoked, etc.)
|
||||
set_transient(self::CACHE_KEY, 0, self::CACHE_TTL);
|
||||
set_transient(self::ERROR_CACHE_KEY, $e->getMessage(), self::ERROR_CACHE_TTL);
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
// Network/server error - use shorter cache to allow retry
|
||||
set_transient(
|
||||
self::ERROR_CACHE_KEY,
|
||||
__('Could not connect to license server.', 'wc-licensed-product') . ' ' . $e->getMessage(),
|
||||
self::ERROR_CACHE_TTL
|
||||
);
|
||||
|
||||
// Don't cache validation failure on network errors - allow retry on next page load
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last error message
|
||||
*/
|
||||
public function getLastError(): ?string
|
||||
{
|
||||
$error = get_transient(self::ERROR_CACHE_KEY);
|
||||
return $error !== false ? (string) $error : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the validation cache
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
delete_transient(self::CACHE_KEY);
|
||||
delete_transient(self::ERROR_CACHE_KEY);
|
||||
$this->isLocalhostCached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on localhost
|
||||
*
|
||||
* Matches localhost, 127.0.0.1, ::1, and any port number.
|
||||
*/
|
||||
public function isLocalhost(): bool
|
||||
{
|
||||
if ($this->isLocalhostCached !== null) {
|
||||
return $this->isLocalhostCached;
|
||||
}
|
||||
|
||||
$domain = $this->getCurrentDomain();
|
||||
|
||||
// Remove port number if present
|
||||
$domainWithoutPort = preg_replace('/:[\d]+$/', '', $domain);
|
||||
|
||||
// Check for localhost variants
|
||||
$localhostNames = ['localhost', '127.0.0.1', '::1'];
|
||||
|
||||
if (in_array($domainWithoutPort, $localhostNames, true)) {
|
||||
$this->isLocalhostCached = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for .localhost and .local subdomains
|
||||
if (
|
||||
str_ends_with($domainWithoutPort, '.localhost') ||
|
||||
str_ends_with($domainWithoutPort, '.local')
|
||||
) {
|
||||
$this->isLocalhostCached = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->isLocalhostCached = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current domain from the site URL
|
||||
*/
|
||||
private function getCurrentDomain(): string
|
||||
{
|
||||
$siteUrl = get_site_url();
|
||||
$parsed = parse_url($siteUrl);
|
||||
$host = $parsed['host'] ?? 'localhost';
|
||||
|
||||
// Include port if non-standard
|
||||
if (isset($parsed['port'])) {
|
||||
$host .= ':' . $parsed['port'];
|
||||
}
|
||||
|
||||
return strtolower($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the license server URL from settings
|
||||
*/
|
||||
private function getLicenseServerUrl(): string
|
||||
{
|
||||
return (string) get_option('wc_licensed_product_plugin_license_server_url', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the license key from settings
|
||||
*/
|
||||
private function getLicenseKey(): string
|
||||
{
|
||||
return (string) get_option('wc_licensed_product_plugin_license_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server secret from settings (optional)
|
||||
*/
|
||||
private function getServerSecret(): ?string
|
||||
{
|
||||
$secret = get_option('wc_licensed_product_plugin_license_server_secret', '');
|
||||
return !empty($secret) ? (string) $secret : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the license client instance
|
||||
*/
|
||||
private function createLicenseClient(): LicenseClientInterface
|
||||
{
|
||||
$httpClient = HttpClient::create([
|
||||
'timeout' => 10,
|
||||
'verify_peer' => true,
|
||||
]);
|
||||
|
||||
$serverUrl = $this->getLicenseServerUrl();
|
||||
$serverSecret = $this->getServerSecret();
|
||||
|
||||
// Use secure client if server secret is configured
|
||||
if ($serverSecret !== null) {
|
||||
return new SecureLicenseClient(
|
||||
httpClient: $httpClient,
|
||||
baseUrl: $serverUrl,
|
||||
serverSecret: $serverSecret,
|
||||
);
|
||||
}
|
||||
|
||||
return new LicenseClient(
|
||||
httpClient: $httpClient,
|
||||
baseUrl: $serverUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||
@@ -21,6 +22,7 @@ use Jeremias\WcLicensedProduct\Email\LicenseEmailController;
|
||||
use Jeremias\WcLicensedProduct\Frontend\AccountController;
|
||||
use Jeremias\WcLicensedProduct\Frontend\DownloadController;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Twig\Environment;
|
||||
@@ -118,21 +120,42 @@ final class Plugin
|
||||
$this->licenseManager = new LicenseManager();
|
||||
$this->versionManager = new VersionManager();
|
||||
|
||||
// Initialize controllers
|
||||
// Check plugin license
|
||||
$licenseChecker = PluginLicenseChecker::getInstance();
|
||||
$isLicensed = $licenseChecker->isLicenseValid();
|
||||
|
||||
// Always initialize product type (needed for existing orders)
|
||||
new LicensedProductType();
|
||||
new CheckoutController($this->licenseManager);
|
||||
new StoreApiExtension($this->licenseManager);
|
||||
$this->registerCheckoutBlocksIntegration();
|
||||
$this->downloadController = new DownloadController($this->licenseManager, $this->versionManager);
|
||||
new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController);
|
||||
|
||||
// Only initialize frontend components if licensed or on localhost
|
||||
if ($isLicensed) {
|
||||
new CheckoutController($this->licenseManager);
|
||||
new StoreApiExtension($this->licenseManager);
|
||||
$this->registerCheckoutBlocksIntegration();
|
||||
$this->downloadController = new DownloadController($this->licenseManager, $this->versionManager);
|
||||
new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController);
|
||||
}
|
||||
|
||||
// Always initialize REST API and email controller
|
||||
new RestApiController($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();
|
||||
}
|
||||
|
||||
// Admin always available
|
||||
if (is_admin()) {
|
||||
new AdminController($this->twig, $this->licenseManager);
|
||||
new VersionAdminController($this->versionManager);
|
||||
new OrderLicenseController($this->licenseManager);
|
||||
new SettingsController();
|
||||
|
||||
// Show admin notice if unlicensed and not on localhost
|
||||
if (!$isLicensed && !$licenseChecker->isLocalhost()) {
|
||||
add_action('admin_notices', [$this, 'showUnlicensedNotice']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,12 +181,16 @@ final class Plugin
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Generate license on order completion (multiple hooks for compatibility)
|
||||
add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']);
|
||||
add_action('woocommerce_order_status_processing', [$this, 'onOrderCompleted']);
|
||||
// Only register order hooks if licensed (license generation requires valid license)
|
||||
$licenseChecker = PluginLicenseChecker::getInstance();
|
||||
if ($licenseChecker->isLicenseValid()) {
|
||||
// Generate license on order completion (multiple hooks for compatibility)
|
||||
add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']);
|
||||
add_action('woocommerce_order_status_processing', [$this, 'onOrderCompleted']);
|
||||
|
||||
// Also hook into payment complete for immediate license generation
|
||||
add_action('woocommerce_payment_complete', [$this, 'onOrderCompleted']);
|
||||
// Also hook into payment complete for immediate license generation
|
||||
add_action('woocommerce_payment_complete', [$this, 'onOrderCompleted']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,4 +242,29 @@ final class Plugin
|
||||
{
|
||||
return $this->twig->render($template, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show admin notice when plugin is unlicensed
|
||||
*/
|
||||
public function showUnlicensedNotice(): void
|
||||
{
|
||||
$settingsUrl = admin_url('admin.php?page=wc-settings&tab=licensed_product');
|
||||
?>
|
||||
<div class="notice notice-warning is-dismissible">
|
||||
<p>
|
||||
<strong><?php esc_html_e('WC Licensed Product', 'wc-licensed-product'); ?>:</strong>
|
||||
<?php esc_html_e('Plugin license is not configured or invalid. Frontend features are disabled.', 'wc-licensed-product'); ?>
|
||||
<a href="<?php echo esc_url($settingsUrl); ?>"><?php esc_html_e('Configure License', 'wc-licensed-product'); ?></a>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin license checker instance
|
||||
*/
|
||||
public function getLicenseChecker(): PluginLicenseChecker
|
||||
{
|
||||
return PluginLicenseChecker::getInstance();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ final class LicensedProductType
|
||||
|
||||
// Make product virtual by default
|
||||
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
|
||||
|
||||
// Display current version under product title on single product page
|
||||
add_action('woocommerce_single_product_summary', [$this, 'displayCurrentVersion'], 6);
|
||||
|
||||
// Enqueue frontend CSS for licensed products on single product pages
|
||||
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,4 +241,52 @@ final class LicensedProductType
|
||||
}
|
||||
return $isVirtual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend styles for licensed products on single product pages
|
||||
*/
|
||||
public function enqueueFrontendStyles(): void
|
||||
{
|
||||
if (!is_product()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'wc-licensed-product-frontend',
|
||||
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/frontend.css',
|
||||
[],
|
||||
WC_LICENSED_PRODUCT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display current version under product title on single product page
|
||||
*/
|
||||
public function displayCurrentVersion(): void
|
||||
{
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var LicensedProduct $product */
|
||||
$version = $product->get_current_version();
|
||||
|
||||
if (empty($version)) {
|
||||
return;
|
||||
}
|
||||
|
||||
printf(
|
||||
'<p class="wclp-product-version"><span class="version-label">%s</span> <span class="version-number">%s</span></p>',
|
||||
esc_html__('Version:', 'wc-licensed-product'),
|
||||
esc_html($version)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class ProductVersion
|
||||
private ?string $releaseNotes;
|
||||
private ?string $downloadUrl;
|
||||
private ?int $attachmentId;
|
||||
private ?string $fileHash;
|
||||
private bool $isActive;
|
||||
private \DateTimeInterface $releasedAt;
|
||||
private \DateTimeInterface $createdAt;
|
||||
@@ -42,6 +43,7 @@ class ProductVersion
|
||||
$version->releaseNotes = $data['release_notes'] ?: null;
|
||||
$version->downloadUrl = $data['download_url'] ?: 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->releasedAt = new \DateTimeImmutable($data['released_at']);
|
||||
$version->createdAt = new \DateTimeImmutable($data['created_at']);
|
||||
@@ -137,15 +139,20 @@ class ProductVersion
|
||||
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
|
||||
{
|
||||
if ($this->attachmentId) {
|
||||
return wp_get_attachment_url($this->attachmentId) ?: null;
|
||||
}
|
||||
return $this->downloadUrl;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,9 +163,6 @@ class ProductVersion
|
||||
if ($this->attachmentId) {
|
||||
return wp_basename(get_attached_file($this->attachmentId) ?: '');
|
||||
}
|
||||
if ($this->downloadUrl) {
|
||||
return wp_basename($this->downloadUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,6 +196,7 @@ class ProductVersion
|
||||
'release_notes' => $this->releaseNotes,
|
||||
'download_url' => $this->downloadUrl,
|
||||
'attachment_id' => $this->attachmentId,
|
||||
'file_hash' => $this->fileHash,
|
||||
'is_active' => $this->isActive,
|
||||
'released_at' => $this->releasedAt->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
|
||||
*
|
||||
* @throws \InvalidArgumentException If file hash validation fails
|
||||
*/
|
||||
public function createVersion(
|
||||
int $productId,
|
||||
string $version,
|
||||
?string $releaseNotes = null,
|
||||
?string $downloadUrl = null,
|
||||
?int $attachmentId = null
|
||||
?int $attachmentId = null,
|
||||
?string $fileHash = null
|
||||
): ?ProductVersion {
|
||||
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);
|
||||
|
||||
$tableName = Installer::getVersionsTable();
|
||||
@@ -114,10 +125,9 @@ class VersionManager
|
||||
'minor_version' => $parsed['minor'],
|
||||
'patch_version' => $parsed['patch'],
|
||||
'release_notes' => $releaseNotes,
|
||||
'download_url' => $downloadUrl,
|
||||
'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
|
||||
if ($attachmentId !== null && $attachmentId > 0) {
|
||||
@@ -125,6 +135,12 @@ class VersionManager
|
||||
$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);
|
||||
|
||||
if ($result === false) {
|
||||
@@ -136,13 +152,44 @@ class VersionManager
|
||||
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
|
||||
*/
|
||||
public function updateVersion(
|
||||
int $versionId,
|
||||
?string $releaseNotes = null,
|
||||
?string $downloadUrl = null,
|
||||
?bool $isActive = null,
|
||||
?int $attachmentId = null
|
||||
): bool {
|
||||
@@ -156,19 +203,26 @@ class VersionManager
|
||||
$formats[] = '%s';
|
||||
}
|
||||
|
||||
if ($downloadUrl !== null) {
|
||||
$data['download_url'] = $downloadUrl;
|
||||
$formats[] = '%s';
|
||||
}
|
||||
|
||||
if ($isActive !== null) {
|
||||
$data['is_active'] = $isActive ? 1 : 0;
|
||||
$formats[] = '%d';
|
||||
}
|
||||
|
||||
if ($attachmentId !== null) {
|
||||
$data['attachment_id'] = $attachmentId > 0 ? $attachmentId : null;
|
||||
$formats[] = $attachmentId > 0 ? '%d' : null;
|
||||
if ($attachmentId > 0) {
|
||||
$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)) {
|
||||
|
||||
@@ -184,6 +184,13 @@
|
||||
</td>
|
||||
<td class="license-actions">
|
||||
<div class="row-actions">
|
||||
<span class="test">
|
||||
<a href="#" class="wclp-test-license-link"
|
||||
data-license-id="{{ item.license.id }}"
|
||||
data-license-key="{{ esc_attr(item.license.licenseKey) }}"
|
||||
data-domain="{{ esc_attr(item.license.domain) }}"
|
||||
title="{{ __('Test license against API') }}">{{ __('Test') }}</a> |
|
||||
</span>
|
||||
{% if item.license.status != 'revoked' %}
|
||||
<span class="transfer">
|
||||
<a href="#" class="wclp-transfer-link"
|
||||
@@ -272,6 +279,36 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Test License Modal -->
|
||||
<div id="wclp-test-modal" class="wclp-modal" style="display:none;">
|
||||
<div class="wclp-modal-content">
|
||||
<span class="wclp-modal-close">×</span>
|
||||
<h2>{{ __('License Validation Test') }}</h2>
|
||||
<div class="wclp-test-info">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th scope="row">{{ __('License Key') }}</th>
|
||||
<td><code id="test-license-key"></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{{ __('Domain') }}</th>
|
||||
<td><code id="test-domain"></code></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div id="wclp-test-loading" style="display:none; text-align:center; padding:20px;">
|
||||
<span class="spinner is-active" style="float:none;"></span>
|
||||
<p>{{ __('Testing license...') }}</p>
|
||||
</div>
|
||||
<div id="wclp-test-result" style="display:none;">
|
||||
<div id="wclp-test-result-content"></div>
|
||||
</div>
|
||||
<p class="submit">
|
||||
<button type="button" class="button wclp-modal-cancel">{{ __('Close') }}</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Modal -->
|
||||
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
|
||||
<div class="wclp-modal-content">
|
||||
@@ -349,5 +386,91 @@
|
||||
$modal.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Test License modal
|
||||
var $testModal = $('#wclp-test-modal');
|
||||
var $testLoading = $('#wclp-test-loading');
|
||||
var $testResult = $('#wclp-test-result');
|
||||
var $testResultContent = $('#wclp-test-result-content');
|
||||
|
||||
$('.wclp-test-license-link').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
var licenseKey = $(this).data('license-key');
|
||||
var domain = $(this).data('domain');
|
||||
|
||||
// Show modal with info
|
||||
$('#test-license-key').text(licenseKey);
|
||||
$('#test-domain').text(domain);
|
||||
$testLoading.show();
|
||||
$testResult.hide();
|
||||
$testModal.show();
|
||||
|
||||
// Call the test endpoint
|
||||
$.ajax({
|
||||
url: wclpAdmin.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wclp_test_license',
|
||||
nonce: wclpAdmin.editNonce,
|
||||
license_key: licenseKey,
|
||||
domain: domain
|
||||
},
|
||||
success: function(response) {
|
||||
$testLoading.hide();
|
||||
if (response.success) {
|
||||
var result = response.data;
|
||||
var html = '';
|
||||
|
||||
if (result.valid) {
|
||||
html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>';
|
||||
html += '<table class="widefat striped"><tbody>';
|
||||
html += '<tr><th>{{ __('Product') }}</th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>';
|
||||
html += '<tr><th>{{ __('Version') }}</th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
|
||||
if (result.expires_at) {
|
||||
html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
|
||||
} else {
|
||||
html += '<tr><th>{{ __('Expires') }}</th><td>{{ __('Lifetime') }}</td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
html = '<div class="notice notice-error inline"><p><strong>✗ {{ __('License is INVALID') }}</strong></p></div>';
|
||||
html += '<table class="widefat striped"><tbody>';
|
||||
html += '<tr><th>{{ __('Error Code') }}</th><td><code>' + escapeHtml(result.error || 'unknown') + '</code></td></tr>';
|
||||
html += '<tr><th>{{ __('Message') }}</th><td>' + escapeHtml(result.message || '-') + '</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
$testResultContent.html(html);
|
||||
$testResult.show();
|
||||
} else {
|
||||
$testResultContent.html('<div class="notice notice-error inline"><p>' + escapeHtml(response.data.message || 'Error') + '</p></div>');
|
||||
$testResult.show();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$testLoading.hide();
|
||||
$testResultContent.html('<div class="notice notice-error inline"><p>{{ __('Failed to test license. Please try again.') }}</p></div>');
|
||||
$testResult.show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close test modal
|
||||
$testModal.find('.wclp-modal-close, .wclp-modal-cancel').on('click', function() {
|
||||
$testModal.hide();
|
||||
});
|
||||
|
||||
$(window).on('click', function(e) {
|
||||
if ($(e.target).is($testModal)) {
|
||||
$testModal.hide();
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
})(jQuery);
|
||||
</script>
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
</a>
|
||||
<span class="download-version">v{{ esc_html(download.version) }}</span>
|
||||
<span class="download-date">{{ esc_html(download.released_at) }}</span>
|
||||
{% if download.file_hash %}
|
||||
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
|
||||
<span class="dashicons dashicons-shield"></span>
|
||||
<code>{{ download.file_hash[:12] }}...</code>
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: WooCommerce Licensed Product
|
||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
|
||||
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
|
||||
* Version: 0.0.11
|
||||
* Version: 0.3.4
|
||||
* Author: Marco Graetsch
|
||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.0.11');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.3.4');
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user