You've already forked wc-licensed-product
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3abf05cff3 | |||
| 169eed65eb | |||
| 90cb8d97bd | |||
| fc281f7f4a | |||
| 962368d35f | |||
| 4dcace6f06 | |||
| 62aecc0240 | |||
| 1f676556f2 | |||
| 5f51aafe3b | |||
| 279b0d5dd6 | |||
| 086755cb11 | |||
| 0b58de193e | |||
| ae49b262fa | |||
| 5d5bb7e595 | |||
| bee9854c18 | |||
| c31df1e8c4 | |||
| 8cac742f57 | |||
| 41e46fc7b8 | |||
| 549a58dc5d | |||
| 7d02105284 | |||
| 2207efbc52 | |||
| 3fe173686b | |||
| 86b5bdb075 | |||
| c6d6269ee3 | |||
| 75f1dabdb4 | |||
| 8acde7cadd | |||
| c45816b491 | |||
| bcabf8feb2 | |||
| 83836d69af | |||
| 550a84beb9 | |||
| 7d48028f62 | |||
| 2ec3f42b1f | |||
| 4817175f99 | |||
| a4561057fa | |||
| d15c59b7c3 | |||
| 4a90e6b18b | |||
| 502a8c7cd7 | |||
| 6b83fce8b2 | |||
| 8c33eaff29 | |||
| 98002ae3d7 | |||
| a93381dce6 | |||
| a522455a0a | |||
| 2de6abe133 | |||
| 8d60758f23 | |||
| 82bec621c6 | |||
| 034593f896 | |||
| 202f8a6dc0 | |||
| 36b51c9fc8 | |||
| d0aaf3180f | |||
| 35d802c2b8 | |||
| 4e683e2ff4 | |||
| c7967f71ab | |||
| 1de8257527 | |||
| 26245c0c57 | |||
| a6c6d247aa | |||
| fba8bf2352 | |||
| 12a3a37658 | |||
| b1fe34adfd | |||
| dcf3a03598 | |||
| 38a9f0d90f | |||
| 8b87c954eb | |||
| 1bc643408e | |||
| 875c8dd1c1 | |||
| 5834e067f4 | |||
| 79417e4971 | |||
| 304eb16e2e | |||
| df4cfc7e84 | |||
| 812beb2a02 | |||
| e6c8bb5471 | |||
| e9763192f6 | |||
| 6fe3a88592 | |||
| bb8f44bfac | |||
| f7490de69b | |||
| d2bf9aa330 | |||
| d00a2235ef | |||
| 27c9a22739 | |||
| fc2fe70576 | |||
| f5a1e55710 |
407
CHANGELOG.md
407
CHANGELOG.md
@@ -7,6 +7,405 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.8] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed critical error on frontend product pages for licensed variable products
|
||||
- Variable product add-to-cart template now passes required variables (`available_variations`, `attributes`, `selected_attributes`)
|
||||
- Variants tab no longer disappears when saving attributes on licensed variable products
|
||||
- Added WooCommerce AJAX event listeners to maintain tab visibility during attribute operations
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved JavaScript event handling for licensed-variable product type in admin
|
||||
- Added listeners for `woocommerce_variations_loaded`, `woocommerce_variations_added`, `woocommerce_variations_saved` events
|
||||
- Added AJAX complete handler for attribute save operations
|
||||
|
||||
## [0.5.7] - 2026-01-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed "Default" prefix from setting labels on Default Settings page for cleaner UI
|
||||
- Labels now read "Max Activations", "License Validity (Days)", and "Bind to Major Version"
|
||||
|
||||
## [0.5.6] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- License Settings tab now only shows for Licensed Product and Licensed Variable Product types
|
||||
- Previously the tab was visible on all product types due to CSS `!important` override
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved JavaScript for License Settings tab visibility handling on product type change
|
||||
- Updated README.md with complete feature documentation for v0.5.x features:
|
||||
- Variable Licensed Products
|
||||
- Multi-Domain Licensing
|
||||
- Per-License Customer Secrets
|
||||
- Download Statistics
|
||||
- Configurable Rate Limiting
|
||||
|
||||
## [0.5.5] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Response signing key derivation now uses native `hash_hkdf()` for RFC 5869 compliance
|
||||
- Key derivation now matches client library (`SecureLicenseClient`) exactly
|
||||
- Added missing domain validation to `/activate` endpoint (1-255 characters)
|
||||
|
||||
### Changed
|
||||
|
||||
- `ResponseSigner::deriveCustomerSecret()` now uses `hash_hkdf('sha256', $serverSecret, 32, $licenseKey)`
|
||||
- Previous custom HKDF-like implementation was incompatible with client library
|
||||
|
||||
### Security
|
||||
|
||||
- Signatures generated by server now verify correctly with `magdev/wc-licensed-product-client`
|
||||
- All three API endpoints now have consistent parameter validation
|
||||
|
||||
## [0.5.4] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- REST API `/validate` endpoint now returns HTTP 404 for `license_not_found` error (was 403)
|
||||
- License key validation now enforces minimum 8 characters per API documentation
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable rate limiting via `WC_LICENSE_RATE_LIMIT` and `WC_LICENSE_RATE_WINDOW` constants
|
||||
- Rate limit now defaults to 30 requests per 60 second window (configurable)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved HTTP status code mapping: 404 for not found, 500 for server errors, 403 for all other errors
|
||||
- Rate limiting implementation now uses configurable constants instead of hardcoded values
|
||||
|
||||
## [0.5.3] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
- Variable licensed product type (`licensed-variable`) for selling licenses with different durations
|
||||
- Support for monthly, yearly, quarterly, or lifetime license variations
|
||||
- `LicensedVariableProduct` class extending `WC_Product_Variable`
|
||||
- `LicensedProductVariation` class for individual variation license settings
|
||||
- Variation-specific license duration settings in product edit page
|
||||
- Duration labels displayed in checkout domain fields (e.g., "Yearly License")
|
||||
- Variation ID tracking in order domain meta for proper license generation
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `LicenseManager::generateLicense()` to accept optional variation ID
|
||||
- Checkout now handles variations with separate domain fields per product/variation
|
||||
- WooCommerce Blocks checkout updated to display variation duration labels
|
||||
- Store API extension updated to include variation_id in domain data schema
|
||||
|
||||
## [0.5.2] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
- Per-license customer secrets for API response verification
|
||||
- "API Verification Secret" section in customer account licenses page (collapsible)
|
||||
- Copy button for customer secrets with clipboard support
|
||||
- Documentation for per-license secret derivation and usage
|
||||
|
||||
### Security
|
||||
|
||||
- Customers no longer need the master server secret for signature verification
|
||||
- Each license key has a unique derived secret using HKDF-like key derivation
|
||||
- If one customer's secret is compromised, other customers remain unaffected
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `ResponseSigner` with static methods for secret derivation
|
||||
- Updated `server-implementation.md` with per-license secret documentation
|
||||
- Added new translation strings for secret-related UI
|
||||
|
||||
## [0.5.1] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Product versions now sort correctly by version DESC when added via AJAX in admin
|
||||
- License actions in admin overview are now always visible instead of only on hover
|
||||
|
||||
### Changed
|
||||
|
||||
- Added `compareVersions()` JavaScript function for proper semantic version comparison
|
||||
- Updated CSS with `!important` to override WordPress default hover-only behavior for row actions
|
||||
|
||||
## [0.5.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
- Multi-domain licensing support: Customers can now purchase multiple licenses for different domains in a single order
|
||||
- Each cart item quantity requires a unique domain at checkout
|
||||
- New "Enable Multi-Domain Licensing" setting in WooCommerce > Settings > Licensed Products
|
||||
- Multi-domain checkout UI for WooCommerce Blocks checkout
|
||||
- DOM injection fallback for checkout domain fields when React component fails to render
|
||||
- Grouped license display in customer account page by product/order
|
||||
- "Older versions" collapsible section in customer download area
|
||||
- Updated email templates to show licenses grouped by product
|
||||
|
||||
### Changed
|
||||
|
||||
- Customer account licenses page now shows licenses grouped by product package
|
||||
- Order meta now stores `_licensed_product_domains` array for multi-domain orders
|
||||
- Updated translations with 19 new strings for multi-domain functionality (de_CH)
|
||||
- Refactored checkout blocks JavaScript to use ExperimentalOrderMeta slot pattern
|
||||
|
||||
### Technical Details
|
||||
|
||||
- `CheckoutBlocksIntegration` now uses `registerPlugin` with `woocommerce-checkout` scope
|
||||
- `StoreApiExtension` handles both single-domain and multi-domain data formats
|
||||
- `CheckoutController` validates unique domains per product in multi-domain mode
|
||||
- `AccountController` groups licenses by product for package-style display
|
||||
- Backward compatible: existing single-domain orders continue to work
|
||||
|
||||
## [0.4.0] - 2026-01-24
|
||||
|
||||
### Added
|
||||
|
||||
- Self-licensing prevention: Plugin automatically bypasses license validation when the configured license server URL points to the same WordPress installation
|
||||
- New `isSelfLicensing()` method in `PluginLicenseChecker` to detect circular licensing scenarios
|
||||
- New `normalizeDomain()` helper method for domain comparison (strips www prefix, lowercases)
|
||||
|
||||
### Changed
|
||||
|
||||
- `isLicenseValid()` and `validateLicense()` now check for self-licensing before attempting validation
|
||||
- Cache clearing now also clears the self-licensing check cache
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Self-licensing detection compares normalized domains of license server URL and current site URL
|
||||
- Prevents circular dependency where plugin would try to validate against itself
|
||||
- Plugins can only be validated against the original store from which they were obtained
|
||||
|
||||
## [0.3.9] - 2026-01-24
|
||||
|
||||
### Added
|
||||
|
||||
- "Generate Licenses" button in order meta box for admin-created orders
|
||||
- "Generate Missing Licenses" button when some products in an order are missing licenses
|
||||
- AJAX handler `ajaxGenerateOrderLicenses()` for manual license generation from admin
|
||||
- Warning message when order domain is not set before generating licenses
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Critical:** Licenses are now generated for orders created manually in admin area
|
||||
- Previously, licenses were only generated via checkout hooks, leaving admin-created orders without licenses
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `wclp_generate_order_licenses` AJAX action to `OrderLicenseController`
|
||||
- Updated `order-licenses.js` with generate button handler and page reload on success
|
||||
- Added CSS styles for generate status messages
|
||||
- Updated translations (365 strings)
|
||||
|
||||
## [0.3.8] - 2026-01-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate German translation string causing `ArgumentCountError` in settings page
|
||||
- The notification settings description had duplicated text with two `%s` placeholders
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` to latest version (64d215c)
|
||||
|
||||
## [0.3.7] - 2026-01-24
|
||||
|
||||
### Added
|
||||
|
||||
- Download counter for licensed product versions (tracked per version)
|
||||
- Download Statistics admin dashboard widget showing total downloads, top products, and top versions
|
||||
- New `DownloadWidgetController` class for download statistics widget
|
||||
- New `incrementDownloadCount()`, `getTotalDownloadCount()`, and `getDownloadStatistics()` methods in `VersionManager`
|
||||
- New `download_count` column in product versions database table
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard widget "View All Licenses" link now uses correct page slug (`wc-licenses`)
|
||||
- Download links in customer account page no longer result in 404 errors (added query var registration)
|
||||
- Added `license-download` endpoint registration during plugin activation
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed redundant "Status Breakdown" section from dashboard widget (info already shown in stat cards)
|
||||
- License Types section in dashboard widget now uses card style matching the stats row above
|
||||
- Improved dashboard widget visual consistency
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `addDownloadQueryVar()` method to `DownloadController` for proper endpoint registration
|
||||
- Updated `Installer::activate()` to register `license-download` endpoint before flushing rewrite rules
|
||||
- Updated translations (356 strings)
|
||||
|
||||
## [0.3.6] - 2026-01-23
|
||||
|
||||
### Security
|
||||
|
||||
- Added CSRF protection (nonce verification) to CSV export functionality
|
||||
- Fixed IP header spoofing vulnerability in rate limiting - now requires explicit trusted proxy configuration
|
||||
- Enabled explicit Twig autoescape for XSS protection
|
||||
- Fixed unescaped status values in CSS classes in Twig templates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed response signing to use recursive key sorting for client compatibility
|
||||
- ResponseSigner now recursively sorts nested array keys alphabetically as required by client implementation
|
||||
|
||||
### Changed
|
||||
|
||||
- Rate limiting now only trusts proxy headers when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
|
||||
- Added Cloudflare IP range support via `WC_LICENSE_TRUSTED_PROXIES = 'CLOUDFLARE'` configuration
|
||||
- Improved IP detection with CIDR notation support for trusted proxy ranges
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `recursiveKeySort()` method to `ResponseSigner` for proper response signing
|
||||
- Added `isTrustedProxy()`, `isCloudflareIp()`, and `ipMatchesCidr()` methods to `RestApiController`
|
||||
- Twig environment now explicitly sets `autoescape => 'html'`
|
||||
- Export CSV link now includes nonce via `wp_nonce_url()`
|
||||
- Added `export_csv_url()` Twig function for generating export URL with nonce
|
||||
|
||||
## [0.3.5] - 2026-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- Admin dashboard widget showing license statistics on WordPress dashboard
|
||||
- Automatic license expiration via daily wp-cron job
|
||||
- License expired email notification sent when license auto-expires
|
||||
- New `LicenseExpiredEmail` WooCommerce email class (configurable via WooCommerce > Settings > Emails)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved download list layout in customer account licenses page
|
||||
- Downloads now displayed in two-row format: file link on first row, metadata on second row
|
||||
- Better visual separation between download link and version/date/checksum information
|
||||
|
||||
### Technical Details
|
||||
|
||||
- New `DashboardWidgetController` class in `src/Admin/` for WordPress dashboard widget
|
||||
- Widget displays: total licenses, active, expiring soon, expired counts, status breakdown, license types
|
||||
- New `LicenseExpiredEmail` class in `src/Email/` for expired license notifications
|
||||
- Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods to `LicenseManager`
|
||||
- Daily cron now auto-expires licenses with past expiration date and sends notification emails
|
||||
- Updated `templates/frontend/licenses.html.twig` with new two-row structure
|
||||
- Added `.download-item`, `.download-row-file`, `.download-row-meta` CSS classes
|
||||
- Improved responsive behavior for download metadata
|
||||
|
||||
## [0.3.4] - 2026-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- Current version display on single product pages for licensed products
|
||||
- Version number shown directly under the product title
|
||||
- Frontend CSS styling for version badge with monospace font
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added `displayCurrentVersion()` method to `LicensedProductType` class
|
||||
- Hooked to `woocommerce_single_product_summary` at priority 6 (after title)
|
||||
- Added `enqueueFrontendStyles()` to load CSS on product pages
|
||||
- Uses `LicensedProduct::get_current_version()` to fetch latest version
|
||||
|
||||
## [0.3.3] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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
|
||||
@@ -340,7 +739,13 @@ define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||
- WordPress REST API integration
|
||||
- Custom WooCommerce product type extending WC_Product
|
||||
|
||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.0...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
|
||||
|
||||
786
CLAUDE.md
786
CLAUDE.md
@@ -32,11 +32,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
|
||||
|
||||
### Known Bugs
|
||||
### Version 0.6.0
|
||||
|
||||
No known bugs at the moment
|
||||
|
||||
No planned features at this time. See Session History for completed work.
|
||||
*No planned features yet.*
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -717,3 +715,783 @@ 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()`
|
||||
|
||||
### 2026-01-23 - Version 0.3.5 - Dashboard Widget & Auto-Expire
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added admin dashboard widget for license statistics and automatic license expiration via daily cron job.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Admin dashboard widget showing license statistics (total, active, expiring soon, expired)
|
||||
- Status breakdown display with color-coded badges
|
||||
- License type breakdown (time-limited vs lifetime)
|
||||
- Daily wp-cron job to auto-expire licenses past their expiration date
|
||||
- License expired email notification sent when license auto-expires
|
||||
- Downloads in customer account now displayed in two-row format
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Admin/DashboardWidgetController.php` - WordPress dashboard widget controller
|
||||
- `src/Email/LicenseExpiredEmail.php` - WooCommerce email for expired license notifications
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Plugin.php` - Added DashboardWidgetController instantiation
|
||||
- `src/License/LicenseManager.php` - Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods
|
||||
- `src/Email/LicenseEmailController.php` - Added auto-expire logic and LicenseExpiredEmail registration
|
||||
- `templates/frontend/licenses.html.twig` - Restructured download list with two-row layout
|
||||
- `assets/css/frontend.css` - Added dashboard widget and download list styles
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Dashboard widget uses `wp_add_dashboard_widget()` hook, requires `manage_woocommerce` capability
|
||||
- Widget displays statistics from existing `LicenseManager::getStatistics()` method
|
||||
- Auto-expire runs during daily `wclp_check_expiring_licenses` cron event
|
||||
- `getExpiredActiveLicenses()` finds licenses with past expiration date but still active status
|
||||
- `autoExpireLicense()` updates status to expired and returns true if changed
|
||||
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
|
||||
- Expired notification tracked via user meta to prevent duplicate emails
|
||||
|
||||
### 2026-01-23 - Version 0.3.6 - Security Hardening
|
||||
|
||||
**Overview:**
|
||||
|
||||
Security audit and implementation alignment with client/server documentation. Fixed response signing compatibility, rate limiting security, and XSS prevention.
|
||||
|
||||
**Security Fixes:**
|
||||
|
||||
- Added CSRF protection (nonce verification) to CSV export functionality
|
||||
- Fixed IP header spoofing vulnerability in rate limiting - now requires explicit trusted proxy configuration
|
||||
- Enabled explicit Twig autoescape (`'html'`) for XSS protection
|
||||
- Fixed unescaped status values in CSS class names in Twig templates
|
||||
|
||||
**Implementation Fixes:**
|
||||
|
||||
- Fixed response signing to use recursive key sorting for client library compatibility
|
||||
- ResponseSigner now recursively sorts nested array keys alphabetically as required by `magdev/wc-licensed-product-client`
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Api/ResponseSigner.php` - Added `recursiveKeySort()` method for proper signature generation
|
||||
- `src/Api/RestApiController.php` - Added trusted proxy support with `isTrustedProxy()`, `isCloudflareIp()`, `ipMatchesCidr()` methods
|
||||
- `src/Plugin.php` - Added explicit `autoescape => 'html'` to Twig environment
|
||||
- `src/Admin/AdminController.php` - Added nonce verification to `handleCsvExport()`, added `export_csv_url()` Twig function
|
||||
- `templates/frontend/licenses.html.twig` - Added `esc_attr()` for CSS class status
|
||||
- `templates/admin/licenses.html.twig` - Added `esc_attr()` for CSS class status, updated export link to use `export_csv_url()`
|
||||
|
||||
**Configuration:**
|
||||
|
||||
To enable trusted proxy support for rate limiting, add to `wp-config.php`:
|
||||
|
||||
```php
|
||||
// For Cloudflare
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
|
||||
|
||||
// Or for specific IPs/CIDR ranges
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
|
||||
```
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Rate limiting now only trusts proxy headers (`HTTP_CF_CONNECTING_IP`, `HTTP_X_FORWARDED_FOR`, `HTTP_X_REAL_IP`) when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
|
||||
- Without trusted proxy configuration, rate limiting uses `REMOTE_ADDR` only (prevents IP spoofing)
|
||||
- Cloudflare IP ranges are hardcoded for convenience (as of 2024)
|
||||
- CIDR notation supported for custom proxy ranges
|
||||
- Recursive key sorting ensures signature compatibility with SecureLicenseClient
|
||||
- References: <https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/raw/branch/main/docs/server-implementation.md>
|
||||
|
||||
**Release v0.3.6:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.6.zip` (818 KB)
|
||||
- SHA256: `b0063f0312759f090e12faba83de730baf4114139d763e46fad2b781d4b38270`
|
||||
- Tagged as `v0.3.6` and pushed to `main` branch
|
||||
|
||||
### 2026-01-24 - Version 0.3.7 - Dashboard Improvements & Download Counter
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed dashboard widget bugs, improved UI consistency, and added download tracking functionality with a new statistics widget.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Fixed: Dashboard widget "View All Licenses" link used wrong page slug (`wc-licensed-product-licenses` instead of `wc-licenses`)
|
||||
- Fixed: Download links in customer account resulted in 404 errors due to missing query var registration
|
||||
- Added `license-download` endpoint registration during plugin activation in `Installer::activate()`
|
||||
- Added `addDownloadQueryVar()` method to `DownloadController` for proper WordPress endpoint recognition
|
||||
|
||||
**UI Improvements:**
|
||||
|
||||
- Removed redundant "Status Breakdown" section from license statistics widget (info already shown in stat cards above)
|
||||
- Changed License Types section to use card-style layout matching the stats row above
|
||||
- Cleaned up unused CSS for status badges
|
||||
|
||||
**New Features:**
|
||||
|
||||
- Download counter for licensed product versions (tracked per version in database)
|
||||
- New Download Statistics admin dashboard widget showing:
|
||||
- Total downloads count
|
||||
- Top 5 products by downloads
|
||||
- Top 5 versions by downloads
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Admin/DownloadWidgetController.php` - Dashboard widget for download statistics
|
||||
|
||||
**New methods in VersionManager:**
|
||||
|
||||
- `incrementDownloadCount()` - Atomically increment download count for a version
|
||||
- `getTotalDownloadCount()` - Get total downloads across all versions
|
||||
- `getDownloadStatistics()` - Get download stats grouped by product and version
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Installer.php` - Added `download_count` column to versions table, added `license-download` endpoint registration
|
||||
- `src/Product/ProductVersion.php` - Added `downloadCount` property and `getDownloadCount()` method
|
||||
- `src/Product/VersionManager.php` - Added download counting methods
|
||||
- `src/Frontend/DownloadController.php` - Added query var registration, increment download count on file serve
|
||||
- `src/Admin/DashboardWidgetController.php` - Fixed URL, removed Status Breakdown, changed License Types to cards
|
||||
- `src/Plugin.php` - Added DownloadWidgetController instantiation
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Download count is incremented atomically using SQL `download_count = download_count + 1`
|
||||
- Statistics queries use SQL aggregation with product name enrichment via `wc_get_product()`
|
||||
- WordPress endpoints require both `add_rewrite_endpoint()` AND `query_vars` filter registration
|
||||
- Existing installations need to flush rewrite rules (Settings > Permalinks > Save) or reactivate plugin
|
||||
|
||||
**Release v0.3.7:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.7.zip` (827 KB)
|
||||
- SHA256: `e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6`
|
||||
- Tagged as `v0.3.7` and pushed to `main` branch
|
||||
|
||||
### 2026-01-24 - Version 0.3.8 - Translation Bug Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed a critical translation bug that caused the settings page to crash with an `ArgumentCountError`.
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- Fixed: Duplicate German translation string in `wc-licensed-product-de_CH.po` causing `ArgumentCountError` in settings page
|
||||
- Root cause: The notification settings description was duplicated in the translation, resulting in two `%s` placeholders when only one argument was passed to `sprintf()`
|
||||
- Location: [wc-licensed-product-de_CH.po:322-328](languages/wc-licensed-product-de_CH.po#L322-L328)
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `languages/wc-licensed-product-de_CH.po` - Removed duplicated translation string
|
||||
- `languages/wc-licensed-product-de_CH.mo` - Recompiled binary translation
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Error was logged to `tmp/fatal-errors-2026-01-24.log`
|
||||
- The German `msgstr` contained the same text twice, each with a `%s` placeholder
|
||||
- `sprintf()` at `SettingsController.php:221` only provided one argument for the single `%s` in the English source
|
||||
- Translation strings with `%s` placeholders must have exactly matching placeholder counts between source and translation
|
||||
|
||||
**Dependency Updates:**
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` from `9f513a8` to `64d215c`
|
||||
|
||||
**Release v0.3.8:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.8.zip` (829 KB)
|
||||
- SHA256: `50ad6966c5ab8db2257572084d2d8a820448df62615678e1576696f2c0cb383d`
|
||||
- Tagged as `v0.3.8` and pushed to `main` branch
|
||||
|
||||
### 2026-01-24 - Version 0.3.9 - Admin Order License Generation Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed a critical bug where licenses were not generated for orders created manually in the WordPress admin area.
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- **Critical:** Licenses are now generated for orders created manually in admin area
|
||||
- Previously, licenses were only generated via checkout hooks (`woocommerce_order_status_completed`, `woocommerce_order_status_processing`, `woocommerce_payment_complete`)
|
||||
- Admin-created orders bypassed checkout, so the `_licensed_product_domain` meta was never set and licenses were never generated
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- "Generate Licenses" button in order meta box for admin-created orders
|
||||
- "Generate Missing Licenses" button when some products in an order already have licenses
|
||||
- Warning message when order domain is not set before generating licenses
|
||||
- AJAX handler `ajaxGenerateOrderLicenses()` for manual license generation
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/OrderLicenseController.php` - Added Generate button, AJAX handler, CSS styles
|
||||
- `assets/js/order-licenses.js` - Added `generateLicenses()` function with page reload on success
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Button only appears when order is paid and domain is set
|
||||
- Uses existing `LicenseManager::generateLicense()` which handles duplicate prevention
|
||||
- Page reloads after successful generation to show new licenses in table
|
||||
- Tracks generated vs skipped licenses for accurate feedback messages
|
||||
- Updated translations (365 strings)
|
||||
|
||||
**Release v0.3.9:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.3.9.zip` (851 KB)
|
||||
- SHA256: `fdb65200c368da380df0cabb3c6ac6419d5b4731cd528f630f9b432a3ba5c586`
|
||||
- Tagged as `v0.3.9` and pushed to `main` branch
|
||||
|
||||
### 2026-01-24 - Version 0.4.0 - Self-Licensing Prevention
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added self-licensing prevention to avoid circular dependency when the plugin tries to validate its license against itself.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Self-licensing detection: Plugin automatically bypasses license validation when the configured license server URL points to the same WordPress installation
|
||||
- New `isSelfLicensing()` method in `PluginLicenseChecker` to detect circular licensing scenarios
|
||||
- New `normalizeDomain()` helper method for domain comparison (strips www prefix, lowercases)
|
||||
- Cache property `$isSelfLicensingCached` for efficient repeated checks
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/License/PluginLicenseChecker.php` - Added self-licensing detection methods and bypass logic
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Self-licensing detection compares normalized domains of license server URL and current site URL
|
||||
- Prevents circular dependency where plugin would try to validate against itself
|
||||
- Plugins can only be validated against the original store from which they were obtained
|
||||
- Bypass check added to both `isLicenseValid()` and `validateLicense()` methods
|
||||
- Cache clearing via `clearCache()` also clears the self-licensing check cache
|
||||
|
||||
**Release v0.4.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.4.0.zip` (852 KB)
|
||||
- SHA256: `cf8769c861d77c327f178049d5fac0d4e47679cc1a1d35c5b613e4cd3fb8674f`
|
||||
- Tagged as `v0.4.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-25 - Version 0.5.0 - Multi-Domain Licensing
|
||||
|
||||
**Overview:**
|
||||
|
||||
Major feature release enabling customers to purchase multiple licenses for different domains in a single order. Each cart item quantity requires a unique domain at checkout.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Multi-domain licensing support with new setting "Enable Multi-Domain Licensing"
|
||||
- Multi-domain checkout UI for both classic checkout and WooCommerce Blocks
|
||||
- Grouped license display in customer account page by product/order (package view)
|
||||
- "Older versions" collapsible section in customer download area
|
||||
- Updated email templates to show licenses grouped by product
|
||||
- DOM injection fallback for WooCommerce Blocks when React component fails
|
||||
|
||||
**New Setting:**
|
||||
|
||||
- `wclp_enable_multi_domain` - Enable/disable multi-domain licensing mode
|
||||
|
||||
**New Order Meta:**
|
||||
|
||||
- `_licensed_product_domains` - Array of domain data for multi-domain orders:
|
||||
|
||||
```php
|
||||
[
|
||||
['product_id' => 123, 'domains' => ['site1.com', 'site2.com']],
|
||||
['product_id' => 456, 'domains' => ['another.com']],
|
||||
]
|
||||
```
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/SettingsController.php` - Added multi-domain setting
|
||||
- `src/Checkout/CheckoutController.php` - Multi-domain field rendering and validation
|
||||
- `src/Checkout/CheckoutBlocksIntegration.php` - WooCommerce Blocks multi-domain support
|
||||
- `src/Checkout/StoreApiExtension.php` - Multi-domain data handling in Store API
|
||||
- `src/Frontend/AccountController.php` - Grouped license display by product
|
||||
- `src/Email/LicenseEmailController.php` - Grouped license email templates
|
||||
- `src/Plugin.php` - Multi-domain license generation
|
||||
- `src/License/LicenseManager.php` - Multi-domain license creation
|
||||
- `src/Admin/OrderLicenseController.php` - Multi-domain order display
|
||||
- `assets/js/checkout-blocks.js` - Complete rewrite for ExperimentalOrderMeta slot
|
||||
- `assets/js/frontend.js` - Older versions toggle functionality
|
||||
- `assets/css/frontend.css` - Package-based layout styles
|
||||
- `templates/frontend/licenses.html.twig` - Grouped license template
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- WooCommerce Blocks integration uses `ExperimentalOrderMeta` slot with `registerPlugin`
|
||||
- DOM injection fallback activates after 2 seconds if React component fails to render
|
||||
- Multi-domain validation ensures unique domains per product
|
||||
- Backward compatible: existing single-domain orders continue to work
|
||||
- New `getLicensesByOrderAndProduct()` method returns all licenses for a product in an order
|
||||
- Customer account groups licenses by product for package-style display
|
||||
- Email templates show licenses in table format grouped by product
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- Fixed: Domain fields not rendering in WooCommerce Blocks checkout
|
||||
- Root cause: `registerCheckoutBlock` approach requires manual block editor configuration
|
||||
- Fix: Switched to `ExperimentalOrderMeta` slot pattern with `registerPlugin` + DOM injection fallback
|
||||
|
||||
**Translation Updates:**
|
||||
|
||||
- Added 19 new strings for multi-domain functionality
|
||||
- Fixed all fuzzy translations in German (de_CH)
|
||||
- Updated .pot template and compiled .mo files
|
||||
|
||||
**Release v0.5.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.0.zip` (863 KB)
|
||||
- SHA256: `446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316`
|
||||
- Tagged as `v0.5.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-26 - Version 0.5.1 - Admin UI Fixes
|
||||
|
||||
**Overview:**
|
||||
|
||||
Bug fix release improving admin UI usability for version management and license overview.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Fixed: Product versions in admin now sort by version DESC when adding via AJAX
|
||||
- Fixed: License actions in admin overview are now always visible (not just on hover)
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `assets/css/admin.css` - Added `!important` to `.licenses-table .row-actions` for permanent visibility
|
||||
- `assets/js/versions.js` - Added `compareVersions()` function and sorted insertion for AJAX-added versions
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Version sorting uses semantic version comparison (major.minor.patch)
|
||||
- New versions are inserted in correct sorted position in the table instead of always appending
|
||||
- CSS override uses `!important` to overcome WordPress default hover-only behavior for row actions
|
||||
- `compareVersions()` function compares version strings numerically (1.10.0 > 1.9.0)
|
||||
|
||||
**Release v0.5.1:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.1.zip` (863 KB)
|
||||
- SHA256: `a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556`
|
||||
- Tagged as `v0.5.1` and pushed to `main` branch
|
||||
|
||||
### 2026-01-26 - Version 0.5.2 - Per-License Customer Secrets
|
||||
|
||||
**Overview:**
|
||||
|
||||
Security enhancement release adding per-license customer secrets for API response verification. Each customer now receives a unique secret derived from their license key, eliminating the need to share a global server secret.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Per-license secret derivation using HKDF-like approach
|
||||
- Customer account UI showing API verification secret with collapsible section
|
||||
- Copy-to-clipboard functionality for customer secrets
|
||||
- Static helper methods in ResponseSigner for secret derivation
|
||||
|
||||
**New methods in ResponseSigner:**
|
||||
|
||||
- `deriveCustomerSecret()` - Static method to derive customer secret from license key and server secret
|
||||
- `getCustomerSecretForLicense()` - Static method to get customer secret using configured server secret
|
||||
- `isSigningEnabled()` - Static method to check if response signing is configured
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Api/ResponseSigner.php` - Added static methods for customer secret derivation
|
||||
- `src/Frontend/AccountController.php` - Added `signing_enabled` and `customer_secret` to template data
|
||||
- `templates/frontend/licenses.html.twig` - Added collapsible secret section with toggle and copy button
|
||||
- `assets/css/frontend.css` - Added styles for `.license-row-secret`, `.secret-toggle`, `.secret-content`
|
||||
- `assets/js/frontend.js` - Added `toggleSecret()` and `copySecret()` event handlers
|
||||
- `docs/server-implementation.md` - Added documentation for per-license secrets
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Secret derivation uses HKDF-like approach: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
|
||||
- Each license gets a unique 64-character hex secret
|
||||
- Secrets are only shown when `WC_LICENSE_SERVER_SECRET` is configured
|
||||
- Collapsible UI prevents accidental secret exposure
|
||||
- If server secret is rotated, all customer secrets change automatically
|
||||
|
||||
**Security improvement:**
|
||||
|
||||
- Customers no longer need access to the master `WC_LICENSE_SERVER_SECRET`
|
||||
- If one customer's secret is leaked, other customers are not affected
|
||||
- Each license key derives its own unique verification secret
|
||||
|
||||
**Release v0.5.2:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.2.zip` (845 KB)
|
||||
- SHA256: `2d61a78ac5ba0f1d115a6401e6dded5b872b18f5530027c371604cbd18e9e27c`
|
||||
- Tagged as `v0.5.2` and pushed to `main` branch
|
||||
|
||||
### 2026-01-26 - Version 0.5.3 - Variable Licensed Products
|
||||
|
||||
**Overview:**
|
||||
|
||||
Major feature release adding support for WooCommerce variable products. Customers can now purchase licenses with different durations (monthly, yearly, lifetime) as product variations.
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Product/LicensedVariableProduct.php` - Variable product class extending `WC_Product_Variable`
|
||||
- `src/Product/LicensedProductVariation.php` - Variation class with license settings
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- New `licensed-variable` product type for selling licenses with different durations
|
||||
- `LicensedVariableProduct` class extending WooCommerce variable products
|
||||
- `LicensedProductVariation` class for individual variation license settings
|
||||
- Variation-specific license duration fields in product edit page (days, max activations)
|
||||
- Duration labels (Monthly, Quarterly, Yearly, Lifetime) displayed in checkout
|
||||
- Variation ID tracking in order domain meta for proper license generation
|
||||
- WooCommerce Blocks checkout updated to handle variations with duration labels
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Product/LicensedProductType.php` - Added licensed-variable type registration, variation hooks
|
||||
- `src/License/LicenseManager.php` - Added `isLicensedProduct()` helper, variation support in `generateLicense()`
|
||||
- `src/Plugin.php` - Updated license generation to handle variations
|
||||
- `src/Checkout/CheckoutController.php` - Variation support in domain field rendering
|
||||
- `src/Checkout/CheckoutBlocksIntegration.php` - Variation data in blocks checkout
|
||||
- `src/Checkout/StoreApiExtension.php` - Variation ID in Store API schema
|
||||
- `assets/js/checkout-blocks.js` - Variation handling in React components and DOM fallback
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Variable product type shows in WooCommerce product type selector as "Licensed Variable Product"
|
||||
- Each variation can override parent's license duration and max activations
|
||||
- Variations are always virtual (licensed products don't ship)
|
||||
- `LicensedProductVariation::get_license_duration_label()` returns human-readable duration
|
||||
- Order meta `_licensed_product_domains` now includes optional `variation_id` field
|
||||
- License generation uses variation settings when `variation_id` is present in order item
|
||||
- Backward compatible: existing simple licensed products continue to work unchanged
|
||||
|
||||
### 2026-01-26 - Version 0.5.4 - API Compliance
|
||||
|
||||
**Overview:**
|
||||
|
||||
Bug fix release aligning server implementation with client documentation at `magdev/wc-licensed-product-client`.
|
||||
|
||||
**Fixed:**
|
||||
|
||||
- `/validate` endpoint now returns HTTP 404 for `license_not_found` error (was returning 403)
|
||||
- License key validation now enforces minimum 8 characters across all API endpoints
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Configurable rate limiting via `WC_LICENSE_RATE_LIMIT` constant (default: 30 requests)
|
||||
- Configurable rate window via `WC_LICENSE_RATE_WINDOW` constant (default: 60 seconds)
|
||||
- HTTP status code mapping: 404 for not found, 500 for server errors, 403 for all other errors
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Api/RestApiController.php` - Added configurable rate limiting, fixed HTTP status codes, added license_key validation
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Rate limiting now uses `getRateLimit()` and `getRateWindow()` methods instead of constants
|
||||
- New `getStatusCodeForResult()` method maps error codes to HTTP status codes
|
||||
- License key validation callback added to all three endpoints (validate, status, activate)
|
||||
- Uses PHP 8 match expression for status code mapping
|
||||
|
||||
### 2026-01-26 - Version 0.5.5 - Critical Signature Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Critical bug fix for response signing. The key derivation algorithm was incompatible with the client library, causing signature verification failures.
|
||||
|
||||
**Critical Fix:**
|
||||
|
||||
- Key derivation now uses PHP's native `hash_hkdf()` function per RFC 5869
|
||||
- Previous custom implementation produced different keys than the client library
|
||||
- Signatures now verify correctly with `magdev/wc-licensed-product-client`
|
||||
|
||||
**Additional Fix:**
|
||||
|
||||
- Added missing domain validation to `/activate` endpoint (1-255 characters)
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Api/ResponseSigner.php` - Fixed key derivation to use `hash_hkdf()`
|
||||
- `src/Api/RestApiController.php` - Added domain validation to `/activate` endpoint
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Old implementation: `hash_hmac('sha256', $prk . "\x01", $serverSecret)` - custom HKDF-like
|
||||
- New implementation: `bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey))` - RFC 5869
|
||||
- Parameters match client's `ResponseSignature::deriveKey()` exactly:
|
||||
- IKM (input keying material): server_secret
|
||||
- Length: 32 bytes (256 bits)
|
||||
- Info: license_key (context-specific info)
|
||||
- **Breaking change for existing signatures** - customer secrets will change after upgrade
|
||||
|
||||
### 2026-01-27 - Version 0.5.6 - License Settings Tab Visibility Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed License Settings tab visibility for non-licensed product types and updated README with v0.5.x features.
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- License Settings tab now only shows for Licensed Product and Licensed Variable Product types
|
||||
- Previously the tab was visible on all product types due to CSS `!important` override forcing `display: block`
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `assets/css/admin.css` - Changed from `display: block !important` to `display: none` for `.show_if_licensed` and `.show_if_licensed-variable`
|
||||
- `src/Product/LicensedProductType.php` - Added consolidated `toggleLicensedProductOptions()` JavaScript function
|
||||
- `README.md` - Updated with complete feature documentation for v0.5.x features
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- CSS now hides License Settings tab by default
|
||||
- JavaScript `toggleLicensedProductOptions()` function shows/hides tab based on product type selector
|
||||
- Function is called both on page load and on product type change
|
||||
- README updated with: Variable Licensed Products, Multi-Domain Licensing, Per-License Secrets, Download Statistics, Configurable Rate Limiting
|
||||
|
||||
**Release v0.5.6:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.6.zip` (1.1 MB)
|
||||
- SHA256: `4d35a319fe4cb4e7055bae17fc030487ca05e5e9ac905f76d0ac62002bde4336`
|
||||
- Tagged as `v0.5.6` and pushed to `main` branch
|
||||
|
||||
### 2026-01-27 - Version 0.5.7 - Settings UI Cleanup
|
||||
|
||||
**Overview:**
|
||||
|
||||
Removed redundant "Default" prefix from setting labels on the Default Settings page for cleaner UI.
|
||||
|
||||
**Changed:**
|
||||
|
||||
- "Max Activations" (was "Default Max Activations")
|
||||
- "License Validity (Days)" (was "Default License Validity (Days)")
|
||||
- "Bind to Major Version" (was "Default Bind to Major Version")
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/SettingsController.php` - Removed "Default" prefix from three setting labels
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Labels are cleaner since the page section itself is already named "Default Settings"
|
||||
- No functional changes, purely UI improvement
|
||||
- Updated all translations (388 strings)
|
||||
|
||||
**Release v0.5.7:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.7.zip` (856 KB)
|
||||
- SHA256: `ceb4d57598f576f4f172153ff80df8c180ecd4dca873cf109327fc5ac718930f`
|
||||
- Tagged as `v0.5.7` and pushed to `main` branch
|
||||
|
||||
130
README.md
130
README.md
@@ -11,25 +11,34 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
### Core Features
|
||||
|
||||
- **Licensed Product Type**: New WooCommerce product type for software sales
|
||||
- **Variable Licensed Products**: Create product variations with different license durations (monthly, yearly, lifetime)
|
||||
- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
|
||||
- **Domain Binding**: Licenses are bound to customer-specified domains
|
||||
- **Multi-Domain Licensing**: Customers can purchase multiple licenses for different domains in a single order
|
||||
- **REST API**: Public endpoints for license validation and management
|
||||
- **Response Signing**: Optional HMAC-SHA256 cryptographic signatures for API responses
|
||||
- **Per-License Secrets**: Each customer receives a unique verification secret for their license
|
||||
- **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)
|
||||
- **Rate Limiting**: API endpoints protected with configurable rate limiting (default: 30 requests/minute)
|
||||
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
|
||||
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
|
||||
- **Self-Licensing**: The plugin can validate its own license (for commercial distribution)
|
||||
|
||||
### Customer Features
|
||||
|
||||
- **My Account Licenses**: Customers can view their licenses in My Account
|
||||
- **My Account Licenses**: Customers can view their licenses in My Account (grouped by product)
|
||||
- **License Transfers**: Customers can transfer licenses to new domains
|
||||
- **Secure Downloads**: Download purchased software versions with license verification
|
||||
- **Version History**: Access to older versions with collapsible download section
|
||||
- **Copy to Clipboard**: Easy license key copying
|
||||
- **API Verification Secret**: Per-license secret displayed for secure API integration
|
||||
|
||||
### Admin Features
|
||||
|
||||
- **License Management**: Full CRUD interface for license management
|
||||
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
|
||||
- **Dashboard Widgets**: License statistics and download statistics on WordPress admin dashboard
|
||||
- **Search & Filtering**: Search by license key, domain, status, or product
|
||||
- **Live Search**: AJAX-powered instant search results
|
||||
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
|
||||
@@ -37,8 +46,13 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
- **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
|
||||
- **Generate Licenses**: Manually generate licenses for admin-created orders
|
||||
- **Expiration Warnings**: Automatic email notifications before license expiration
|
||||
- **Auto-Expire**: Daily cron job automatically expires licenses past their expiration date
|
||||
- **License Testing**: Test licenses against the API directly from admin interface
|
||||
- **Version Management**: Manage multiple versions per product with file attachments
|
||||
- **Download Tracking**: Track download counts per version with statistics widget
|
||||
- **SHA256 Checksums**: File integrity verification with SHA256 hash display
|
||||
- **Global Settings**: Default license settings via WooCommerce settings tab
|
||||
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
|
||||
|
||||
@@ -59,13 +73,27 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
### Creating a Licensed Product
|
||||
|
||||
1. Go to Products > Add New
|
||||
2. Select "Licensed Product" from the product type dropdown
|
||||
2. Select "Licensed Product" from the product type dropdown (or "Licensed Variable Product" for different license durations)
|
||||
3. Configure the product price in the General tab
|
||||
4. Set license options in the "License Settings" tab:
|
||||
- **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
|
||||
|
||||
### Creating Variable Licensed Products
|
||||
|
||||
For selling licenses with different durations (monthly, yearly, lifetime):
|
||||
|
||||
1. Go to Products > Add New
|
||||
2. Select "Licensed Variable Product" from the product type dropdown
|
||||
3. Create variations as you would for any variable product (e.g., by "License Duration")
|
||||
4. For each variation, set:
|
||||
- **Variation Price**: Different prices for different durations
|
||||
- **License Duration (Days)**: Days until expiration (0 = lifetime)
|
||||
- **Max Activations**: Override parent product setting if needed
|
||||
|
||||
Duration labels (Monthly, Yearly, Lifetime) are automatically displayed at checkout.
|
||||
|
||||
### Managing Product Versions
|
||||
|
||||
1. Edit a Licensed Product
|
||||
@@ -77,7 +105,8 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
|
||||
1. Go to WooCommerce > Settings > Licensed Products
|
||||
2. Set default values for Max Activations, License Validity, and Version Binding
|
||||
3. Per-product settings override these defaults
|
||||
3. Enable Multi-Domain Licensing to allow multiple licenses per cart item
|
||||
4. Per-product settings override these defaults
|
||||
|
||||
### Customer Checkout
|
||||
|
||||
@@ -103,16 +132,100 @@ When a customer purchases a licensed product, they must enter the domain where t
|
||||
3. Upload a CSV file (supports exported format or simplified format)
|
||||
4. Choose options: skip header row, update existing licenses
|
||||
|
||||
## Security
|
||||
|
||||
The plugin implements several security best practices:
|
||||
|
||||
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
||||
- **Output Escaping**: All output is escaped to prevent XSS attacks
|
||||
- **CSRF Protection**: Nonce verification on all forms and AJAX requests
|
||||
- **SQL Injection Prevention**: All database queries use prepared statements
|
||||
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
||||
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
||||
- **Response Signing**: Optional HMAC-SHA256 signatures for API tamper protection
|
||||
|
||||
### Trusted Proxy Configuration
|
||||
|
||||
If your server is behind a load balancer, reverse proxy, or CDN (like Cloudflare), you need to configure trusted proxies for accurate rate limiting. Without this, the rate limiter uses the direct connection IP which may be your proxy's IP.
|
||||
|
||||
**Configuration (wp-config.php):**
|
||||
|
||||
```php
|
||||
// For Cloudflare (includes all Cloudflare IP ranges)
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
|
||||
|
||||
// For specific proxy IPs
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
|
||||
|
||||
// For CIDR ranges
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.0/8,192.168.1.0/24');
|
||||
|
||||
// Combine multiple methods
|
||||
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE,10.0.0.1');
|
||||
```
|
||||
|
||||
**Note**: Only configure trusted proxies if you actually use them. Without this configuration, rate limiting is more secure against IP spoofing attacks.
|
||||
|
||||
### Configurable Rate Limiting
|
||||
|
||||
The default rate limit is 30 requests per 60 seconds. You can customize this:
|
||||
|
||||
```php
|
||||
// Requests allowed per window (default: 30)
|
||||
define('WC_LICENSE_RATE_LIMIT', 60);
|
||||
|
||||
// Window duration in seconds (default: 60)
|
||||
define('WC_LICENSE_RATE_WINDOW', 120);
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
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');
|
||||
```
|
||||
|
||||
Generate a secure secret using:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
**Per-License Customer Secrets**: Each customer receives a unique verification secret derived from their license key. This secret is displayed in their account page under "API Verification Secret" and can be used with the client library instead of sharing the master server secret.
|
||||
|
||||
### 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))
|
||||
@@ -226,11 +339,12 @@ Content-Type: application/json
|
||||
|
||||
## Email Notifications
|
||||
|
||||
The plugin sends automatic email notifications:
|
||||
The plugin sends automatic email notifications (configurable via WooCommerce > Settings > Emails):
|
||||
|
||||
- **Order Completion**: License keys included in order confirmation emails
|
||||
- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration
|
||||
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
|
||||
- **License Expired**: Notification when a license auto-expires
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
@@ -43,9 +43,17 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* License Product Tab */
|
||||
#woocommerce-product-data .show_if_licensed {
|
||||
display: block !important;
|
||||
/* File Hash */
|
||||
code.file-hash {
|
||||
cursor: help;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* License Product Tab - Hidden by default, shown via JS based on product type */
|
||||
#woocommerce-product-data .show_if_licensed,
|
||||
#woocommerce-product-data .show_if_licensed-variable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#woocommerce-product-data .hide_if_licensed {
|
||||
@@ -160,6 +168,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;
|
||||
@@ -181,7 +202,8 @@
|
||||
}
|
||||
|
||||
.licenses-table .row-actions {
|
||||
visibility: visible;
|
||||
visibility: visible !important;
|
||||
position: static !important;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,196 @@
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
/* License Cards */
|
||||
/* License Packages */
|
||||
.woocommerce-licenses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5em;
|
||||
}
|
||||
|
||||
.license-package {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.package-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1em 1.5em;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.package-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.package-title h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.package-title h3 a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.package-title h3 a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.package-order {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.package-order a {
|
||||
color: #2271b1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.package-order a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.package-license-count {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
background: #e9ecef;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Package Licenses - Two Row Layout */
|
||||
.package-licenses {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.license-entry {
|
||||
padding: 1em 1.5em;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.license-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.license-entry:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.license-row-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.license-key-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.license-entry code.license-key {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.4em 0.75em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95em;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.license-key-group .license-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.license-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.license-row-secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5em;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.license-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
}
|
||||
|
||||
.license-meta-item .dashicons {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.license-domain {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.license-expiry .lifetime {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Legacy table styles (kept for backwards compatibility) */
|
||||
.licenses-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.licenses-table th,
|
||||
.licenses-table td {
|
||||
padding: 0.75em 1em;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.licenses-table th {
|
||||
font-weight: 600;
|
||||
background-color: #fafafa;
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.licenses-table code.license-key {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.3em 0.6em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.licenses-table .lifetime {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Legacy single card styles (kept for backwards compatibility) */
|
||||
.license-card {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
@@ -184,12 +367,14 @@
|
||||
}
|
||||
|
||||
/* Download Section */
|
||||
.package-downloads,
|
||||
.license-downloads {
|
||||
padding: 1em 1.5em;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.package-downloads h4,
|
||||
.license-downloads h4 {
|
||||
margin: 0 0 0.75em 0;
|
||||
font-size: 0.95em;
|
||||
@@ -202,18 +387,30 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.download-list li {
|
||||
.download-list li.download-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding: 0.5em 0;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
padding: 0.75em 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.download-list li:last-child {
|
||||
.download-list li.download-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.download-row-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.download-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -244,7 +441,95 @@
|
||||
.download-date {
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Latest version badge */
|
||||
.download-version-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Older versions collapsible */
|
||||
.older-versions-section {
|
||||
margin-top: 0.75em;
|
||||
padding-top: 0.75em;
|
||||
border-top: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
.older-versions-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
padding: 0.4em 0.75em;
|
||||
background: transparent;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.older-versions-toggle:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.older-versions-toggle .dashicons {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.older-versions-toggle[aria-expanded="true"] .dashicons {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.older-versions-list {
|
||||
margin-top: 0.75em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.older-versions-list .download-item {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.older-versions-list .download-item:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Domain Field */
|
||||
@@ -298,6 +583,52 @@
|
||||
|
||||
/* Responsive */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* Package header responsive */
|
||||
.package-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.package-license-count {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* License entry responsive */
|
||||
.license-entry {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.license-row-primary {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.license-key-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.license-entry code.license-key {
|
||||
font-size: 0.85em;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.license-actions {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.license-row-secondary {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
/* Legacy card layout responsive */
|
||||
.license-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -314,42 +645,49 @@
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.download-list li {
|
||||
.download-row-meta {
|
||||
padding-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.download-date {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Legacy table responsive */
|
||||
.woocommerce-licenses-table,
|
||||
.woocommerce-licenses-table thead,
|
||||
.woocommerce-licenses-table tbody,
|
||||
.woocommerce-licenses-table th,
|
||||
.woocommerce-licenses-table td,
|
||||
.woocommerce-licenses-table tr {
|
||||
.woocommerce-licenses-table tr,
|
||||
.licenses-table,
|
||||
.licenses-table thead,
|
||||
.licenses-table tbody,
|
||||
.licenses-table th,
|
||||
.licenses-table td,
|
||||
.licenses-table tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.woocommerce-licenses-table thead tr {
|
||||
.woocommerce-licenses-table thead tr,
|
||||
.licenses-table thead tr {
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
.woocommerce-licenses-table tr {
|
||||
.woocommerce-licenses-table tr,
|
||||
.licenses-table tr {
|
||||
border: 1px solid #e5e5e5;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.woocommerce-licenses-table td {
|
||||
.woocommerce-licenses-table td,
|
||||
.licenses-table td {
|
||||
border: none;
|
||||
position: relative;
|
||||
padding-left: 50%;
|
||||
}
|
||||
|
||||
.woocommerce-licenses-table td:before {
|
||||
.woocommerce-licenses-table td:before,
|
||||
.licenses-table td:before {
|
||||
content: attr(data-title);
|
||||
position: absolute;
|
||||
left: 0.75em;
|
||||
@@ -504,3 +842,139 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* Customer Secret Section */
|
||||
.license-row-secret {
|
||||
margin-top: 0.75em;
|
||||
padding-top: 0.75em;
|
||||
border-top: 1px dashed #e5e5e5;
|
||||
}
|
||||
|
||||
.secret-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
padding: 0.4em 0.75em;
|
||||
background: transparent;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.secret-toggle:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.secret-toggle .dashicons {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.secret-toggle .toggle-arrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.secret-toggle[aria-expanded="true"] .toggle-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.secret-content {
|
||||
margin-top: 0.75em;
|
||||
padding: 1em;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.secret-description {
|
||||
margin: 0 0 0.75em 0;
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.secret-value-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.secret-value {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: 0.75em;
|
||||
background: #fff;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.copy-secret-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-secret-btn:hover {
|
||||
background: #e5e5e5;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.copy-secret-btn .dashicons {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.secret-value-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.secret-value {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.copy-secret-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* WooCommerce Checkout Blocks Integration
|
||||
*
|
||||
* Adds a domain field to the checkout block for licensed products.
|
||||
* Adds domain fields to the checkout block for licensed products.
|
||||
* Supports single domain mode (legacy) and multi-domain mode (per quantity).
|
||||
*
|
||||
* @package WcLicensedProduct
|
||||
*/
|
||||
@@ -9,92 +10,375 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { registerCheckoutBlock } = wc.blocksCheckout;
|
||||
const { createElement, useState, useEffect } = wp.element;
|
||||
// Check dependencies
|
||||
if (typeof wc === 'undefined' ||
|
||||
typeof wc.blocksCheckout === 'undefined' ||
|
||||
typeof wc.wcSettings === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSetting } = wc.wcSettings;
|
||||
const { createElement, useState } = wp.element;
|
||||
const { TextControl } = wp.components;
|
||||
const { __ } = wp.i18n;
|
||||
const { extensionCartUpdate } = wc.blocksCheckout;
|
||||
const { getSetting } = wc.wcSettings;
|
||||
|
||||
// Get settings passed from PHP
|
||||
// Get available exports from blocksCheckout
|
||||
const { ExperimentalOrderMeta } = wc.blocksCheckout;
|
||||
|
||||
// Get settings from PHP
|
||||
const settings = getSetting('wc-licensed-product_data', {});
|
||||
|
||||
// Check if we have licensed products
|
||||
if (!settings.hasLicensedProducts) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain format
|
||||
*/
|
||||
function isValidDomain(domain) {
|
||||
if (!domain || domain.length > 255) {
|
||||
return false;
|
||||
}
|
||||
if (!domain || domain.length > 255) return false;
|
||||
const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
return pattern.test(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize domain (remove protocol and www)
|
||||
* Normalize domain
|
||||
*/
|
||||
function normalizeDomain(domain) {
|
||||
let normalized = domain.toLowerCase().trim();
|
||||
normalized = normalized.replace(/^https?:\/\//, '');
|
||||
normalized = normalized.replace(/^www\./, '');
|
||||
normalized = normalized.replace(/\/.*$/, '');
|
||||
return normalized;
|
||||
return domain.toLowerCase().trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/.*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* License Domain Block Component
|
||||
* Single Domain Component
|
||||
*/
|
||||
const LicenseDomainBlock = ({ checkoutExtensionData, extensions }) => {
|
||||
const SingleDomainField = () => {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { setExtensionData } = checkoutExtensionData;
|
||||
|
||||
// Only show if cart has licensed products
|
||||
if (!settings.hasLicensedProducts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (value) => {
|
||||
const normalized = normalizeDomain(value);
|
||||
setDomain(normalized);
|
||||
|
||||
// Validate
|
||||
if (normalized && !isValidDomain(normalized)) {
|
||||
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
|
||||
// Update extension data for server-side processing
|
||||
setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized);
|
||||
// Store in hidden input for form submission
|
||||
const hiddenInput = document.getElementById('wclp-domain-hidden');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = normalized;
|
||||
}
|
||||
};
|
||||
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: 'wc-block-components-licensed-product-domain' },
|
||||
createElement(
|
||||
'h3',
|
||||
{ className: 'wc-block-components-title' },
|
||||
{
|
||||
className: 'wc-block-components-licensed-product-domain',
|
||||
style: {
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '16px',
|
||||
}
|
||||
},
|
||||
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
|
||||
settings.sectionTitle || __('License Domain', 'wc-licensed-product')
|
||||
),
|
||||
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
|
||||
settings.fieldDescription || __('Enter the domain where you will use the license.', 'wc-licensed-product')
|
||||
),
|
||||
createElement(TextControl, {
|
||||
label: settings.fieldLabel || __('Domain for License Activation', 'wc-licensed-product'),
|
||||
label: settings.singleDomainLabel || __('Domain', 'wc-licensed-product'),
|
||||
value: domain,
|
||||
onChange: handleChange,
|
||||
placeholder: settings.fieldPlaceholder || 'example.com',
|
||||
help: error || settings.fieldDescription || __('Enter the domain where you will use this license.', 'wc-licensed-product'),
|
||||
help: error || '',
|
||||
className: error ? 'has-error' : '',
|
||||
required: true,
|
||||
}),
|
||||
createElement('input', {
|
||||
type: 'hidden',
|
||||
id: 'wclp-domain-hidden',
|
||||
name: 'wclp_license_domain',
|
||||
value: domain,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Register the checkout block
|
||||
registerCheckoutBlock({
|
||||
metadata: {
|
||||
name: 'wc-licensed-product/domain-field',
|
||||
parent: ['woocommerce/checkout-contact-information-block'],
|
||||
},
|
||||
component: LicenseDomainBlock,
|
||||
/**
|
||||
* Get unique key for product (handles variations)
|
||||
*/
|
||||
function getProductKey(product) {
|
||||
if (product.variation_id && product.variation_id > 0) {
|
||||
return `${product.product_id}_${product.variation_id}`;
|
||||
}
|
||||
return String(product.product_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-Domain Component
|
||||
*/
|
||||
const MultiDomainFields = () => {
|
||||
const products = settings.licensedProducts || [];
|
||||
const [domains, setDomains] = useState(() => {
|
||||
const init = {};
|
||||
products.forEach(p => {
|
||||
const key = getProductKey(p);
|
||||
init[key] = Array(p.quantity).fill('');
|
||||
});
|
||||
return init;
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
if (!products.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (productKey, index, value) => {
|
||||
const normalized = normalizeDomain(value);
|
||||
const newDomains = { ...domains };
|
||||
if (!newDomains[productKey]) newDomains[productKey] = [];
|
||||
newDomains[productKey] = [...newDomains[productKey]];
|
||||
newDomains[productKey][index] = normalized;
|
||||
setDomains(newDomains);
|
||||
|
||||
// Validate
|
||||
const key = `${productKey}_${index}`;
|
||||
const newErrors = { ...errors };
|
||||
if (normalized && !isValidDomain(normalized)) {
|
||||
newErrors[key] = settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product');
|
||||
} else {
|
||||
delete newErrors[key];
|
||||
}
|
||||
|
||||
// Check for duplicates within same product/variation
|
||||
const productDomains = newDomains[productKey].filter(d => d);
|
||||
const uniqueDomains = new Set(productDomains.map(d => normalizeDomain(d)));
|
||||
if (productDomains.length !== uniqueDomains.size) {
|
||||
const seen = new Set();
|
||||
newDomains[productKey].forEach((d, idx) => {
|
||||
const normalizedD = normalizeDomain(d);
|
||||
const dupKey = `${productKey}_${idx}`;
|
||||
if (normalizedD && seen.has(normalizedD)) {
|
||||
newErrors[dupKey] = settings.duplicateError || __('Each license requires a unique domain.', 'wc-licensed-product');
|
||||
} else if (normalizedD) {
|
||||
seen.add(normalizedD);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
// Update hidden field with variation support
|
||||
const data = products.map(p => {
|
||||
const pKey = getProductKey(p);
|
||||
const doms = newDomains[pKey] || [];
|
||||
const entry = {
|
||||
product_id: p.product_id,
|
||||
domains: doms.filter(d => d),
|
||||
};
|
||||
if (p.variation_id && p.variation_id > 0) {
|
||||
entry.variation_id = p.variation_id;
|
||||
}
|
||||
return entry;
|
||||
}).filter(item => item.domains.length > 0);
|
||||
|
||||
const hiddenInput = document.getElementById('wclp-domains-hidden');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = JSON.stringify(data);
|
||||
}
|
||||
};
|
||||
|
||||
return createElement(
|
||||
'div',
|
||||
{
|
||||
className: 'wc-block-components-licensed-product-domains',
|
||||
style: {
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '16px',
|
||||
}
|
||||
},
|
||||
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
|
||||
settings.sectionTitle || __('License Domains', 'wc-licensed-product')
|
||||
),
|
||||
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
|
||||
settings.fieldDescription || __('Enter a unique domain for each license.', 'wc-licensed-product')
|
||||
),
|
||||
products.map(product => {
|
||||
const productKey = getProductKey(product);
|
||||
const durationLabel = product.duration_label || '';
|
||||
const displayName = durationLabel
|
||||
? `${product.name} (${durationLabel})`
|
||||
: product.name;
|
||||
|
||||
return createElement(
|
||||
'div',
|
||||
{
|
||||
key: productKey,
|
||||
style: {
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '4px',
|
||||
}
|
||||
},
|
||||
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
|
||||
displayName + (product.quantity > 1 ? ` ×${product.quantity}` : '')
|
||||
),
|
||||
Array.from({ length: product.quantity }, (_, i) => {
|
||||
const key = `${productKey}_${i}`;
|
||||
return createElement(
|
||||
'div',
|
||||
{ key: i, style: { marginBottom: '8px' } },
|
||||
createElement(TextControl, {
|
||||
label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1),
|
||||
value: domains[productKey]?.[i] || '',
|
||||
onChange: (val) => handleChange(productKey, i, val),
|
||||
placeholder: settings.fieldPlaceholder || 'example.com',
|
||||
help: errors[key] || '',
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
createElement('input', {
|
||||
type: 'hidden',
|
||||
id: 'wclp-domains-hidden',
|
||||
name: 'wclp_license_domains',
|
||||
value: '',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main License Domains Block
|
||||
*/
|
||||
const LicenseDomainsBlock = () => {
|
||||
if (settings.isMultiDomainEnabled) {
|
||||
return createElement(MultiDomainFields);
|
||||
}
|
||||
return createElement(SingleDomainField);
|
||||
};
|
||||
|
||||
// Register using ExperimentalOrderMeta slot
|
||||
if (ExperimentalOrderMeta) {
|
||||
const { registerPlugin } = wp.plugins || {};
|
||||
|
||||
if (registerPlugin) {
|
||||
registerPlugin('wc-licensed-product-domain-fields', {
|
||||
render: () => createElement(
|
||||
ExperimentalOrderMeta,
|
||||
{},
|
||||
createElement(LicenseDomainsBlock)
|
||||
),
|
||||
scope: 'woocommerce-checkout',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: inject into DOM directly if React approach fails
|
||||
setTimeout(function() {
|
||||
const existingComponent = document.querySelector('.wc-block-components-licensed-product-domain, .wc-block-components-licensed-product-domains');
|
||||
if (existingComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkoutForm = document.querySelector('.wc-block-checkout, .wc-block-checkout__form, form.checkout');
|
||||
if (!checkoutForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = document.querySelector('.wc-block-checkout__contact-fields, .wp-block-woocommerce-checkout-contact-information-block');
|
||||
const paymentMethods = document.querySelector('.wc-block-checkout__payment-method, .wp-block-woocommerce-checkout-payment-block');
|
||||
|
||||
let insertionPoint = contactInfo || paymentMethods;
|
||||
if (!insertionPoint) {
|
||||
insertionPoint = checkoutForm.querySelector('.wc-block-components-form');
|
||||
}
|
||||
|
||||
if (!insertionPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = 'wclp-domain-fields-container';
|
||||
container.className = 'wc-block-components-licensed-product-wrapper';
|
||||
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
|
||||
|
||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||
container.innerHTML = `
|
||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
|
||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||||
${settings.fieldDescription || 'Enter a unique domain for each license.'}
|
||||
</p>
|
||||
${settings.licensedProducts.map(product => {
|
||||
const productKey = product.variation_id && product.variation_id > 0
|
||||
? `${product.product_id}_${product.variation_id}`
|
||||
: product.product_id;
|
||||
const durationLabel = product.duration_label || '';
|
||||
const displayName = durationLabel
|
||||
? `${product.name} (${durationLabel})`
|
||||
: product.name;
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
||||
<strong style="display: block; margin-bottom: 8px;">
|
||||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
||||
</strong>
|
||||
${Array.from({ length: product.quantity }, (_, i) => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: block; margin-bottom: 4px;">
|
||||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_domains[${productKey}][${i}]"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
${product.variation_id && product.variation_id > 0 ? `
|
||||
<input type="hidden"
|
||||
name="licensed_variation_ids[${productKey}]"
|
||||
value="${product.variation_id}"
|
||||
/>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}).join('')}
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4>
|
||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||||
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
|
||||
</p>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: block; margin-bottom: 4px;">
|
||||
${settings.singleDomainLabel || 'Domain'}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_product_domain"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (contactInfo) {
|
||||
contactInfo.parentNode.insertBefore(container, contactInfo.nextSibling);
|
||||
} else if (paymentMethods) {
|
||||
paymentMethods.parentNode.insertBefore(container, paymentMethods);
|
||||
} else {
|
||||
insertionPoint.appendChild(container);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
})();
|
||||
|
||||
@@ -19,12 +19,19 @@
|
||||
|
||||
bindEvents: function() {
|
||||
$(document).on('click', '.copy-license-btn', this.copyLicenseKey);
|
||||
$(document).on('click', '.copy-secret-btn', this.copySecret);
|
||||
|
||||
// Transfer modal events
|
||||
$(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this));
|
||||
$(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this));
|
||||
$(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this));
|
||||
|
||||
// Older versions toggle
|
||||
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
|
||||
|
||||
// Secret toggle
|
||||
$(document).on('click', '.secret-toggle', this.toggleSecret);
|
||||
|
||||
// Close modal on escape key
|
||||
$(document).on('keyup', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -33,6 +40,61 @@
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle older versions visibility
|
||||
*/
|
||||
toggleOlderVersions: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $btn = $(this);
|
||||
var $list = $btn.siblings('.older-versions-list');
|
||||
var isExpanded = $btn.attr('aria-expanded') === 'true';
|
||||
|
||||
$btn.attr('aria-expanded', !isExpanded);
|
||||
$list.slideToggle(200);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle secret visibility
|
||||
*/
|
||||
toggleSecret: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $btn = $(this);
|
||||
var $content = $btn.siblings('.secret-content');
|
||||
var isExpanded = $btn.attr('aria-expanded') === 'true';
|
||||
|
||||
$btn.attr('aria-expanded', !isExpanded);
|
||||
$content.slideToggle(200);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy secret to clipboard
|
||||
*/
|
||||
copySecret: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $btn = $(this);
|
||||
var secret = $btn.data('secret');
|
||||
|
||||
if (!secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use modern clipboard API if available
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(secret)
|
||||
.then(function() {
|
||||
WCLicensedProductFrontend.showCopyFeedback($btn, true);
|
||||
})
|
||||
.catch(function() {
|
||||
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
|
||||
});
|
||||
} else {
|
||||
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy license key to clipboard
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
// Order domain save
|
||||
$('#wclp-save-order-domain').on('click', this.saveOrderDomain.bind(this));
|
||||
|
||||
// Generate licenses button
|
||||
$(document).on('click', '#wclp-generate-licenses', this.generateLicenses.bind(this));
|
||||
|
||||
// License domain edit/save/cancel
|
||||
$(document).on('click', '.wclp-edit-domain-btn', this.startEditDomain);
|
||||
$(document).on('click', '.wclp-save-domain-btn', this.saveLicenseDomain.bind(this));
|
||||
@@ -135,6 +138,54 @@
|
||||
$editBtn.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate licenses for order
|
||||
*/
|
||||
generateLicenses: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $btn = $(e.currentTarget);
|
||||
var $spinner = $btn.siblings('.spinner');
|
||||
var $status = $btn.siblings('.wclp-generate-status');
|
||||
|
||||
var orderId = $btn.data('order-id');
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
$spinner.addClass('is-active');
|
||||
$status.text('').removeClass('success error');
|
||||
|
||||
$.ajax({
|
||||
url: wclpOrderLicenses.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'wclp_generate_order_licenses',
|
||||
nonce: wclpOrderLicenses.nonce,
|
||||
order_id: orderId
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$status.text(response.data.message).addClass('success');
|
||||
if (response.data.reload) {
|
||||
// Reload the page after a short delay to show the new licenses
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
}
|
||||
} else {
|
||||
$status.text(response.data.message || wclpOrderLicenses.strings.error).addClass('error');
|
||||
$btn.prop('disabled', false);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$status.text(wclpOrderLicenses.strings.error).addClass('error');
|
||||
$btn.prop('disabled', false);
|
||||
},
|
||||
complete: function() {
|
||||
$spinner.removeClass('is-active');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Save license domain
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -101,9 +106,90 @@
|
||||
$('#selected_file_name').text('');
|
||||
$('#remove-version-file-btn').hide();
|
||||
|
||||
// Hide and clear SHA256 hash field
|
||||
// Hide and clear checksum file field
|
||||
$('#sha256-hash-row').hide();
|
||||
$('#new_file_hash').val('');
|
||||
$('#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);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* Returns: positive if a > b, negative if a < b, 0 if equal
|
||||
*/
|
||||
compareVersions: function(a, b) {
|
||||
var partsA = a.split('.').map(Number);
|
||||
var partsB = b.split('.').map(Number);
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var numA = partsA[i] || 0;
|
||||
var numB = partsB[i] || 0;
|
||||
if (numA !== numB) {
|
||||
return numA - numB;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -134,13 +220,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 releaseNotes = $('#new_release_notes').val().trim();
|
||||
var attachmentId = $('#new_attachment_id').val();
|
||||
var fileHash = $('#new_file_hash').val().trim();
|
||||
var checksumFile = $('#new_checksum_file')[0].files[0];
|
||||
|
||||
// Validate version
|
||||
if (!version) {
|
||||
@@ -156,6 +243,8 @@
|
||||
$btn.prop('disabled', true);
|
||||
$spinner.addClass('is-active');
|
||||
|
||||
// Read checksum file if provided, then submit
|
||||
self.readChecksumFile(checksumFile).then(function(fileHash) {
|
||||
$.ajax({
|
||||
url: wcLicensedProductVersions.ajaxUrl,
|
||||
type: 'POST',
|
||||
@@ -173,8 +262,23 @@
|
||||
// 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 in sorted position (by version DESC)
|
||||
var $newRow = $(response.data.html);
|
||||
var newVersion = (response.data.version && response.data.version.version) || version;
|
||||
var inserted = false;
|
||||
|
||||
$('#versions-table tbody tr').each(function() {
|
||||
var rowVersion = $(this).find('td:first strong').text();
|
||||
if (self.compareVersions(newVersion, rowVersion) > 0) {
|
||||
$newRow.insertBefore($(this));
|
||||
inserted = true;
|
||||
return false; // break
|
||||
}
|
||||
});
|
||||
|
||||
if (!inserted) {
|
||||
$('#versions-table tbody').append($newRow);
|
||||
}
|
||||
|
||||
// Clear form
|
||||
$('#new_version').val('');
|
||||
@@ -183,7 +287,9 @@
|
||||
$('#selected_file_name').text('');
|
||||
$('#remove-version-file-btn').hide();
|
||||
$('#sha256-hash-row').hide();
|
||||
$('#new_file_hash').val('');
|
||||
$('#new_checksum_file').val('');
|
||||
$('#selected_checksum_name').text('');
|
||||
$('#remove-checksum-file-btn').hide();
|
||||
} else {
|
||||
alert(response.data.message || wcLicensedProductVersions.strings.error);
|
||||
}
|
||||
@@ -196,6 +302,11 @@
|
||||
$spinner.removeClass('is-active');
|
||||
}
|
||||
});
|
||||
}).catch(function(error) {
|
||||
alert(error.message);
|
||||
$btn.prop('disabled', false);
|
||||
$spinner.removeClass('is-active');
|
||||
});
|
||||
},
|
||||
|
||||
deleteVersion: function(e) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
673
composer.lock
generated
673
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": "5e4b5a970f75d0163c5496581d963a24ade4f276"
|
||||
},
|
||||
"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-26T15:54:37+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.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
|
||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c",
|
||||
"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.4"
|
||||
},
|
||||
"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": "2026-01-23T16:34:22+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",
|
||||
@@ -242,17 +726,184 @@
|
||||
"time": "2024-12-23T08:48:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.22.2",
|
||||
"name": "symfony/polyfill-php83",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2"
|
||||
"url": "https://github.com/symfony/polyfill-php83.git",
|
||||
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2",
|
||||
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2",
|
||||
"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.23.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
|
||||
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -306,7 +957,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/twigphp/Twig/issues",
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.22.2"
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.23.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -318,13 +969,15 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-14T11:28:47+00:00"
|
||||
"time": "2026-01-23T21:00:41+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"stability-flags": {
|
||||
"magdev/wc-licensed-product-client": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
|
||||
@@ -8,14 +8,16 @@ 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
|
||||
3. Each license key has a unique derived secret (not the master secret)
|
||||
4. Client verifies the signature using their per-license secret
|
||||
5. Invalid signatures cause the client to reject the response
|
||||
|
||||
This prevents attackers from:
|
||||
|
||||
- Faking valid license responses
|
||||
- Replaying old responses
|
||||
- Tampering with response data
|
||||
- Using one customer's secret to verify another customer's responses
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -323,13 +325,49 @@ Adjust if needed:
|
||||
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
|
||||
```
|
||||
|
||||
### Per-License Secrets
|
||||
|
||||
Each customer receives a unique secret derived from their license key. This means:
|
||||
|
||||
- Customers only know their own secret, not the master server secret
|
||||
- If one customer's secret is leaked, other customers are not affected
|
||||
- The server uses HKDF-like derivation to create unique secrets
|
||||
|
||||
#### How Customers Get Their Secret
|
||||
|
||||
Customers can find their per-license verification secret in their account:
|
||||
|
||||
1. Log in to the store
|
||||
2. Go to My Account > Licenses
|
||||
3. Click "API Verification Secret" under any license
|
||||
4. Copy the 64-character hex string
|
||||
|
||||
This secret is automatically derived from the customer's license key and the server's master secret.
|
||||
|
||||
#### Using the Customer Secret
|
||||
|
||||
```php
|
||||
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
// Customer uses their per-license secret (from account page)
|
||||
$client = new SecureLicenseClient(
|
||||
httpClient: HttpClient::create(),
|
||||
baseUrl: 'https://shop.example.com',
|
||||
serverSecret: 'customer-secret-from-account-page', // 64 hex chars
|
||||
);
|
||||
|
||||
$info = $client->validate('XXXX-XXXX-XXXX-XXXX', 'example.com');
|
||||
```
|
||||
|
||||
### Secret Key Rotation
|
||||
|
||||
To rotate the server secret:
|
||||
|
||||
1. Deploy new secret to server
|
||||
2. Update client configurations
|
||||
3. Old signatures become invalid immediately
|
||||
2. All per-license secrets change automatically (they're derived)
|
||||
3. Customers must copy their new secret from their account page
|
||||
4. Old signatures become invalid immediately
|
||||
|
||||
For zero-downtime rotation, implement versioned secrets:
|
||||
|
||||
|
||||
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.
1
releases/wc-licensed-product-0.3.4.sha256
Normal file
1
releases/wc-licensed-product-0.3.4.sha256
Normal file
@@ -0,0 +1 @@
|
||||
36a81c00eb03adf5dfa633891664d44b7e5225bf1ee594904f8acc9adec6bb47 releases/wc-licensed-product-0.3.4.zip
|
||||
BIN
releases/wc-licensed-product-0.3.4.zip
Normal file
BIN
releases/wc-licensed-product-0.3.4.zip
Normal file
Binary file not shown.
BIN
releases/wc-licensed-product-0.3.7.zip
Normal file
BIN
releases/wc-licensed-product-0.3.7.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.3.7.zip.sha256
Normal file
1
releases/wc-licensed-product-0.3.7.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6 wc-licensed-product-0.3.7.zip
|
||||
BIN
releases/wc-licensed-product-0.3.9.zip
Normal file
BIN
releases/wc-licensed-product-0.3.9.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.3.9.zip.sha256
Normal file
1
releases/wc-licensed-product-0.3.9.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
fdb65200c368da380df0cabb3c6ac6419d5b4731cd528f630f9b432a3ba5c586 releases/wc-licensed-product-0.3.9.zip
|
||||
BIN
releases/wc-licensed-product-0.4.0.zip
Normal file
BIN
releases/wc-licensed-product-0.4.0.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.4.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.4.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
cf8769c861d77c327f178049d5fac0d4e47679cc1a1d35c5b613e4cd3fb8674f wc-licensed-product-0.4.0.zip
|
||||
BIN
releases/wc-licensed-product-0.5.0.zip
Normal file
BIN
releases/wc-licensed-product-0.5.0.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316 wc-licensed-product-0.5.0.zip
|
||||
BIN
releases/wc-licensed-product-0.5.1.zip
Normal file
BIN
releases/wc-licensed-product-0.5.1.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.1.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.1.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556 wc-licensed-product-0.5.1.zip
|
||||
BIN
releases/wc-licensed-product-0.5.3.zip
Normal file
BIN
releases/wc-licensed-product-0.5.3.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.3.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.3.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
bbd0fa8888c6990a4ba00ccfb8b2189ee6ac529a34cc11a5d8d8d28518b1f6dd wc-licensed-product-0.5.3.zip
|
||||
BIN
releases/wc-licensed-product-0.5.5.zip
Normal file
BIN
releases/wc-licensed-product-0.5.5.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.5.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.5.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
8c37e1c68eb6031c37d35adc516a492abdbea8498bdc3e3fc7d93eda380a4fe0 wc-licensed-product-0.5.5.zip
|
||||
BIN
releases/wc-licensed-product-0.5.6.zip
Normal file
BIN
releases/wc-licensed-product-0.5.6.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.6.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.6.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
4d35a319fe4cb4e7055bae17fc030487ca05e5e9ac905f76d0ac62002bde4336 releases/wc-licensed-product-0.5.6.zip
|
||||
BIN
releases/wc-licensed-product-0.5.7.zip
Normal file
BIN
releases/wc-licensed-product-0.5.7.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.7.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.7.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
ceb4d57598f576f4f172153ff80df8c180ecd4dca873cf109327fc5ac718930f wc-licensed-product-0.5.7.zip
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,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)
|
||||
*/
|
||||
@@ -545,6 +572,11 @@ final class AdminController
|
||||
*/
|
||||
private function handleCsvExport(): void
|
||||
{
|
||||
// Verify nonce for CSRF protection
|
||||
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'export_licenses_csv')) {
|
||||
wp_die(__('Security check failed.', 'wc-licensed-product'));
|
||||
}
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product'));
|
||||
}
|
||||
@@ -927,7 +959,7 @@ final class AdminController
|
||||
<span class="dashicons dashicons-admin-network"></span>
|
||||
<?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="button">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="button">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
<?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
@@ -1021,6 +1053,12 @@ final class AdminController
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string {
|
||||
return wp_create_nonce('transfer_license');
|
||||
}));
|
||||
$this->twig->addFunction(new \Twig\TwigFunction('export_csv_url', function (): string {
|
||||
return wp_nonce_url(
|
||||
admin_url('admin.php?page=wc-licenses&action=export_csv'),
|
||||
'export_licenses_csv'
|
||||
);
|
||||
}));
|
||||
|
||||
try {
|
||||
echo $this->twig->render('admin/licenses.html.twig', [
|
||||
@@ -1160,7 +1198,7 @@ final class AdminController
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline"><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
|
||||
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="page-title-action">
|
||||
<a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="page-title-action">
|
||||
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||
<?php esc_html_e('Export CSV', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
@@ -1347,7 +1385,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'),
|
||||
@@ -1429,8 +1480,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);
|
||||
@@ -1445,6 +1557,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>
|
||||
|
||||
147
src/Admin/DashboardWidgetController.php
Normal file
147
src/Admin/DashboardWidgetController.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard Widget Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Admin
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Admin;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\License;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
|
||||
/**
|
||||
* Handles the WordPress admin dashboard widget for license statistics
|
||||
*/
|
||||
final class DashboardWidgetController
|
||||
{
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager)
|
||||
{
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dashboard widget
|
||||
*/
|
||||
public function registerDashboardWidget(): void
|
||||
{
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_add_dashboard_widget(
|
||||
'wclp_license_statistics',
|
||||
__('License Statistics', 'wc-licensed-product'),
|
||||
[$this, 'renderWidget']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the dashboard widget content
|
||||
*/
|
||||
public function renderWidget(): void
|
||||
{
|
||||
$stats = $this->licenseManager->getStatistics();
|
||||
$licensesUrl = admin_url('admin.php?page=wc-licenses');
|
||||
?>
|
||||
<style>
|
||||
.wclp-widget-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wclp-stat-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e4e7;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.wclp-stat-card.highlight {
|
||||
border-left: 3px solid #7f54b3;
|
||||
}
|
||||
.wclp-stat-card.warning {
|
||||
border-left: 3px solid #f0b849;
|
||||
}
|
||||
.wclp-stat-card.danger {
|
||||
border-left: 3px solid #dc3232;
|
||||
}
|
||||
.wclp-stat-card.success {
|
||||
border-left: 3px solid #46b450;
|
||||
}
|
||||
.wclp-stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wclp-stat-label {
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.wclp-widget-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e2e4e7;
|
||||
text-align: center;
|
||||
}
|
||||
.wclp-widget-footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wclp-widget-stats">
|
||||
<div class="wclp-stat-card highlight">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Total Licenses', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card success">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_ACTIVE])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Active', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card warning">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring_soon'])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Expiring Soon', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card danger">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_EXPIRED])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Expired', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wclp-widget-stats">
|
||||
<div class="wclp-stat-card">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring'])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Time-limited', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
<div class="wclp-stat-card">
|
||||
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['lifetime'])); ?></div>
|
||||
<div class="wclp-stat-label"><?php esc_html_e('Lifetime', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wclp-widget-footer">
|
||||
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">
|
||||
<?php esc_html_e('View All Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
184
src/Admin/DownloadWidgetController.php
Normal file
184
src/Admin/DownloadWidgetController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* Download Statistics Widget Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Admin
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Admin;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
|
||||
/**
|
||||
* Handles the WordPress admin dashboard widget for download statistics
|
||||
*/
|
||||
final class DownloadWidgetController
|
||||
{
|
||||
private VersionManager $versionManager;
|
||||
|
||||
public function __construct(VersionManager $versionManager)
|
||||
{
|
||||
$this->versionManager = $versionManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dashboard widget
|
||||
*/
|
||||
public function registerDashboardWidget(): void
|
||||
{
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_add_dashboard_widget(
|
||||
'wclp_download_statistics',
|
||||
__('Download Statistics', 'wc-licensed-product'),
|
||||
[$this, 'renderWidget']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the dashboard widget content
|
||||
*/
|
||||
public function renderWidget(): void
|
||||
{
|
||||
$stats = $this->versionManager->getDownloadStatistics();
|
||||
?>
|
||||
<style>
|
||||
.wclp-download-widget-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wclp-download-stat-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e4e7;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border-left: 3px solid #2271b1;
|
||||
}
|
||||
.wclp-download-stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wclp-download-stat-label {
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.wclp-download-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.wclp-download-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e2e4e7;
|
||||
}
|
||||
.wclp-download-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.wclp-download-list .product-name {
|
||||
font-weight: 500;
|
||||
color: #1d2327;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.wclp-download-list .version-info {
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
}
|
||||
.wclp-download-list .download-count {
|
||||
background: #e7f5ff;
|
||||
color: #0a4b78;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wclp-download-section-title {
|
||||
margin: 16px 0 8px 0;
|
||||
font-size: 13px;
|
||||
color: #1d2327;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wclp-no-downloads {
|
||||
color: #646970;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wclp-download-widget-stats">
|
||||
<div class="wclp-download-stat-card">
|
||||
<div class="wclp-download-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
|
||||
<div class="wclp-download-stat-label"><?php esc_html_e('Total Downloads', 'wc-licensed-product'); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="wclp-download-section-title">
|
||||
<?php esc_html_e('Top Products', 'wc-licensed-product'); ?>
|
||||
</h4>
|
||||
<?php if (!empty($stats['by_product'])): ?>
|
||||
<ul class="wclp-download-list">
|
||||
<?php foreach (array_slice($stats['by_product'], 0, 5) as $product): ?>
|
||||
<li>
|
||||
<span class="product-name"><?php echo esc_html($product['product_name']); ?></span>
|
||||
<span class="download-count">
|
||||
<?php echo esc_html(number_format_i18n($product['downloads'])); ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h4 class="wclp-download-section-title">
|
||||
<?php esc_html_e('Top Versions', 'wc-licensed-product'); ?>
|
||||
</h4>
|
||||
<?php if (!empty($stats['by_version'])): ?>
|
||||
<ul class="wclp-download-list">
|
||||
<?php foreach (array_slice($stats['by_version'], 0, 5) as $version): ?>
|
||||
<li>
|
||||
<span class="product-name">
|
||||
<?php echo esc_html($version['product_name']); ?>
|
||||
<span class="version-info">v<?php echo esc_html($version['version']); ?></span>
|
||||
</span>
|
||||
<span class="download-count">
|
||||
<?php echo esc_html(number_format_i18n($version['downloads'])); ?>
|
||||
</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ final class OrderLicenseController
|
||||
// Handle AJAX actions
|
||||
add_action('wp_ajax_wclp_update_order_domain', [$this, 'ajaxUpdateOrderDomain']);
|
||||
add_action('wp_ajax_wclp_update_license_domain', [$this, 'ajaxUpdateLicenseDomain']);
|
||||
add_action('wp_ajax_wclp_generate_order_licenses', [$this, 'ajaxGenerateOrderLicenses']);
|
||||
|
||||
// Enqueue admin scripts
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']);
|
||||
@@ -93,8 +94,10 @@ final class OrderLicenseController
|
||||
return;
|
||||
}
|
||||
|
||||
// Get order domain
|
||||
$orderDomain = $order->get_meta('_licensed_product_domain');
|
||||
// Check for multi-domain format first, then fall back to legacy single domain
|
||||
$multiDomainData = $order->get_meta('_licensed_product_domains');
|
||||
$legacyDomain = $order->get_meta('_licensed_product_domain');
|
||||
$hasMultiDomain = !empty($multiDomainData) && is_array($multiDomainData);
|
||||
|
||||
// Get licenses for this order
|
||||
$licenses = $this->licenseManager->getLicensesByOrder($order->get_id());
|
||||
@@ -103,7 +106,25 @@ final class OrderLicenseController
|
||||
?>
|
||||
<div class="wclp-order-licenses">
|
||||
<div class="wclp-order-domain-section">
|
||||
<h4><?php esc_html_e('Order Domain', 'wc-licensed-product'); ?></h4>
|
||||
<h4><?php esc_html_e('Order Domains', 'wc-licensed-product'); ?></h4>
|
||||
|
||||
<?php if ($hasMultiDomain): ?>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Domains specified during checkout (multi-domain order).', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
<div class="wclp-multi-domain-display" style="margin-top: 10px;">
|
||||
<?php foreach ($multiDomainData as $item): ?>
|
||||
<?php
|
||||
$product = wc_get_product($item['product_id']);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
?>
|
||||
<div class="wclp-product-domains-item" style="margin-bottom: 10px; padding: 10px; background: #f8f8f8; border-radius: 4px;">
|
||||
<strong><?php echo esc_html($productName); ?>:</strong><br>
|
||||
<code><?php echo esc_html(implode(', ', $item['domains'])); ?></code>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="description">
|
||||
<?php esc_html_e('The domain specified during checkout. Changing this will not automatically update existing license domains.', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
@@ -111,7 +132,7 @@ final class OrderLicenseController
|
||||
<input type="text"
|
||||
id="wclp-order-domain"
|
||||
class="regular-text"
|
||||
value="<?php echo esc_attr($orderDomain); ?>"
|
||||
value="<?php echo esc_attr($legacyDomain); ?>"
|
||||
data-order-id="<?php echo esc_attr($order->get_id()); ?>"
|
||||
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" />
|
||||
<button type="button" class="button" id="wclp-save-order-domain">
|
||||
@@ -120,12 +141,36 @@ final class OrderLicenseController
|
||||
<span class="spinner"></span>
|
||||
<span class="wclp-status-message"></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
|
||||
|
||||
<?php
|
||||
// Count expected licenses based on domain data
|
||||
$expectedLicenses = 0;
|
||||
if ($hasMultiDomain) {
|
||||
// Multi-domain: count total domains across all products
|
||||
foreach ($multiDomainData as $item) {
|
||||
if (isset($item['domains']) && is_array($item['domains'])) {
|
||||
$expectedLicenses += count($item['domains']);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy: one license per licensed product
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
$expectedLicenses++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$missingLicenses = $expectedLicenses - count($licenses);
|
||||
$hasDomainData = $hasMultiDomain || !empty($legacyDomain);
|
||||
?>
|
||||
|
||||
<?php if (empty($licenses)): ?>
|
||||
<p class="description">
|
||||
<?php esc_html_e('No licenses have been generated for this order yet.', 'wc-licensed-product'); ?>
|
||||
@@ -137,6 +182,20 @@ final class OrderLicenseController
|
||||
<em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if ($hasDomainData && $order->is_paid()): ?>
|
||||
<p style="margin-top: 10px;">
|
||||
<button type="button" class="button button-primary" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>">
|
||||
<?php esc_html_e('Generate Licenses', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<span class="spinner" style="float: none; margin-top: 4px;"></span>
|
||||
<span class="wclp-generate-status"></span>
|
||||
</p>
|
||||
<?php elseif (!$hasDomainData): ?>
|
||||
<p class="description" style="margin-top: 10px; color: #d63638;">
|
||||
<span class="dashicons dashicons-warning"></span>
|
||||
<?php esc_html_e('Please set the order domain above before generating licenses.', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<table class="widefat striped wclp-licenses-table">
|
||||
<thead>
|
||||
@@ -223,6 +282,29 @@ final class OrderLicenseController
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
|
||||
<?php if ($missingLicenses > 0 && $hasDomainData && $order->is_paid()): ?>
|
||||
<p style="margin-top: 10px;">
|
||||
<span class="dashicons dashicons-warning" style="color: #dba617;"></span>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: Number of missing licenses */
|
||||
esc_html(_n(
|
||||
'%d licensed product is missing a license.',
|
||||
'%d licensed products are missing licenses.',
|
||||
$missingLicenses,
|
||||
'wc-licensed-product'
|
||||
)),
|
||||
$missingLicenses
|
||||
);
|
||||
?>
|
||||
<button type="button" class="button" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>">
|
||||
<?php esc_html_e('Generate Missing Licenses', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<span class="spinner" style="float: none; margin-top: 4px;"></span>
|
||||
<span class="wclp-generate-status"></span>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -248,6 +330,9 @@ final class OrderLicenseController
|
||||
.wclp-lifetime { color: #0073aa; font-weight: 500; }
|
||||
.wclp-edit-domain-btn { color: #0073aa; text-decoration: none; }
|
||||
.wclp-edit-domain-btn .dashicons { font-size: 16px; width: 16px; height: 16px; }
|
||||
.wclp-generate-status { font-style: italic; margin-left: 8px; }
|
||||
.wclp-generate-status.success { color: #46b450; }
|
||||
.wclp-generate-status.error { color: #dc3232; }
|
||||
</style>
|
||||
<?php
|
||||
}
|
||||
@@ -284,8 +369,9 @@ final class OrderLicenseController
|
||||
'strings' => [
|
||||
'saving' => __('Saving...', 'wc-licensed-product'),
|
||||
'saved' => __('Saved!', 'wc-licensed-product'),
|
||||
'error' => __('Error saving. Please try again.', 'wc-licensed-product'),
|
||||
'error' => __('Error. Please try again.', 'wc-licensed-product'),
|
||||
'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'),
|
||||
'generating' => __('Generating...', 'wc-licensed-product'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -392,4 +478,166 @@ final class OrderLicenseController
|
||||
$pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
|
||||
return (bool) preg_match($pattern, $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for generating order licenses
|
||||
*/
|
||||
public function ajaxGenerateOrderLicenses(): void
|
||||
{
|
||||
check_ajax_referer('wclp_order_license_actions', 'nonce');
|
||||
|
||||
if (!current_user_can('manage_woocommerce')) {
|
||||
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
$orderId = absint($_POST['order_id'] ?? 0);
|
||||
|
||||
if (!$orderId) {
|
||||
wp_send_json_error(['message' => __('Invalid order ID.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
$order = wc_get_order($orderId);
|
||||
if (!$order) {
|
||||
wp_send_json_error(['message' => __('Order not found.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
// Check if order is paid
|
||||
if (!$order->is_paid()) {
|
||||
wp_send_json_error(['message' => __('Order must be paid before licenses can be generated.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
// Check for multi-domain format first
|
||||
$multiDomainData = $order->get_meta('_licensed_product_domains');
|
||||
$legacyDomain = $order->get_meta('_licensed_product_domain');
|
||||
|
||||
if (!empty($multiDomainData) && is_array($multiDomainData)) {
|
||||
// Multi-domain format
|
||||
$result = $this->generateMultiDomainLicenses($order, $multiDomainData);
|
||||
} elseif (!empty($legacyDomain)) {
|
||||
// Legacy single domain format
|
||||
$result = $this->generateLegacyLicenses($order, $legacyDomain);
|
||||
} else {
|
||||
wp_send_json_error(['message' => __('Please set the order domain before generating licenses.', 'wc-licensed-product')]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result['generated'] > 0) {
|
||||
wp_send_json_success([
|
||||
'message' => sprintf(
|
||||
/* translators: %d: Number of licenses generated */
|
||||
_n(
|
||||
'%d license generated successfully.',
|
||||
'%d licenses generated successfully.',
|
||||
$result['generated'],
|
||||
'wc-licensed-product'
|
||||
),
|
||||
$result['generated']
|
||||
),
|
||||
'generated' => $result['generated'],
|
||||
'skipped' => $result['skipped'],
|
||||
'reload' => true,
|
||||
]);
|
||||
} else {
|
||||
wp_send_json_success([
|
||||
'message' => __('All licenses already exist for this order.', 'wc-licensed-product'),
|
||||
'generated' => 0,
|
||||
'skipped' => $result['skipped'],
|
||||
'reload' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate licenses for multi-domain format
|
||||
*/
|
||||
private function generateMultiDomainLicenses(\WC_Order $order, array $domainData): array
|
||||
{
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
$orderId = $order->get_id();
|
||||
$customerId = $order->get_customer_id();
|
||||
|
||||
// Index domains by product ID
|
||||
$domainsByProduct = [];
|
||||
foreach ($domainData as $item) {
|
||||
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
|
||||
$domainsByProduct[(int) $item['product_id']] = $item['domains'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productId = $product->get_id();
|
||||
$domains = $domainsByProduct[$productId] ?? [];
|
||||
|
||||
// Get existing licenses for this product
|
||||
$existingLicenses = $this->licenseManager->getLicensesByOrderAndProduct($orderId, $productId);
|
||||
$existingDomains = array_map(fn($l) => $l->getDomain(), $existingLicenses);
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
|
||||
// Skip if license already exists for this domain
|
||||
if (in_array($normalizedDomain, $existingDomains, true)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$license = $this->licenseManager->generateLicense(
|
||||
$orderId,
|
||||
$productId,
|
||||
$customerId,
|
||||
$normalizedDomain
|
||||
);
|
||||
|
||||
if ($license) {
|
||||
$generated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['generated' => $generated, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate licenses for legacy single domain format
|
||||
*/
|
||||
private function generateLegacyLicenses(\WC_Order $order, string $domain): array
|
||||
{
|
||||
$generated = 0;
|
||||
$skipped = 0;
|
||||
$orderId = $order->get_id();
|
||||
$customerId = $order->get_customer_id();
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if license already exists
|
||||
$existing = $this->licenseManager->getLicenseByOrderAndProduct($orderId, $product->get_id());
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$license = $this->licenseManager->generateLicense(
|
||||
$orderId,
|
||||
$product->get_id(),
|
||||
$customerId,
|
||||
$domain
|
||||
);
|
||||
|
||||
if ($license) {
|
||||
$generated++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['generated' => $generated, 'skipped' => $skipped];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
@@ -59,7 +173,7 @@ final class SettingsController
|
||||
'id' => 'wc_licensed_product_section_defaults',
|
||||
],
|
||||
'default_max_activations' => [
|
||||
'name' => __('Default Max Activations', 'wc-licensed-product'),
|
||||
'name' => __('Max Activations', 'wc-licensed-product'),
|
||||
'type' => 'number',
|
||||
'desc' => __('Default maximum number of domain activations per license.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_default_max_activations',
|
||||
@@ -70,7 +184,7 @@ final class SettingsController
|
||||
],
|
||||
],
|
||||
'default_validity_days' => [
|
||||
'name' => __('Default License Validity (Days)', 'wc-licensed-product'),
|
||||
'name' => __('License Validity (Days)', 'wc-licensed-product'),
|
||||
'type' => 'number',
|
||||
'desc' => __('Default number of days a license is valid. Leave empty or set to 0 for lifetime licenses.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_default_validity_days',
|
||||
@@ -82,17 +196,32 @@ final class SettingsController
|
||||
],
|
||||
],
|
||||
'default_bind_to_version' => [
|
||||
'name' => __('Default Bind to Major Version', 'wc-licensed-product'),
|
||||
'name' => __('Bind to Major Version', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => __('If enabled, licenses are bound to the major version at purchase time by default.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_default_bind_to_version',
|
||||
'default' => 'no',
|
||||
],
|
||||
'enable_multi_domain' => [
|
||||
'name' => __('Enable Multi-Domain Licensing', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => __('Allow customers to purchase multiple licenses for different domains at once. Each unit in cart quantity requires a unique domain.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_enable_multi_domain',
|
||||
'default' => 'no',
|
||||
],
|
||||
'section_end' => [
|
||||
'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 +267,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
|
||||
*/
|
||||
@@ -178,6 +394,14 @@ final class SettingsController
|
||||
return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if multi-domain licensing is enabled
|
||||
*/
|
||||
public static function isMultiDomainEnabled(): bool
|
||||
{
|
||||
return get_option('wc_licensed_product_enable_multi_domain', 'no') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if expiration warning emails are enabled
|
||||
* This checks both the WooCommerce email setting and the old setting for backwards compatibility
|
||||
@@ -210,4 +434,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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +99,17 @@ final class VersionAdminController
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="sha256-hash-row" style="display: none;">
|
||||
<th><label for="new_file_hash"><?php esc_html_e('SHA256 Hash', 'wc-licensed-product'); ?></label></th>
|
||||
<th><label for="new_checksum_file"><?php esc_html_e('Checksum File', 'wc-licensed-product'); ?></label></th>
|
||||
<td>
|
||||
<input type="text" id="new_file_hash" name="new_file_hash" class="large-text" placeholder="<?php esc_attr_e('Enter SHA256 checksum...', 'wc-licensed-product'); ?>" pattern="[a-fA-F0-9]{64}" />
|
||||
<p class="description"><?php esc_html_e('SHA256 checksum of the uploaded file (optional but recommended for integrity verification).', '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):
|
||||
?>
|
||||
<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'),
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -342,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')]);
|
||||
@@ -369,16 +388,25 @@ final class VersionAdminController
|
||||
$filename = $version->getDownloadFilename();
|
||||
if ($effectiveUrl):
|
||||
?>
|
||||
<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'; ?>">
|
||||
|
||||
@@ -94,8 +94,8 @@ final class ResponseSigner
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($data);
|
||||
// Recursively sort keys for consistent ordering (required by client implementation)
|
||||
$data = $this->recursiveKeySort($data);
|
||||
|
||||
// Build signature payload
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
@@ -109,6 +109,33 @@ final class ResponseSigner
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys alphabetically
|
||||
*
|
||||
* @param mixed $data The data to sort
|
||||
* @return mixed The sorted data
|
||||
*/
|
||||
private function recursiveKeySort(mixed $data): mixed
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Check if array is associative (has string keys)
|
||||
$isAssociative = array_keys($data) !== range(0, count($data) - 1);
|
||||
|
||||
if ($isAssociative) {
|
||||
ksort($data);
|
||||
}
|
||||
|
||||
// Recursively sort nested arrays
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = $this->recursiveKeySort($value);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique signing key for a license
|
||||
*
|
||||
@@ -120,9 +147,59 @@ final class ResponseSigner
|
||||
*/
|
||||
private function deriveKey(string $licenseKey): string
|
||||
{
|
||||
// HKDF-like key derivation
|
||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||
return self::deriveCustomerSecret($licenseKey, $this->serverSecret);
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||
/**
|
||||
* Derive a customer-specific secret from a license key
|
||||
*
|
||||
* This secret is unique per license and can be shared with the customer
|
||||
* to verify signed API responses. Each customer gets their own secret
|
||||
* derived from their license key.
|
||||
*
|
||||
* Uses RFC 5869 HKDF via PHP's native hash_hkdf() function.
|
||||
* Parameters match the client library (SecureLicenseClient):
|
||||
* - IKM (input keying material): server_secret
|
||||
* - Length: 32 bytes (256 bits for SHA-256)
|
||||
* - Info: license_key (context-specific info)
|
||||
*
|
||||
* @param string $licenseKey The customer's license key
|
||||
* @param string $serverSecret The server's master secret
|
||||
* @return string The derived secret (64 hex characters)
|
||||
*/
|
||||
public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string
|
||||
{
|
||||
// RFC 5869 HKDF using PHP's native implementation
|
||||
// Must match client's ResponseSignature::deriveKey() exactly
|
||||
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
||||
|
||||
return bin2hex($binaryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customer secret for a license key using the configured server secret
|
||||
*
|
||||
* @param string $licenseKey The customer's license key
|
||||
* @return string|null The derived secret, or null if server secret is not configured
|
||||
*/
|
||||
public static function getCustomerSecretForLicense(string $licenseKey): ?string
|
||||
{
|
||||
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
|
||||
|
||||
if (empty($serverSecret)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::deriveCustomerSecret($licenseKey, $serverSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response signing is enabled
|
||||
*
|
||||
* @return bool True if server secret is configured
|
||||
*/
|
||||
public static function isSigningEnabled(): bool
|
||||
{
|
||||
return defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,34 @@ final class RestApiController
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
/**
|
||||
* Rate limit: requests per minute per IP
|
||||
* Default rate limit: requests per window per IP
|
||||
*/
|
||||
private const RATE_LIMIT_REQUESTS = 30;
|
||||
private const DEFAULT_RATE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Rate limit window in seconds
|
||||
* Default rate limit window in seconds
|
||||
*/
|
||||
private const RATE_LIMIT_WINDOW = 60;
|
||||
private const DEFAULT_RATE_WINDOW = 60;
|
||||
|
||||
/**
|
||||
* Get the configured rate limit (requests per window)
|
||||
*/
|
||||
private function getRateLimit(): int
|
||||
{
|
||||
return defined('WC_LICENSE_RATE_LIMIT')
|
||||
? (int) WC_LICENSE_RATE_LIMIT
|
||||
: self::DEFAULT_RATE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured rate limit window in seconds
|
||||
*/
|
||||
private function getRateWindow(): int
|
||||
{
|
||||
return defined('WC_LICENSE_RATE_WINDOW')
|
||||
? (int) WC_LICENSE_RATE_WINDOW
|
||||
: self::DEFAULT_RATE_WINDOW;
|
||||
}
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
@@ -56,12 +76,14 @@ final class RestApiController
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$transientKey = 'wclp_rate_' . md5($ip);
|
||||
$rateLimit = $this->getRateLimit();
|
||||
$rateWindow = $this->getRateWindow();
|
||||
|
||||
$data = get_transient($transientKey);
|
||||
|
||||
if ($data === false) {
|
||||
// First request, start counting
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -69,15 +91,15 @@ final class RestApiController
|
||||
$start = (int) ($data['start'] ?? time());
|
||||
|
||||
// Check if window has expired
|
||||
if (time() - $start >= self::RATE_LIMIT_WINDOW) {
|
||||
if (time() - $start >= $rateWindow) {
|
||||
// Reset counter
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if ($count >= self::RATE_LIMIT_REQUESTS) {
|
||||
$retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start);
|
||||
if ($count >= $rateLimit) {
|
||||
$retryAfter = $rateWindow - (time() - $start);
|
||||
$response = new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'rate_limit_exceeded',
|
||||
@@ -89,35 +111,158 @@ final class RestApiController
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW);
|
||||
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* Security note: Only trust proxy headers when explicitly configured.
|
||||
* Set WC_LICENSE_TRUSTED_PROXIES constant or configure trusted_proxies
|
||||
* in wp-config.php to enable proxy header support.
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
// Get the direct connection IP first
|
||||
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
// Only check proxy headers if we're behind a trusted proxy
|
||||
if ($this->isTrustedProxy($remoteAddr)) {
|
||||
// Check headers in order of trust preference
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and return direct connection IP
|
||||
if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) {
|
||||
return $remoteAddr;
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given IP is a trusted proxy
|
||||
*
|
||||
* @param string $ip The IP address to check
|
||||
* @return bool Whether the IP is a trusted proxy
|
||||
*/
|
||||
private function isTrustedProxy(string $ip): bool
|
||||
{
|
||||
// Check if trusted proxies are configured
|
||||
if (!defined('WC_LICENSE_TRUSTED_PROXIES')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trustedProxies = WC_LICENSE_TRUSTED_PROXIES;
|
||||
|
||||
// Handle string constant (comma-separated list)
|
||||
if (is_string($trustedProxies)) {
|
||||
$trustedProxies = array_map('trim', explode(',', $trustedProxies));
|
||||
}
|
||||
|
||||
if (!is_array($trustedProxies)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for special keywords
|
||||
if (in_array('CLOUDFLARE', $trustedProxies, true)) {
|
||||
// Cloudflare IP ranges (simplified - in production, fetch from Cloudflare API)
|
||||
if ($this->isCloudflareIp($ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check direct IP match or CIDR notation
|
||||
foreach ($trustedProxies as $proxy) {
|
||||
if ($proxy === $ip) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Support CIDR notation
|
||||
if (str_contains($proxy, '/') && $this->ipMatchesCidr($ip, $proxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP is in Cloudflare range
|
||||
*
|
||||
* @param string $ip The IP to check
|
||||
* @return bool Whether IP belongs to Cloudflare
|
||||
*/
|
||||
private function isCloudflareIp(string $ip): bool
|
||||
{
|
||||
// Cloudflare IPv4 ranges (as of 2024)
|
||||
$cloudflareRanges = [
|
||||
'173.245.48.0/20',
|
||||
'103.21.244.0/22',
|
||||
'103.22.200.0/22',
|
||||
'103.31.4.0/22',
|
||||
'141.101.64.0/18',
|
||||
'108.162.192.0/18',
|
||||
'190.93.240.0/20',
|
||||
'188.114.96.0/20',
|
||||
'197.234.240.0/22',
|
||||
'198.41.128.0/17',
|
||||
'162.158.0.0/15',
|
||||
'104.16.0.0/13',
|
||||
'104.24.0.0/14',
|
||||
'172.64.0.0/13',
|
||||
'131.0.72.0/22',
|
||||
];
|
||||
|
||||
foreach ($cloudflareRanges as $range) {
|
||||
if ($this->ipMatchesCidr($ip, $range)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP matches a CIDR range
|
||||
*
|
||||
* @param string $ip The IP to check
|
||||
* @param string $cidr The CIDR range (e.g., "192.168.1.0/24")
|
||||
* @return bool Whether the IP matches the CIDR range
|
||||
*/
|
||||
private function ipMatchesCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr);
|
||||
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
|
||||
!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$mask = -1 << (32 - (int) $bits);
|
||||
|
||||
return ($ipLong & $mask) === ($subnetLong & $mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
@@ -134,7 +279,8 @@ final class RestApiController
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ($value): bool {
|
||||
return !empty($value) && strlen($value) <= 64;
|
||||
$len = strlen($value);
|
||||
return !empty($value) && $len >= 8 && $len <= 64;
|
||||
},
|
||||
],
|
||||
'domain' => [
|
||||
@@ -158,6 +304,10 @@ final class RestApiController
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ($value): bool {
|
||||
$len = strlen($value);
|
||||
return !empty($value) && $len >= 8 && $len <= 64;
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -172,11 +322,18 @@ final class RestApiController
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ($value): bool {
|
||||
$len = strlen($value);
|
||||
return !empty($value) && $len >= 8 && $len <= 64;
|
||||
},
|
||||
],
|
||||
'domain' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => function ($value): bool {
|
||||
return !empty($value) && strlen($value) <= 255;
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -197,11 +354,32 @@ final class RestApiController
|
||||
|
||||
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||
|
||||
$statusCode = $result['valid'] ? 200 : 403;
|
||||
$statusCode = $this->getStatusCodeForResult($result);
|
||||
|
||||
return new WP_REST_Response($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP status code based on validation result
|
||||
*
|
||||
* @param array $result The validation result
|
||||
* @return int HTTP status code
|
||||
*/
|
||||
private function getStatusCodeForResult(array $result): int
|
||||
{
|
||||
if ($result['valid']) {
|
||||
return 200;
|
||||
}
|
||||
|
||||
$error = $result['error'] ?? '';
|
||||
|
||||
return match ($error) {
|
||||
'license_not_found' => 404,
|
||||
'activation_failed' => 500,
|
||||
default => 403,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check license status endpoint
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,8 @@ declare(strict_types=1);
|
||||
namespace Jeremias\WcLicensedProduct\Checkout;
|
||||
|
||||
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
|
||||
|
||||
/**
|
||||
* Integration with WooCommerce Checkout Blocks
|
||||
@@ -30,7 +32,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
public function initialize(): void
|
||||
{
|
||||
$this->registerScripts();
|
||||
$this->registerBlockExtensionData();
|
||||
$this->registerAdditionalCheckoutFields();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +47,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
wp_register_script(
|
||||
'wc-licensed-product-checkout-blocks',
|
||||
$scriptUrl,
|
||||
['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n'],
|
||||
['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n', 'wp-plugins', 'wp-data'],
|
||||
WC_LICENSED_PRODUCT_VERSION,
|
||||
true
|
||||
);
|
||||
@@ -59,20 +61,33 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Register block extension data
|
||||
* Register additional checkout fields using WooCommerce Blocks API
|
||||
*/
|
||||
private function registerBlockExtensionData(): void
|
||||
private function registerAdditionalCheckoutFields(): void
|
||||
{
|
||||
// Pass data to the checkout block script
|
||||
add_filter(
|
||||
'woocommerce_blocks_checkout_block_registration_data',
|
||||
function (array $data): array {
|
||||
$data['wc-licensed-product'] = [
|
||||
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
|
||||
];
|
||||
return $data;
|
||||
add_action('woocommerce_blocks_loaded', function (): void {
|
||||
// Check if the function exists (WooCommerce 8.9+)
|
||||
if (!function_exists('woocommerce_register_additional_checkout_field')) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
// Register the domain field using WooCommerce's checkout fields API
|
||||
// For single domain mode only (multi-domain uses custom JS component)
|
||||
if (!SettingsController::isMultiDomainEnabled()) {
|
||||
woocommerce_register_additional_checkout_field([
|
||||
'id' => 'wc-licensed-product/domain',
|
||||
'label' => __('License Domain', 'wc-licensed-product'),
|
||||
'location' => 'order',
|
||||
'type' => 'text',
|
||||
'required' => false,
|
||||
'attributes' => [
|
||||
'placeholder' => __('example.com', 'wc-licensed-product'),
|
||||
'pattern' => '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$',
|
||||
'title' => __('Enter a valid domain (without http:// or www)', 'wc-licensed-product'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,13 +111,23 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
*/
|
||||
public function get_script_data(): array
|
||||
{
|
||||
$isMultiDomain = SettingsController::isMultiDomainEnabled();
|
||||
|
||||
return [
|
||||
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
|
||||
'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'),
|
||||
'licensedProducts' => $this->getLicensedProductsFromCart(),
|
||||
'isMultiDomainEnabled' => $isMultiDomain,
|
||||
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
|
||||
'fieldDescription' => __('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'),
|
||||
'sectionTitle' => __('License Domain', 'wc-licensed-product'),
|
||||
'validationError' => __('Please enter a valid domain for your license activation.', 'wc-licensed-product'),
|
||||
'fieldDescription' => $isMultiDomain
|
||||
? __('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product')
|
||||
: __('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'),
|
||||
'sectionTitle' => $isMultiDomain
|
||||
? __('License Domains', 'wc-licensed-product')
|
||||
: __('License Domain', 'wc-licensed-product'),
|
||||
'validationError' => __('Please enter a valid domain.', 'wc-licensed-product'),
|
||||
'duplicateError' => __('Each license requires a unique domain.', 'wc-licensed-product'),
|
||||
'licenseLabel' => __('License %d:', 'wc-licensed-product'),
|
||||
'singleDomainLabel' => __('Domain', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -111,17 +136,69 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
*/
|
||||
private function cartHasLicensedProducts(): bool
|
||||
{
|
||||
if (!WC()->cart) {
|
||||
return false;
|
||||
return !empty($this->getLicensedProductsFromCart());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get licensed products from cart with quantities
|
||||
*
|
||||
* @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
|
||||
*/
|
||||
private function getLicensedProductsFromCart(): array
|
||||
{
|
||||
if (!WC()->cart) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$licensedProducts = [];
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
return true;
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for simple licensed products
|
||||
if ($product->is_type('licensed')) {
|
||||
$productId = $product->get_id();
|
||||
$licensedProducts[] = [
|
||||
'product_id' => $productId,
|
||||
'variation_id' => 0,
|
||||
'name' => $product->get_name(),
|
||||
'quantity' => (int) $cartItem['quantity'],
|
||||
'duration_label' => '',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for variations of licensed-variable products
|
||||
if ($product->is_type('variation')) {
|
||||
$parentId = $product->get_parent_id();
|
||||
$parent = wc_get_product($parentId);
|
||||
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
$variationId = $product->get_id();
|
||||
|
||||
// Get duration label if it's a LicensedProductVariation
|
||||
$durationLabel = '';
|
||||
if ($product instanceof LicensedProductVariation) {
|
||||
$durationLabel = $product->get_license_duration_label();
|
||||
} else {
|
||||
// Try to instantiate as LicensedProductVariation
|
||||
$variation = new LicensedProductVariation($variationId);
|
||||
$durationLabel = $variation->get_license_duration_label();
|
||||
}
|
||||
|
||||
$licensedProducts[] = [
|
||||
'product_id' => $parentId,
|
||||
'variation_id' => $variationId,
|
||||
'name' => $product->get_name(),
|
||||
'quantity' => (int) $cartItem['quantity'],
|
||||
'duration_label' => $durationLabel,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $licensedProducts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ declare(strict_types=1);
|
||||
namespace Jeremias\WcLicensedProduct\Checkout;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
|
||||
|
||||
/**
|
||||
* Handles checkout modifications for licensed products
|
||||
@@ -50,35 +52,113 @@ final class CheckoutController
|
||||
*/
|
||||
private function cartHasLicensedProducts(): bool
|
||||
{
|
||||
if (!WC()->cart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return !empty($this->getLicensedProductsFromCart());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add domain field to checkout form
|
||||
* Get licensed products from cart with quantities
|
||||
*
|
||||
* @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
|
||||
*/
|
||||
private function getLicensedProductsFromCart(): array
|
||||
{
|
||||
if (!WC()->cart) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$licensedProducts = [];
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for simple licensed products
|
||||
if ($product->is_type('licensed')) {
|
||||
$productId = $product->get_id();
|
||||
$licensedProducts[$productId] = [
|
||||
'product_id' => $productId,
|
||||
'variation_id' => 0,
|
||||
'name' => $product->get_name(),
|
||||
'quantity' => (int) $cartItem['quantity'],
|
||||
'duration_label' => '',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for variations of licensed-variable products
|
||||
if ($product->is_type('variation')) {
|
||||
$parentId = $product->get_parent_id();
|
||||
$parent = wc_get_product($parentId);
|
||||
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
$variationId = $product->get_id();
|
||||
// Use combination key to allow same product with different variations
|
||||
$key = "{$parentId}_{$variationId}";
|
||||
|
||||
// Get duration label if it's a LicensedProductVariation
|
||||
$durationLabel = '';
|
||||
if ($product instanceof LicensedProductVariation) {
|
||||
$durationLabel = $product->get_license_duration_label();
|
||||
} else {
|
||||
// Try to instantiate as LicensedProductVariation
|
||||
$variation = new LicensedProductVariation($variationId);
|
||||
$durationLabel = $variation->get_license_duration_label();
|
||||
}
|
||||
|
||||
$licensedProducts[$key] = [
|
||||
'product_id' => $parentId,
|
||||
'variation_id' => $variationId,
|
||||
'name' => $product->get_name(),
|
||||
'quantity' => (int) $cartItem['quantity'],
|
||||
'duration_label' => $durationLabel,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $licensedProducts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add domain fields to checkout form
|
||||
* Shows multiple domain fields if multi-domain is enabled, otherwise single field
|
||||
*/
|
||||
public function addDomainField(): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
if (empty($licensedProducts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if multi-domain licensing is enabled
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->renderMultiDomainFields($licensedProducts);
|
||||
} else {
|
||||
$this->renderSingleDomainField();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render single domain field (legacy mode)
|
||||
*/
|
||||
private function renderSingleDomainField(): void
|
||||
{
|
||||
$savedValue = '';
|
||||
|
||||
// Check POST data first (validation failure case)
|
||||
if (isset($_POST['licensed_product_domain'])) {
|
||||
$savedValue = sanitize_text_field($_POST['licensed_product_domain']);
|
||||
} elseif (WC()->session) {
|
||||
$savedValue = WC()->session->get('licensed_product_domain', '');
|
||||
}
|
||||
|
||||
?>
|
||||
<div id="licensed-product-domain-field">
|
||||
<h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3>
|
||||
<p class="form-row form-row-wide">
|
||||
<label for="licensed_product_domain">
|
||||
<?php esc_html_e('Domain for License Activation', 'wc-licensed-product'); ?>
|
||||
<?php esc_html_e('Domain', 'wc-licensed-product'); ?>
|
||||
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
|
||||
</label>
|
||||
<input
|
||||
@@ -87,10 +167,10 @@ final class CheckoutController
|
||||
name="licensed_product_domain"
|
||||
id="licensed_product_domain"
|
||||
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
|
||||
value="<?php echo esc_attr(WC()->checkout->get_value('licensed_product_domain')); ?>"
|
||||
value="<?php echo esc_attr($savedValue); ?>"
|
||||
/>
|
||||
<span class="description">
|
||||
<?php esc_html_e('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'); ?>
|
||||
<?php esc_html_e('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'); ?>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,62 +178,318 @@ final class CheckoutController
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain field during checkout
|
||||
* Render multi-domain fields (one per quantity)
|
||||
*/
|
||||
private function renderMultiDomainFields(array $licensedProducts): void
|
||||
{
|
||||
?>
|
||||
<div id="licensed-product-domain-fields">
|
||||
<h3><?php esc_html_e('License Domains', 'wc-licensed-product'); ?></h3>
|
||||
<p class="wclp-domain-description">
|
||||
<?php esc_html_e('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
|
||||
<?php foreach ($licensedProducts as $key => $productData): ?>
|
||||
<?php
|
||||
$productId = $productData['product_id'];
|
||||
$variationId = $productData['variation_id'] ?? 0;
|
||||
$durationLabel = $productData['duration_label'] ?? '';
|
||||
// Use key for field names to handle variations
|
||||
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : $productId;
|
||||
?>
|
||||
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>" data-variation-id="<?php echo esc_attr($variationId); ?>">
|
||||
<h4>
|
||||
<?php
|
||||
echo esc_html($productData['name']);
|
||||
if (!empty($durationLabel)) {
|
||||
echo ' <span class="wclp-duration-badge">(' . esc_html($durationLabel) . ')</span>';
|
||||
}
|
||||
if ($productData['quantity'] > 1) {
|
||||
printf(' ×%d', $productData['quantity']);
|
||||
}
|
||||
?>
|
||||
</h4>
|
||||
|
||||
<?php for ($i = 0; $i < $productData['quantity']; $i++): ?>
|
||||
<?php
|
||||
$fieldName = sprintf('licensed_domains[%s][%d]', $fieldKey, $i);
|
||||
$fieldId = sprintf('licensed_domain_%s_%d', str_replace('_', '-', $fieldKey), $i);
|
||||
$savedValue = $this->getSavedDomainValue($productId, $i, $variationId);
|
||||
?>
|
||||
<p class="form-row form-row-wide wclp-domain-row">
|
||||
<label for="<?php echo esc_attr($fieldId); ?>">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %d: license number */
|
||||
esc_html__('License %d:', 'wc-licensed-product'),
|
||||
$i + 1
|
||||
);
|
||||
?>
|
||||
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input-text wclp-domain-input"
|
||||
name="<?php echo esc_attr($fieldName); ?>"
|
||||
id="<?php echo esc_attr($fieldId); ?>"
|
||||
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
|
||||
value="<?php echo esc_attr($savedValue); ?>"
|
||||
/>
|
||||
<?php if ($variationId > 0): ?>
|
||||
<input type="hidden" name="licensed_variation_ids[<?php echo esc_attr($fieldKey); ?>]" value="<?php echo esc_attr($variationId); ?>" />
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<style>
|
||||
#licensed-product-domain-fields { margin-bottom: 20px; }
|
||||
#licensed-product-domain-fields h3 { margin-bottom: 10px; }
|
||||
.wclp-domain-description { margin-bottom: 15px; color: #666; }
|
||||
.wclp-product-domains { margin-bottom: 20px; padding: 15px; background: #f8f8f8; border-radius: 4px; }
|
||||
.wclp-product-domains h4 { margin: 0 0 10px 0; font-size: 1em; }
|
||||
.wclp-duration-badge { color: #0073aa; font-weight: normal; }
|
||||
.wclp-domain-row { margin-bottom: 10px; }
|
||||
.wclp-domain-row:last-child { margin-bottom: 0; }
|
||||
.wclp-domain-row label { display: block; margin-bottom: 5px; }
|
||||
</style>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved domain value from session/POST
|
||||
*/
|
||||
private function getSavedDomainValue(int $productId, int $index, int $variationId = 0): string
|
||||
{
|
||||
// Build the field key (with or without variation)
|
||||
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
|
||||
|
||||
// Check POST data first (validation failure case)
|
||||
if (isset($_POST['licensed_domains'][$fieldKey][$index])) {
|
||||
return sanitize_text_field($_POST['licensed_domains'][$fieldKey][$index]);
|
||||
}
|
||||
|
||||
// Also try numeric key for backward compatibility
|
||||
if (isset($_POST['licensed_domains'][$productId][$index])) {
|
||||
return sanitize_text_field($_POST['licensed_domains'][$productId][$index]);
|
||||
}
|
||||
|
||||
// Check session for blocks checkout
|
||||
if (WC()->session) {
|
||||
$sessionDomains = WC()->session->get('licensed_product_domains', []);
|
||||
foreach ($sessionDomains as $item) {
|
||||
$itemProductId = (int) ($item['product_id'] ?? 0);
|
||||
$itemVariationId = (int) ($item['variation_id'] ?? 0);
|
||||
|
||||
// Match by product and variation
|
||||
if ($itemProductId === $productId && $itemVariationId === $variationId) {
|
||||
if (isset($item['domains'][$index])) {
|
||||
return $item['domains'][$index];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain fields during checkout
|
||||
*/
|
||||
public function validateDomainField(): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
if (empty($licensedProducts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$domain = isset($_POST['licensed_product_domain'])
|
||||
? sanitize_text_field($_POST['licensed_product_domain'])
|
||||
: '';
|
||||
// Check if multi-domain licensing is enabled
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->validateMultiDomainFields($licensedProducts);
|
||||
} else {
|
||||
$this->validateSingleDomainField();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate single domain field
|
||||
*/
|
||||
private function validateSingleDomainField(): void
|
||||
{
|
||||
$domain = isset($_POST['licensed_product_domain']) ? sanitize_text_field($_POST['licensed_product_domain']) : '';
|
||||
|
||||
if (empty($domain)) {
|
||||
wc_add_notice(__('Please enter a domain for your license.', 'wc-licensed-product'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (!$this->isValidDomain($normalizedDomain)) {
|
||||
wc_add_notice(__('Please enter a valid domain for your license.', 'wc-licensed-product'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multi-domain fields
|
||||
*/
|
||||
private function validateMultiDomainFields(array $licensedProducts): void
|
||||
{
|
||||
$licensedDomains = $_POST['licensed_domains'] ?? [];
|
||||
|
||||
foreach ($licensedProducts as $key => $productData) {
|
||||
$productId = $productData['product_id'];
|
||||
$variationId = $productData['variation_id'] ?? 0;
|
||||
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
|
||||
|
||||
$productDomains = $licensedDomains[$fieldKey] ?? $licensedDomains[$productId] ?? [];
|
||||
$normalizedDomains = [];
|
||||
|
||||
for ($i = 0; $i < $productData['quantity']; $i++) {
|
||||
$domain = isset($productDomains[$i]) ? sanitize_text_field($productDomains[$i]) : '';
|
||||
|
||||
// Check if domain is empty
|
||||
if (empty($domain)) {
|
||||
wc_add_notice(
|
||||
__('Please enter a domain for your license activation.', 'wc-licensed-product'),
|
||||
sprintf(
|
||||
/* translators: 1: product name, 2: license number */
|
||||
__('Please enter a domain for %1$s (License %2$d).', 'wc-licensed-product'),
|
||||
$productData['name'],
|
||||
$i + 1
|
||||
),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (!$this->isValidDomain($normalizedDomain)) {
|
||||
wc_add_notice(
|
||||
__('Please enter a valid domain name.', 'wc-licensed-product'),
|
||||
sprintf(
|
||||
/* translators: 1: product name, 2: license number */
|
||||
__('Please enter a valid domain for %1$s (License %2$d).', 'wc-licensed-product'),
|
||||
$productData['name'],
|
||||
$i + 1
|
||||
),
|
||||
'error'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate domains within same product/variation
|
||||
if (in_array($normalizedDomain, $normalizedDomains, true)) {
|
||||
wc_add_notice(
|
||||
sprintf(
|
||||
/* translators: 1: domain name, 2: product name */
|
||||
__('The domain "%1$s" is used multiple times for %2$s. Each license requires a unique domain.', 'wc-licensed-product'),
|
||||
$normalizedDomain,
|
||||
$productData['name']
|
||||
),
|
||||
'error'
|
||||
);
|
||||
} else {
|
||||
$normalizedDomains[] = $normalizedDomain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save domain field to order meta
|
||||
* Save domain fields to order meta
|
||||
*/
|
||||
public function saveDomainField(int $orderId): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
if (empty($licensedProducts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) {
|
||||
$domain = sanitize_text_field($_POST['licensed_product_domain']);
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
|
||||
$order = wc_get_order($orderId);
|
||||
if ($order) {
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if multi-domain licensing is enabled
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->saveMultiDomainFields($order, $licensedProducts);
|
||||
} else {
|
||||
$this->saveSingleDomainField($order);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save single domain field to order meta (legacy format)
|
||||
*/
|
||||
private function saveSingleDomainField(\WC_Order $order): void
|
||||
{
|
||||
$domain = isset($_POST['licensed_product_domain']) ? sanitize_text_field($_POST['licensed_product_domain']) : '';
|
||||
|
||||
if (!empty($domain)) {
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
$order->update_meta_data('_licensed_product_domain', $normalizedDomain);
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save multi-domain fields to order meta
|
||||
*/
|
||||
private function saveMultiDomainFields(\WC_Order $order, array $licensedProducts): void
|
||||
{
|
||||
$licensedDomains = $_POST['licensed_domains'] ?? [];
|
||||
$licensedVariationIds = $_POST['licensed_variation_ids'] ?? [];
|
||||
$domainData = [];
|
||||
|
||||
foreach ($licensedProducts as $key => $productData) {
|
||||
$productId = $productData['product_id'];
|
||||
$variationId = $productData['variation_id'] ?? 0;
|
||||
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
|
||||
|
||||
$productDomains = $licensedDomains[$fieldKey] ?? $licensedDomains[$productId] ?? [];
|
||||
$normalizedDomains = [];
|
||||
|
||||
for ($i = 0; $i < $productData['quantity']; $i++) {
|
||||
$domain = isset($productDomains[$i]) ? sanitize_text_field($productDomains[$i]) : '';
|
||||
if (!empty($domain)) {
|
||||
$normalizedDomains[] = $this->licenseManager->normalizeDomain($domain);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($normalizedDomains)) {
|
||||
$entry = [
|
||||
'product_id' => $productId,
|
||||
'domains' => $normalizedDomains,
|
||||
];
|
||||
|
||||
// Include variation_id if present
|
||||
if ($variationId > 0) {
|
||||
$entry['variation_id'] = $variationId;
|
||||
}
|
||||
|
||||
$domainData[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($domainData)) {
|
||||
$order->update_meta_data('_licensed_product_domains', $domainData);
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display domain in admin order view
|
||||
* Display domains in admin order view
|
||||
*/
|
||||
public function displayDomainInAdmin(\WC_Order $order): void
|
||||
{
|
||||
// Try new multi-domain format first
|
||||
$domainData = $order->get_meta('_licensed_product_domains');
|
||||
if (!empty($domainData) && is_array($domainData)) {
|
||||
$this->displayMultiDomainsInAdmin($domainData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to legacy single domain
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if (!$domain) {
|
||||
return;
|
||||
@@ -168,10 +504,54 @@ final class CheckoutController
|
||||
}
|
||||
|
||||
/**
|
||||
* Display domain in order emails
|
||||
* Display multi-domain data in admin
|
||||
*/
|
||||
private function displayMultiDomainsInAdmin(array $domainData): void
|
||||
{
|
||||
?>
|
||||
<div class="wclp-order-domains">
|
||||
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
|
||||
<?php foreach ($domainData as $item): ?>
|
||||
<?php
|
||||
$productId = $item['product_id'];
|
||||
$variationId = $item['variation_id'] ?? 0;
|
||||
|
||||
// Get product name
|
||||
if ($variationId > 0) {
|
||||
$variation = wc_get_product($variationId);
|
||||
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
|
||||
|
||||
// Add duration label if available
|
||||
if ($variation instanceof LicensedProductVariation) {
|
||||
$productName .= ' (' . $variation->get_license_duration_label() . ')';
|
||||
}
|
||||
} else {
|
||||
$product = wc_get_product($productId);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
?>
|
||||
<p style="margin: 5px 0 5px 15px;">
|
||||
<em><?php echo esc_html($productName); ?>:</em><br>
|
||||
<?php echo esc_html(implode(', ', $item['domains'])); ?>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Display domains in order emails
|
||||
*/
|
||||
public function displayDomainInEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText): void
|
||||
{
|
||||
// Try new multi-domain format first
|
||||
$domainData = $order->get_meta('_licensed_product_domains');
|
||||
if (!empty($domainData) && is_array($domainData)) {
|
||||
$this->displayMultiDomainsInEmail($domainData, $plainText);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to legacy single domain
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if (!$domain) {
|
||||
return;
|
||||
@@ -189,6 +569,60 @@ final class CheckoutController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display multi-domain data in email
|
||||
*/
|
||||
private function displayMultiDomainsInEmail(array $domainData, bool $plainText): void
|
||||
{
|
||||
if ($plainText) {
|
||||
echo "\n" . esc_html__('License Domains:', 'wc-licensed-product') . "\n";
|
||||
foreach ($domainData as $item) {
|
||||
$productId = $item['product_id'];
|
||||
$variationId = $item['variation_id'] ?? 0;
|
||||
|
||||
if ($variationId > 0) {
|
||||
$variation = wc_get_product($variationId);
|
||||
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
|
||||
if ($variation instanceof LicensedProductVariation) {
|
||||
$productName .= ' (' . $variation->get_license_duration_label() . ')';
|
||||
}
|
||||
} else {
|
||||
$product = wc_get_product($productId);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
echo ' ' . esc_html($productName) . ': ' . esc_html(implode(', ', $item['domains'])) . "\n";
|
||||
}
|
||||
} else {
|
||||
?>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
|
||||
<?php foreach ($domainData as $item): ?>
|
||||
<?php
|
||||
$productId = $item['product_id'];
|
||||
$variationId = $item['variation_id'] ?? 0;
|
||||
|
||||
if ($variationId > 0) {
|
||||
$variation = wc_get_product($variationId);
|
||||
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
|
||||
if ($variation instanceof LicensedProductVariation) {
|
||||
$productName .= ' (' . $variation->get_license_duration_label() . ')';
|
||||
}
|
||||
} else {
|
||||
$product = wc_get_product($productId);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
?>
|
||||
<p style="margin: 5px 0 5px 15px;">
|
||||
<em><?php echo esc_html($productName); ?>:</em><br>
|
||||
<?php echo esc_html(implode(', ', $item['domains'])); ?>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate domain format
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
|
||||
use Automattic\WooCommerce\StoreApi\StoreApi;
|
||||
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
|
||||
/**
|
||||
@@ -70,6 +71,12 @@ final class StoreApiExtension
|
||||
*/
|
||||
public function getExtensionData(): array
|
||||
{
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
return [
|
||||
'licensed_product_domains' => WC()->session ? WC()->session->get('licensed_product_domains', []) : [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '',
|
||||
];
|
||||
@@ -80,6 +87,34 @@ final class StoreApiExtension
|
||||
*/
|
||||
public function getExtensionSchema(): array
|
||||
{
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
return [
|
||||
'licensed_product_domains' => [
|
||||
'description' => __('Domains for license activation by product', 'wc-licensed-product'),
|
||||
'type' => 'array',
|
||||
'context' => ['view', 'edit'],
|
||||
'readonly' => false,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'product_id' => [
|
||||
'type' => 'integer',
|
||||
],
|
||||
'variation_id' => [
|
||||
'type' => 'integer',
|
||||
],
|
||||
'domains' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'licensed_product_domain' => [
|
||||
'description' => __('Domain for license activation', 'wc-licensed-product'),
|
||||
@@ -95,32 +130,113 @@ final class StoreApiExtension
|
||||
*/
|
||||
public function handleExtensionUpdate(array $data): void
|
||||
{
|
||||
if (isset($data['licensed_product_domain'])) {
|
||||
$domain = sanitize_text_field($data['licensed_product_domain']);
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
// Multi-domain mode
|
||||
if (isset($data['licensed_product_domains']) && is_array($data['licensed_product_domains'])) {
|
||||
$normalizedData = $this->normalizeDomainsData($data['licensed_product_domains']);
|
||||
|
||||
if (WC()->session) {
|
||||
WC()->session->set('licensed_product_domain', $normalizedDomain);
|
||||
WC()->session->set('licensed_product_domains', $normalizedData);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single domain mode
|
||||
if (isset($data['licensed_product_domain'])) {
|
||||
$sanitized = sanitize_text_field($data['licensed_product_domain']);
|
||||
$normalized = $this->licenseManager->normalizeDomain($sanitized);
|
||||
|
||||
if (WC()->session) {
|
||||
WC()->session->set('licensed_product_domain', $normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the checkout order - save domain to order meta
|
||||
* Normalize domains data from frontend
|
||||
*/
|
||||
private function normalizeDomainsData(array $domainsData): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($domainsData as $item) {
|
||||
if (!isset($item['product_id']) || !isset($item['domains']) || !is_array($item['domains'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productId = (int) $item['product_id'];
|
||||
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
|
||||
$domains = [];
|
||||
|
||||
foreach ($item['domains'] as $domain) {
|
||||
$sanitized = sanitize_text_field($domain);
|
||||
if (!empty($sanitized)) {
|
||||
$domains[] = $this->licenseManager->normalizeDomain($sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($domains)) {
|
||||
$entry = [
|
||||
'product_id' => $productId,
|
||||
'domains' => $domains,
|
||||
];
|
||||
|
||||
// Include variation_id if present
|
||||
if ($variationId > 0) {
|
||||
$entry['variation_id'] = $variationId;
|
||||
}
|
||||
|
||||
$normalized[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the checkout order - save domains to order meta
|
||||
*/
|
||||
public function processCheckoutOrder(\WC_Order $order): void
|
||||
{
|
||||
$domain = WC()->session ? WC()->session->get('licensed_product_domain', '') : '';
|
||||
|
||||
// Also check in the request data for block checkout
|
||||
if (empty($domain)) {
|
||||
$requestData = json_decode(file_get_contents('php://input'), true);
|
||||
if (isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain'])) {
|
||||
$domain = sanitize_text_field($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain']);
|
||||
$domain = $this->licenseManager->normalizeDomain($domain);
|
||||
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->processMultiDomainOrder($order, $requestData);
|
||||
} else {
|
||||
$this->processSingleDomainOrder($order, $requestData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process order in single domain mode (legacy)
|
||||
*/
|
||||
private function processSingleDomainOrder(\WC_Order $order, ?array $requestData): void
|
||||
{
|
||||
$domain = '';
|
||||
|
||||
// Check session first
|
||||
if (WC()->session) {
|
||||
$domain = WC()->session->get('licensed_product_domain', '');
|
||||
}
|
||||
|
||||
// Check in the request data for block checkout (extension data)
|
||||
if (empty($domain) && isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain'])) {
|
||||
$sanitized = sanitize_text_field($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain']);
|
||||
$domain = $this->licenseManager->normalizeDomain($sanitized);
|
||||
}
|
||||
|
||||
// Check for wclp_license_domain (from our hidden input)
|
||||
if (empty($domain) && isset($requestData['wclp_license_domain'])) {
|
||||
$sanitized = sanitize_text_field($requestData['wclp_license_domain']);
|
||||
$domain = $this->licenseManager->normalizeDomain($sanitized);
|
||||
}
|
||||
|
||||
// Check for additional_fields (WC Blocks API)
|
||||
if (empty($domain) && isset($requestData['additional_fields']['wc-licensed-product/domain'])) {
|
||||
$sanitized = sanitize_text_field($requestData['additional_fields']['wc-licensed-product/domain']);
|
||||
$domain = $this->licenseManager->normalizeDomain($sanitized);
|
||||
}
|
||||
|
||||
if (!empty($domain)) {
|
||||
$order->update_meta_data('_licensed_product_domain', $domain);
|
||||
$order->save();
|
||||
@@ -131,4 +247,84 @@ final class StoreApiExtension
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process order in multi-domain mode
|
||||
*/
|
||||
private function processMultiDomainOrder(\WC_Order $order, ?array $requestData): void
|
||||
{
|
||||
$domainData = [];
|
||||
|
||||
// Check session first
|
||||
if (WC()->session) {
|
||||
$domainData = WC()->session->get('licensed_product_domains', []);
|
||||
}
|
||||
|
||||
// Check in the request data for block checkout (extension data)
|
||||
if (empty($domainData) && isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domains'])) {
|
||||
$domainData = $this->normalizeDomainsData(
|
||||
$requestData['extensions'][self::IDENTIFIER]['licensed_product_domains']
|
||||
);
|
||||
}
|
||||
|
||||
// Check for wclp_license_domains (from our hidden input - JSON string)
|
||||
if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
|
||||
$parsed = json_decode($requestData['wclp_license_domains'], true);
|
||||
if (is_array($parsed)) {
|
||||
$domainData = $this->normalizeDomainsData($parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for licensed_domains in classic format (from DOM injection)
|
||||
if (empty($domainData) && isset($requestData['licensed_domains']) && is_array($requestData['licensed_domains'])) {
|
||||
$domainData = [];
|
||||
$variationIds = $requestData['licensed_variation_ids'] ?? [];
|
||||
|
||||
foreach ($requestData['licensed_domains'] as $key => $domains) {
|
||||
if (!is_array($domains)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key - could be "productId" or "productId_variationId"
|
||||
$parts = explode('_', (string) $key);
|
||||
$productId = (int) $parts[0];
|
||||
$variationId = isset($parts[1]) ? (int) $parts[1] : 0;
|
||||
|
||||
// Also check for hidden variation ID field
|
||||
if ($variationId === 0 && isset($variationIds[$key])) {
|
||||
$variationId = (int) $variationIds[$key];
|
||||
}
|
||||
|
||||
$normalizedDomains = [];
|
||||
foreach ($domains as $domain) {
|
||||
$sanitized = sanitize_text_field($domain);
|
||||
if (!empty($sanitized)) {
|
||||
$normalizedDomains[] = $this->licenseManager->normalizeDomain($sanitized);
|
||||
}
|
||||
}
|
||||
if (!empty($normalizedDomains)) {
|
||||
$entry = [
|
||||
'product_id' => $productId,
|
||||
'domains' => $normalizedDomains,
|
||||
];
|
||||
|
||||
if ($variationId > 0) {
|
||||
$entry['variation_id'] = $variationId;
|
||||
}
|
||||
|
||||
$domainData[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($domainData)) {
|
||||
$order->update_meta_data('_licensed_product_domains', $domainData);
|
||||
$order->save();
|
||||
|
||||
// Clear session data
|
||||
if (WC()->session) {
|
||||
WC()->session->set('licensed_product_domains', []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ final class LicenseEmailController
|
||||
public function registerEmailClasses(array $email_classes): array
|
||||
{
|
||||
$email_classes['WCLP_License_Expiration'] = new LicenseExpirationEmail();
|
||||
$email_classes['WCLP_License_Expired'] = new LicenseExpiredEmail();
|
||||
return $email_classes;
|
||||
}
|
||||
|
||||
@@ -69,10 +70,13 @@ final class LicenseEmailController
|
||||
}
|
||||
|
||||
/**
|
||||
* Send expiration warning emails
|
||||
* Send expiration warning emails and auto-expire licenses
|
||||
*/
|
||||
public function sendExpirationWarnings(): void
|
||||
{
|
||||
// First, auto-expire licenses that have passed their expiration date
|
||||
$this->autoExpireAndNotify();
|
||||
|
||||
// Check if expiration emails are enabled in settings
|
||||
if (!SettingsController::isExpirationEmailsEnabled()) {
|
||||
return;
|
||||
@@ -107,6 +111,41 @@ final class LicenseEmailController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-expire licenses and send expired notifications
|
||||
*/
|
||||
private function autoExpireAndNotify(): void
|
||||
{
|
||||
// Get licenses that should be auto-expired
|
||||
$expiredActiveLicenses = $this->licenseManager->getExpiredActiveLicenses();
|
||||
|
||||
if (empty($expiredActiveLicenses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the WooCommerce email instance for expired notifications
|
||||
$mailer = WC()->mailer();
|
||||
$emails = $mailer->get_emails();
|
||||
|
||||
/** @var LicenseExpiredEmail|null $expiredEmail */
|
||||
$expiredEmail = $emails['WCLP_License_Expired'] ?? null;
|
||||
|
||||
foreach ($expiredActiveLicenses as $license) {
|
||||
// Auto-expire the license
|
||||
$wasExpired = $this->licenseManager->autoExpireLicense($license->getId());
|
||||
|
||||
if ($wasExpired && $expiredEmail && $expiredEmail->is_enabled()) {
|
||||
// Check if we haven't already sent an expired notification
|
||||
if (!$this->licenseManager->wasExpirationNotified($license->getId(), 'license_expired')) {
|
||||
// Send expired notification email
|
||||
if ($expiredEmail->trigger($license)) {
|
||||
$this->licenseManager->markExpirationNotified($license->getId(), 'license_expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and send expiration warnings for a specific time frame
|
||||
*
|
||||
@@ -155,7 +194,7 @@ final class LicenseEmailController
|
||||
}
|
||||
|
||||
/**
|
||||
* Add license key to order item in email
|
||||
* Add license key(s) to order item in email
|
||||
*/
|
||||
public function addLicenseToOrderItem(int $itemId, \WC_Order_Item $item, \WC_Order $order, bool $plainText): void
|
||||
{
|
||||
@@ -164,85 +203,106 @@ final class LicenseEmailController
|
||||
return;
|
||||
}
|
||||
|
||||
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
|
||||
if (!$license) {
|
||||
$licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
|
||||
if (empty($licenses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($plainText) {
|
||||
echo "\n" . esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($license->getLicenseKey()) . "\n";
|
||||
echo "\n" . esc_html__('License Keys:', 'wc-licensed-product') . "\n";
|
||||
foreach ($licenses as $license) {
|
||||
echo ' - ' . esc_html($license->getLicenseKey());
|
||||
echo ' (' . esc_html($license->getDomain()) . ')' . "\n";
|
||||
}
|
||||
} else {
|
||||
?>
|
||||
<div style="margin-top: 10px; padding: 10px; background-color: #f8f9fa; border-left: 3px solid #7f54b3;">
|
||||
<strong><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></strong>
|
||||
<code style="display: block; margin-top: 5px; padding: 5px; background: #fff; font-family: monospace;">
|
||||
<strong><?php esc_html_e('License Keys:', 'wc-licensed-product'); ?></strong>
|
||||
<?php foreach ($licenses as $license) : ?>
|
||||
<div style="margin-top: 5px; padding: 5px; background: #fff;">
|
||||
<code style="font-family: monospace;">
|
||||
<?php echo esc_html($license->getLicenseKey()); ?>
|
||||
</code>
|
||||
<span style="color: #666; margin-left: 10px;">
|
||||
<?php echo esc_html($license->getDomain()); ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses for an order
|
||||
* Get all licenses for an order grouped by product
|
||||
*
|
||||
* @return array Array of products with their licenses
|
||||
*/
|
||||
private function getLicensesForOrder(\WC_Order $order): array
|
||||
{
|
||||
$licenses = [];
|
||||
$products = [];
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
|
||||
if ($license) {
|
||||
$licenses[] = [
|
||||
'license' => $license,
|
||||
$licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
|
||||
if (!empty($licenses)) {
|
||||
$products[] = [
|
||||
'product_name' => $product->get_name(),
|
||||
'licenses' => $licenses,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $licenses;
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render license info in HTML format
|
||||
*/
|
||||
private function renderHtmlLicenseInfo(array $licenses, \WC_Order $order): void
|
||||
private function renderHtmlLicenseInfo(array $products, \WC_Order $order): void
|
||||
{
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
?>
|
||||
<div style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e5e5e5; border-radius: 4px;">
|
||||
<h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2>
|
||||
|
||||
<?php if ($domain) : ?>
|
||||
<p style="margin-bottom: 15px;">
|
||||
<strong><?php esc_html_e('Licensed Domain:', 'wc-licensed-product'); ?></strong>
|
||||
<?php echo esc_html($domain); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($products as $product) : ?>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="margin: 0 0 10px 0; font-size: 1.1em; color: #333;">
|
||||
<?php echo esc_html($product['product_name']); ?>
|
||||
<span style="font-weight: normal; color: #666; font-size: 0.9em;">
|
||||
(<?php
|
||||
printf(
|
||||
esc_html(_n('%d license', '%d licenses', count($product['licenses']), 'wc-licensed-product')),
|
||||
count($product['licenses'])
|
||||
);
|
||||
?>)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<table style="width: 100%; border-collapse: collapse; background: #fff;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
|
||||
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
|
||||
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
||||
<th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
|
||||
<th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
|
||||
<th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($licenses as $item) : ?>
|
||||
<?php foreach ($product['licenses'] as $license) : ?>
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;"><?php echo esc_html($item['product_name']); ?></td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">
|
||||
<code style="background: #fff; padding: 3px 6px; font-family: monospace;">
|
||||
<?php echo esc_html($item['license']->getLicenseKey()); ?>
|
||||
<td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
|
||||
<code style="background: #f5f5f5; padding: 3px 6px; font-family: monospace; font-size: 0.9em;">
|
||||
<?php echo esc_html($license->getLicenseKey()); ?>
|
||||
</code>
|
||||
</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
|
||||
<?php echo esc_html($license->getDomain()); ?>
|
||||
</td>
|
||||
<td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
|
||||
<?php
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
$expiresAt = $license->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
@@ -252,6 +312,8 @@ final class LicenseEmailController
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<p style="margin-top: 15px; margin-bottom: 0; font-size: 0.9em; color: #666;">
|
||||
<?php esc_html_e('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product'); ?>
|
||||
@@ -263,30 +325,34 @@ final class LicenseEmailController
|
||||
/**
|
||||
* Render license info in plain text format
|
||||
*/
|
||||
private function renderPlainTextLicenseInfo(array $licenses, \WC_Order $order): void
|
||||
private function renderPlainTextLicenseInfo(array $products, \WC_Order $order): void
|
||||
{
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
|
||||
echo "\n\n";
|
||||
echo "==========================================================\n";
|
||||
echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
|
||||
echo "==========================================================\n\n";
|
||||
|
||||
if ($domain) {
|
||||
echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n";
|
||||
}
|
||||
foreach ($products as $product) {
|
||||
echo esc_html($product['product_name']);
|
||||
echo ' (' . count($product['licenses']) . ' ' .
|
||||
_n('license', 'licenses', count($product['licenses']), 'wc-licensed-product') . ')';
|
||||
echo "\n";
|
||||
echo "-----------------------------------------------------------\n";
|
||||
|
||||
foreach ($licenses as $item) {
|
||||
echo esc_html($item['product_name']) . "\n";
|
||||
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n";
|
||||
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
foreach ($product['licenses'] as $license) {
|
||||
echo esc_html__('License Key:', 'wc-licensed-product') . ' ';
|
||||
echo esc_html($license->getLicenseKey()) . "\n";
|
||||
echo esc_html__('Domain:', 'wc-licensed-product') . ' ';
|
||||
echo esc_html($license->getDomain()) . "\n";
|
||||
echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
|
||||
|
||||
$expiresAt = $license->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
echo "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";
|
||||
echo "==========================================================\n\n";
|
||||
|
||||
335
src/Email/LicenseExpiredEmail.php
Normal file
335
src/Email/LicenseExpiredEmail.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
/**
|
||||
* License Expired Email
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Email
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Email;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\License;
|
||||
use WC_Email;
|
||||
|
||||
/**
|
||||
* License Expired Email class
|
||||
*
|
||||
* Sends email notifications to customers when their licenses have expired.
|
||||
* Uses WooCommerce's transactional email system for consistent styling and customization.
|
||||
*/
|
||||
class LicenseExpiredEmail extends WC_Email
|
||||
{
|
||||
/**
|
||||
* License object
|
||||
*/
|
||||
public ?License $license = null;
|
||||
|
||||
/**
|
||||
* Product name
|
||||
*/
|
||||
public string $product_name = '';
|
||||
|
||||
/**
|
||||
* Expiration date formatted
|
||||
*/
|
||||
public string $expiration_date = '';
|
||||
|
||||
/**
|
||||
* Customer display name
|
||||
*/
|
||||
public string $customer_name = '';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->id = 'wclp_license_expired';
|
||||
$this->customer_email = true;
|
||||
$this->title = __('License Expired', 'wc-licensed-product');
|
||||
$this->description = __('License expired emails are sent to customers when their licenses have expired.', 'wc-licensed-product');
|
||||
|
||||
$this->placeholders = [
|
||||
'{site_title}' => $this->get_blogname(),
|
||||
'{product_name}' => '',
|
||||
'{expiration_date}' => '',
|
||||
];
|
||||
|
||||
// Call parent constructor
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email subject
|
||||
*/
|
||||
public function get_default_subject(): string
|
||||
{
|
||||
return __('[{site_title}] Your license for {product_name} has expired', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email heading
|
||||
*/
|
||||
public function get_default_heading(): string
|
||||
{
|
||||
return __('License Expired', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the email
|
||||
*
|
||||
* @param License $license License object
|
||||
*/
|
||||
public function trigger(License $license): bool
|
||||
{
|
||||
$this->setup_locale();
|
||||
|
||||
$customer = get_userdata($license->getCustomerId());
|
||||
if (!$customer || !$customer->user_email) {
|
||||
$this->restore_locale();
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->license = $license;
|
||||
$this->recipient = $customer->user_email;
|
||||
$this->customer_name = $customer->display_name ?: __('Customer', 'wc-licensed-product');
|
||||
|
||||
$product = wc_get_product($license->getProductId());
|
||||
$this->product_name = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
|
||||
$expiresAt = $license->getExpiresAt();
|
||||
$this->expiration_date = $expiresAt ? $expiresAt->format(get_option('date_format')) : '';
|
||||
|
||||
// Update placeholders
|
||||
$this->placeholders['{product_name}'] = $this->product_name;
|
||||
$this->placeholders['{expiration_date}'] = $this->expiration_date;
|
||||
|
||||
if (!$this->is_enabled() || !$this->get_recipient()) {
|
||||
$this->restore_locale();
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->send(
|
||||
$this->get_recipient(),
|
||||
$this->get_subject(),
|
||||
$this->get_content(),
|
||||
$this->get_headers(),
|
||||
$this->get_attachments()
|
||||
);
|
||||
|
||||
$this->restore_locale();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content HTML
|
||||
*/
|
||||
public function get_content_html(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
// Use WooCommerce's email header
|
||||
wc_get_template('emails/email-header.php', ['email_heading' => $this->get_heading()]);
|
||||
|
||||
$this->render_email_body_html();
|
||||
|
||||
// Use WooCommerce's email footer
|
||||
wc_get_template('emails/email-footer.php', ['email' => $this]);
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content plain text
|
||||
*/
|
||||
public function get_content_plain(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
|
||||
echo esc_html(wp_strip_all_tags($this->get_heading()));
|
||||
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
|
||||
|
||||
$this->render_email_body_plain();
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render HTML email body content
|
||||
*/
|
||||
private function render_email_body_html(): void
|
||||
{
|
||||
$account_url = wc_get_account_endpoint_url('licenses');
|
||||
?>
|
||||
<p><?php printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name)); ?></p>
|
||||
|
||||
<p style="color: #dc3232; font-weight: 600;">
|
||||
<?php printf(
|
||||
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
|
||||
'<strong>' . esc_html($this->product_name) . '</strong>',
|
||||
esc_html($this->expiration_date)
|
||||
); ?>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<?php esc_html_e('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
|
||||
<h2><?php esc_html_e('Expired License Details', 'wc-licensed-product'); ?></h2>
|
||||
|
||||
<div style="margin-bottom: 40px;">
|
||||
<table class="td" cellspacing="0" cellpadding="6" style="width: 100%; font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif;" border="1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Product:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->product_name); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
|
||||
<code style="background: #f5f5f5; padding: 3px 8px; border-radius: 3px; font-family: monospace;">
|
||||
<?php echo esc_html($this->license->getLicenseKey()); ?>
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->license->getDomain()); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Expired on:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>; color: #dc3232; font-weight: 600;"><?php echo esc_html($this->expiration_date); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Status:', 'wc-licensed-product'); ?></th>
|
||||
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
|
||||
<span style="background: #f8d7da; color: #721c24; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
||||
<?php esc_html_e('Expired', 'wc-licensed-product'); ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$additional_content = $this->get_additional_content();
|
||||
if ($additional_content) :
|
||||
?>
|
||||
<p><?php echo wp_kses_post($additional_content); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<p style="margin-top: 25px;">
|
||||
<a href="<?php echo esc_url($account_url); ?>" class="button" style="display: inline-block; background-color: #7f54b3; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: 600;">
|
||||
<?php esc_html_e('View My Licenses', 'wc-licensed-product'); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render plain text email body content
|
||||
*/
|
||||
private function render_email_body_plain(): void
|
||||
{
|
||||
printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name));
|
||||
echo "\n\n";
|
||||
|
||||
printf(
|
||||
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
|
||||
esc_html($this->product_name),
|
||||
esc_html($this->expiration_date)
|
||||
);
|
||||
echo "\n\n";
|
||||
|
||||
echo esc_html__('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product');
|
||||
echo "\n\n";
|
||||
|
||||
echo "----------\n";
|
||||
echo esc_html__('Expired License Details', 'wc-licensed-product') . "\n";
|
||||
echo "----------\n\n";
|
||||
|
||||
echo esc_html__('Product:', 'wc-licensed-product') . ' ' . esc_html($this->product_name) . "\n";
|
||||
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($this->license->getLicenseKey()) . "\n";
|
||||
echo esc_html__('Domain:', 'wc-licensed-product') . ' ' . esc_html($this->license->getDomain()) . "\n";
|
||||
echo esc_html__('Expired on:', 'wc-licensed-product') . ' ' . esc_html($this->expiration_date) . "\n";
|
||||
echo esc_html__('Status:', 'wc-licensed-product') . ' ' . esc_html__('Expired', 'wc-licensed-product') . "\n\n";
|
||||
|
||||
$additional_content = $this->get_additional_content();
|
||||
if ($additional_content) {
|
||||
echo "----------\n\n";
|
||||
echo esc_html(wp_strip_all_tags(wptexturize($additional_content)));
|
||||
echo "\n\n";
|
||||
}
|
||||
|
||||
echo esc_html__('View My Licenses', 'wc-licensed-product') . ': ' . esc_url(wc_get_account_endpoint_url('licenses')) . "\n\n";
|
||||
|
||||
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Default content to show below main email content
|
||||
*/
|
||||
public function get_default_additional_content(): string
|
||||
{
|
||||
return __('To continue using this product, please renew your license.', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize settings form fields
|
||||
*/
|
||||
public function init_form_fields(): void
|
||||
{
|
||||
$placeholder_text = sprintf(
|
||||
/* translators: %s: list of placeholders */
|
||||
__('Available placeholders: %s', 'wc-licensed-product'),
|
||||
'<code>{site_title}, {product_name}, {expiration_date}</code>'
|
||||
);
|
||||
|
||||
$this->form_fields = [
|
||||
'enabled' => [
|
||||
'title' => __('Enable/Disable', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'label' => __('Enable this email notification', 'wc-licensed-product'),
|
||||
'default' => 'yes',
|
||||
],
|
||||
'subject' => [
|
||||
'title' => __('Subject', 'wc-licensed-product'),
|
||||
'type' => 'text',
|
||||
'desc_tip' => true,
|
||||
'description' => $placeholder_text,
|
||||
'placeholder' => $this->get_default_subject(),
|
||||
'default' => '',
|
||||
],
|
||||
'heading' => [
|
||||
'title' => __('Email heading', 'wc-licensed-product'),
|
||||
'type' => 'text',
|
||||
'desc_tip' => true,
|
||||
'description' => $placeholder_text,
|
||||
'placeholder' => $this->get_default_heading(),
|
||||
'default' => '',
|
||||
],
|
||||
'additional_content' => [
|
||||
'title' => __('Additional content', 'wc-licensed-product'),
|
||||
'description' => __('Text to appear below the main email content.', 'wc-licensed-product') . ' ' . $placeholder_text,
|
||||
'css' => 'width:400px; height: 75px;',
|
||||
'placeholder' => $this->get_default_additional_content(),
|
||||
'type' => 'textarea',
|
||||
'default' => '',
|
||||
'desc_tip' => true,
|
||||
],
|
||||
'email_type' => [
|
||||
'title' => __('Email type', 'wc-licensed-product'),
|
||||
'type' => 'select',
|
||||
'description' => __('Choose which format of email to send.', 'wc-licensed-product'),
|
||||
'default' => 'html',
|
||||
'class' => 'email_type wc-enhanced-select',
|
||||
'options' => $this->get_email_type_options(),
|
||||
'desc_tip' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Twig\Environment;
|
||||
@@ -107,16 +108,92 @@ final class AccountController
|
||||
|
||||
$licenses = $this->licenseManager->getLicensesByCustomer($customerId);
|
||||
|
||||
// Enrich licenses with product data and downloads
|
||||
$enrichedLicenses = [];
|
||||
foreach ($licenses as $license) {
|
||||
$product = wc_get_product($license->getProductId());
|
||||
$order = wc_get_order($license->getOrderId());
|
||||
// Group licenses by product+order into "packages"
|
||||
$packages = $this->groupLicensesIntoPackages($licenses);
|
||||
|
||||
// Get available downloads for this license
|
||||
$downloads = [];
|
||||
try {
|
||||
echo $this->twig->render('frontend/licenses.html.twig', [
|
||||
'packages' => $packages,
|
||||
'has_packages' => !empty($packages),
|
||||
'signing_enabled' => ResponseSigner::isSigningEnabled(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to PHP template if Twig fails
|
||||
$this->displayLicensesFallback($packages);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group licenses into packages by product+order
|
||||
*
|
||||
* @param array $licenses Array of License objects
|
||||
* @return array Array of package data
|
||||
*/
|
||||
private function groupLicensesIntoPackages(array $licenses): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$productId = $license->getProductId();
|
||||
$orderId = $license->getOrderId();
|
||||
$key = $productId . '_' . $orderId;
|
||||
|
||||
if (!isset($grouped[$key])) {
|
||||
$product = wc_get_product($productId);
|
||||
$order = wc_get_order($orderId);
|
||||
|
||||
$grouped[$key] = [
|
||||
'product_id' => $productId,
|
||||
'order_id' => $orderId,
|
||||
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||
'product_url' => $product ? $product->get_permalink() : '',
|
||||
'order_number' => $order ? $order->get_order_number() : '',
|
||||
'order_url' => $order ? $order->get_view_order_url() : '',
|
||||
'licenses' => [],
|
||||
'downloads' => [],
|
||||
'has_active_license' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// Add license to package
|
||||
$grouped[$key]['licenses'][] = [
|
||||
'id' => $license->getId(),
|
||||
'license_key' => $license->getLicenseKey(),
|
||||
'domain' => $license->getDomain(),
|
||||
'status' => $license->getStatus(),
|
||||
'expires_at' => $license->getExpiresAt(),
|
||||
'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true),
|
||||
'customer_secret' => ResponseSigner::getCustomerSecretForLicense($license->getLicenseKey()),
|
||||
];
|
||||
|
||||
// Track if package has at least one active license
|
||||
if ($license->getStatus() === 'active') {
|
||||
$versions = $this->versionManager->getVersionsByProduct($license->getProductId());
|
||||
$grouped[$key]['has_active_license'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add downloads for packages with active licenses
|
||||
foreach ($grouped as $key => &$package) {
|
||||
if ($package['has_active_license']) {
|
||||
$package['downloads'] = $this->getDownloadsForProduct(
|
||||
$package['product_id'],
|
||||
$package['licenses'][0]['id'] // Use first license for download URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by order date (newest first) - re-index array
|
||||
return array_values($grouped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get downloads for a product
|
||||
*/
|
||||
private function getDownloadsForProduct(int $productId, int $licenseId): array
|
||||
{
|
||||
$downloads = [];
|
||||
$versions = $this->versionManager->getVersionsByProduct($productId);
|
||||
|
||||
foreach ($versions as $version) {
|
||||
if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
|
||||
$downloads[] = [
|
||||
@@ -124,120 +201,160 @@ final class AccountController
|
||||
'version_id' => $version->getId(),
|
||||
'filename' => $version->getDownloadFilename(),
|
||||
'download_url' => $this->downloadController->generateDownloadUrl(
|
||||
$license->getId(),
|
||||
$licenseId,
|
||||
$version->getId()
|
||||
),
|
||||
'release_notes' => $version->getReleaseNotes(),
|
||||
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
|
||||
'file_hash' => $version->getFileHash(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$enrichedLicenses[] = [
|
||||
'license' => $license,
|
||||
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||
'product_url' => $product ? $product->get_permalink() : '',
|
||||
'order_number' => $order ? $order->get_order_number() : '',
|
||||
'order_url' => $order ? $order->get_view_order_url() : '',
|
||||
'downloads' => $downloads,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
echo $this->twig->render('frontend/licenses.html.twig', [
|
||||
'licenses' => $enrichedLicenses,
|
||||
'has_licenses' => !empty($enrichedLicenses),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to PHP template if Twig fails
|
||||
$this->displayLicensesFallback($enrichedLicenses);
|
||||
}
|
||||
return $downloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback display method if Twig is unavailable
|
||||
*/
|
||||
private function displayLicensesFallback(array $enrichedLicenses): void
|
||||
private function displayLicensesFallback(array $packages): void
|
||||
{
|
||||
if (empty($enrichedLicenses)) {
|
||||
if (empty($packages)) {
|
||||
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<div class="woocommerce-licenses">
|
||||
<?php foreach ($enrichedLicenses as $item): ?>
|
||||
<div class="license-card">
|
||||
<div class="license-header">
|
||||
<?php foreach ($packages as $package): ?>
|
||||
<div class="license-package">
|
||||
<div class="package-header">
|
||||
<h3>
|
||||
<?php if ($item['product_url']): ?>
|
||||
<a href="<?php echo esc_url($item['product_url']); ?>">
|
||||
<?php echo esc_html($item['product_name']); ?>
|
||||
<?php if ($package['product_url']): ?>
|
||||
<a href="<?php echo esc_url($package['product_url']); ?>">
|
||||
<?php echo esc_html($package['product_name']); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php echo esc_html($item['product_name']); ?>
|
||||
<?php echo esc_html($package['product_name']); ?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>">
|
||||
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?>
|
||||
<span class="package-order">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: order number */
|
||||
esc_html__('Order #%s', 'wc-licensed-product'),
|
||||
esc_html($package['order_number'])
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="license-details">
|
||||
<div class="license-key-row">
|
||||
<label><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></label>
|
||||
<code class="license-key" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>">
|
||||
<?php echo esc_html($item['license']->getLicenseKey()); ?>
|
||||
</code>
|
||||
<button type="button" class="copy-license-btn" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>">
|
||||
<div class="package-licenses">
|
||||
<?php foreach ($package['licenses'] as $license): ?>
|
||||
<div class="license-entry license-entry-<?php echo esc_attr($license['status']); ?>">
|
||||
<div class="license-row-primary">
|
||||
<div class="license-key-group">
|
||||
<code class="license-key"><?php echo esc_html($license['license_key']); ?></code>
|
||||
<span class="license-status license-status-<?php echo esc_attr($license['status']); ?>">
|
||||
<?php echo esc_html(ucfirst($license['status'])); ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="license-actions">
|
||||
<button type="button" class="copy-license-btn" data-license-key="<?php echo esc_attr($license['license_key']); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>">
|
||||
<span class="dashicons dashicons-clipboard"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="license-info-row">
|
||||
<span class="license-domain-display" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>">
|
||||
<strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong>
|
||||
<span class="domain-value"><?php echo esc_html($item['license']->getDomain()); ?></span>
|
||||
<?php if (in_array($item['license']->getStatus(), ['active', 'inactive'], true)): ?>
|
||||
<?php if ($license['is_transferable']): ?>
|
||||
<button type="button" class="wclp-transfer-btn"
|
||||
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
|
||||
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
|
||||
data-license-id="<?php echo esc_attr($license['id']); ?>"
|
||||
data-current-domain="<?php echo esc_attr($license['domain']); ?>"
|
||||
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
|
||||
<span class="dashicons dashicons-randomize"></span>
|
||||
<?php esc_html_e('Transfer', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="license-row-secondary">
|
||||
<span class="license-meta-item license-domain">
|
||||
<span class="dashicons dashicons-admin-site-alt3"></span>
|
||||
<?php echo esc_html($license['domain']); ?>
|
||||
</span>
|
||||
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong>
|
||||
<span class="license-meta-item license-expiry">
|
||||
<span class="dashicons dashicons-calendar-alt"></span>
|
||||
<?php
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
echo $license['expires_at']
|
||||
? esc_html($license['expires_at']->format('Y-m-d'))
|
||||
: '<span class="lifetime">' . esc_html__('Lifetime', 'wc-licensed-product') . '</span>';
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($item['downloads'])): ?>
|
||||
<div class="license-downloads">
|
||||
<?php if (!empty($package['downloads'])): ?>
|
||||
<div class="package-downloads">
|
||||
<h4><?php esc_html_e('Available Downloads', 'wc-licensed-product'); ?></h4>
|
||||
<ul class="download-list">
|
||||
<?php foreach ($item['downloads'] as $download): ?>
|
||||
<li>
|
||||
<?php
|
||||
$latest = $package['downloads'][0];
|
||||
?>
|
||||
<li class="download-item download-item-latest">
|
||||
<div class="download-row-file">
|
||||
<a href="<?php echo esc_url($latest['download_url']); ?>" class="download-link">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
<?php echo esc_html($latest['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $latest['version'])); ?>
|
||||
</a>
|
||||
<span class="download-version-badge"><?php esc_html_e('Latest', 'wc-licensed-product'); ?></span>
|
||||
</div>
|
||||
<div class="download-row-meta">
|
||||
<span class="download-date"><?php echo esc_html($latest['released_at']); ?></span>
|
||||
<?php if (!empty($latest['file_hash'])): ?>
|
||||
<span class="download-hash" title="<?php echo esc_attr($latest['file_hash']); ?>">
|
||||
<span class="dashicons dashicons-shield"></span>
|
||||
<code><?php echo esc_html(substr($latest['file_hash'], 0, 12)); ?>...</code>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<?php if (count($package['downloads']) > 1): ?>
|
||||
<div class="older-versions-section">
|
||||
<button type="button" class="older-versions-toggle" aria-expanded="false">
|
||||
<span class="dashicons dashicons-arrow-down-alt2"></span>
|
||||
<?php
|
||||
printf(
|
||||
esc_html__('Older versions (%d)', 'wc-licensed-product'),
|
||||
count($package['downloads']) - 1
|
||||
);
|
||||
?>
|
||||
</button>
|
||||
<ul class="download-list older-versions-list" style="display: none;">
|
||||
<?php foreach (array_slice($package['downloads'], 1) as $download): ?>
|
||||
<li class="download-item">
|
||||
<div class="download-row-file">
|
||||
<a href="<?php echo esc_url($download['download_url']); ?>" class="download-link">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
<?php echo esc_html($download['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $download['version'])); ?>
|
||||
</a>
|
||||
<span class="download-version">v<?php echo esc_html($download['version']); ?></span>
|
||||
</div>
|
||||
<div class="download-row-meta">
|
||||
<span class="download-date"><?php echo esc_html($download['released_at']); ?></span>
|
||||
<?php if (!empty($download['file_hash'])): ?>
|
||||
<span class="download-hash" title="<?php echo esc_attr($download['file_hash']); ?>">
|
||||
<span class="dashicons dashicons-shield"></span>
|
||||
<code><?php echo esc_html(substr($download['file_hash'], 0, 12)); ?>...</code>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ final class DownloadController
|
||||
// Add download endpoint
|
||||
add_action('init', [$this, 'addDownloadEndpoint']);
|
||||
|
||||
// Register query var for the endpoint
|
||||
add_filter('query_vars', [$this, 'addDownloadQueryVar']);
|
||||
|
||||
// Handle download requests
|
||||
add_action('template_redirect', [$this, 'handleDownloadRequest']);
|
||||
}
|
||||
@@ -47,6 +50,15 @@ final class DownloadController
|
||||
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the download query var
|
||||
*/
|
||||
public function addDownloadQueryVar(array $vars): array
|
||||
{
|
||||
$vars[] = 'license-download';
|
||||
return $vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle download request
|
||||
*/
|
||||
@@ -160,8 +172,12 @@ final class DownloadController
|
||||
$downloadUrl = $version->getDownloadUrl();
|
||||
|
||||
if ($attachmentId) {
|
||||
// Increment download count before serving
|
||||
$this->versionManager->incrementDownloadCount($versionId);
|
||||
$this->serveAttachment($attachmentId, $version->getVersion());
|
||||
} elseif ($downloadUrl) {
|
||||
// Increment download count before redirect
|
||||
$this->versionManager->incrementDownloadCount($versionId);
|
||||
// Redirect to external URL
|
||||
wp_redirect($downloadUrl);
|
||||
exit;
|
||||
|
||||
@@ -35,8 +35,9 @@ final class Installer
|
||||
// Set version in options
|
||||
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
|
||||
|
||||
// Register the licenses endpoint before flushing rewrite rules
|
||||
// Register endpoints before flushing rewrite rules
|
||||
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
|
||||
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
|
||||
|
||||
// Flush rewrite rules for REST API and My Account endpoints
|
||||
flush_rewrite_rules();
|
||||
@@ -103,6 +104,7 @@ final class Installer
|
||||
download_url VARCHAR(512) DEFAULT NULL,
|
||||
attachment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
file_hash VARCHAR(64) DEFAULT NULL,
|
||||
download_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -11,12 +11,43 @@ namespace Jeremias\WcLicensedProduct\License;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Installer;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProduct;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedVariableProduct;
|
||||
|
||||
/**
|
||||
* Manages license operations (CRUD, validation, generation)
|
||||
*/
|
||||
class LicenseManager
|
||||
{
|
||||
/**
|
||||
* Check if a product is any type of licensed product
|
||||
*
|
||||
* @param \WC_Product $product Product to check
|
||||
* @return bool True if product is licensed (simple or variable or variation)
|
||||
*/
|
||||
public function isLicensedProduct(\WC_Product $product): bool
|
||||
{
|
||||
// Simple licensed product
|
||||
if ($product->is_type('licensed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variable licensed product
|
||||
if ($product->is_type('licensed-variable')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variation of a licensed-variable product
|
||||
if ($product->is_type('variation') && $product->get_parent_id()) {
|
||||
$parent = wc_get_product($product->get_parent_id());
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique license key
|
||||
*/
|
||||
@@ -40,29 +71,63 @@ class LicenseManager
|
||||
|
||||
/**
|
||||
* Generate a license for a completed order
|
||||
*
|
||||
* @param int $orderId Order ID
|
||||
* @param int $productId Product ID (parent product for variations)
|
||||
* @param int $customerId Customer ID
|
||||
* @param string $domain Domain to bind the license to
|
||||
* @param int|null $variationId Optional variation ID for variable licensed products
|
||||
* @return License|null Generated license or null on failure
|
||||
*/
|
||||
public function generateLicense(
|
||||
int $orderId,
|
||||
int $productId,
|
||||
int $customerId,
|
||||
string $domain
|
||||
string $domain,
|
||||
?int $variationId = null
|
||||
): ?License {
|
||||
global $wpdb;
|
||||
|
||||
// Check if license already exists for this order and product
|
||||
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId);
|
||||
// Normalize domain first for duplicate detection
|
||||
$normalizedDomain = $this->normalizeDomain($domain);
|
||||
|
||||
// Check if license already exists for this order, product, domain, and variation
|
||||
$existing = $this->getLicenseByOrderProductDomainAndVariation($orderId, $productId, $normalizedDomain, $variationId);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$product = wc_get_product($productId);
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
// Load the product that has the license settings
|
||||
// For variations, load the variation; otherwise load the parent product
|
||||
if ($variationId) {
|
||||
$settingsProduct = wc_get_product($variationId);
|
||||
$parentProduct = wc_get_product($productId);
|
||||
|
||||
// Verify parent is licensed-variable
|
||||
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure we have the proper variation class
|
||||
if ($settingsProduct && !$settingsProduct instanceof LicensedProductVariation) {
|
||||
$settingsProduct = new LicensedProductVariation($variationId);
|
||||
}
|
||||
} else {
|
||||
$settingsProduct = wc_get_product($productId);
|
||||
|
||||
// Check if this is a licensed product (simple)
|
||||
if (!$settingsProduct || !$settingsProduct->is_type('licensed')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure we have the LicensedProduct instance for type hints
|
||||
if (!$product instanceof LicensedProduct) {
|
||||
$product = new LicensedProduct($productId);
|
||||
if (!$settingsProduct instanceof LicensedProduct) {
|
||||
$settingsProduct = new LicensedProduct($productId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$settingsProduct) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate unique license key
|
||||
@@ -71,16 +136,16 @@ class LicenseManager
|
||||
$licenseKey = $this->generateLicenseKey();
|
||||
}
|
||||
|
||||
// Calculate expiration date
|
||||
// Calculate expiration date from the settings product (variation or parent)
|
||||
$expiresAt = null;
|
||||
$validityDays = $product->get_validity_days();
|
||||
$validityDays = $settingsProduct->get_validity_days();
|
||||
if ($validityDays !== null && $validityDays > 0) {
|
||||
$expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Determine version ID if bound to version
|
||||
// Determine version ID if bound to version (always use parent product ID for versions)
|
||||
$versionId = null;
|
||||
if ($product->is_bound_to_version()) {
|
||||
if ($settingsProduct->is_bound_to_version()) {
|
||||
$versionId = $this->getCurrentVersionId($productId);
|
||||
}
|
||||
|
||||
@@ -96,7 +161,7 @@ class LicenseManager
|
||||
'version_id' => $versionId,
|
||||
'status' => License::STATUS_ACTIVE,
|
||||
'activations_count' => 1,
|
||||
'max_activations' => $product->get_max_activations(),
|
||||
'max_activations' => $settingsProduct->get_max_activations(),
|
||||
'expires_at' => $expiresAt,
|
||||
],
|
||||
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
|
||||
@@ -109,6 +174,16 @@ class LicenseManager
|
||||
return $this->getLicenseById((int) $wpdb->insert_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by order, product, domain, and optional variation
|
||||
*/
|
||||
public function getLicenseByOrderProductDomainAndVariation(int $orderId, int $productId, string $domain, ?int $variationId = null): ?License
|
||||
{
|
||||
// For now, just use the existing method since we don't store variation_id in licenses table yet
|
||||
// In the future, we could add a variation_id column to the licenses table
|
||||
return $this->getLicenseByOrderProductAndDomain($orderId, $productId, $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by ID
|
||||
*/
|
||||
@@ -161,6 +236,49 @@ class LicenseManager
|
||||
return $row ? License::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses for an order and product
|
||||
*
|
||||
* @return License[]
|
||||
*/
|
||||
public function getLicensesByOrderAndProduct(int $orderId, int $productId): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d ORDER BY created_at ASC",
|
||||
$orderId,
|
||||
$productId
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license by order, product, and domain
|
||||
*/
|
||||
public function getLicenseByOrderProductAndDomain(int $orderId, int $productId, string $domain): ?License
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d AND domain = %s",
|
||||
$orderId,
|
||||
$productId,
|
||||
$domain
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $row ? License::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all licenses for an order
|
||||
*/
|
||||
@@ -862,6 +980,56 @@ class LicenseManager
|
||||
return (bool) get_user_meta($license->getCustomerId(), $metaKey, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get licenses that have passed their expiration date but are still marked as active
|
||||
*
|
||||
* @return array Array of License objects that need to be auto-expired
|
||||
*/
|
||||
public function getExpiredActiveLicenses(): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getLicensesTable();
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
$sql = "SELECT * FROM {$tableName}
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < %s
|
||||
AND status = %s";
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare($sql, $now->format('Y-m-d H:i:s'), License::STATUS_ACTIVE),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-expire a license and return true if status was changed
|
||||
*
|
||||
* @param int $licenseId License ID
|
||||
* @return bool True if license was expired, false if already expired or error
|
||||
*/
|
||||
public function autoExpireLicense(int $licenseId): bool
|
||||
{
|
||||
$license = $this->getLicenseById($licenseId);
|
||||
if (!$license) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only expire if currently active and past expiration date
|
||||
if ($license->getStatus() !== License::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$license->isExpired()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->updateLicenseStatus($licenseId, License::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a license from CSV data
|
||||
*
|
||||
|
||||
357
src/License/PluginLicenseChecker.php
Normal file
357
src/License/PluginLicenseChecker.php
Normal file
@@ -0,0 +1,357 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Cached self-licensing check result
|
||||
*/
|
||||
private ?bool $isSelfLicensingCached = 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;
|
||||
}
|
||||
|
||||
// Always valid when self-licensing (server URL points to this installation)
|
||||
if ($this->isSelfLicensing()) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Always valid when self-licensing (server URL points to this installation)
|
||||
if ($this->isSelfLicensing()) {
|
||||
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;
|
||||
$this->isSelfLicensingCached = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if self-licensing (license server URL points to this installation)
|
||||
*
|
||||
* Prevents circular dependency where plugin tries to validate against itself.
|
||||
* Plugins can only be validated against the original store from which they were obtained.
|
||||
*/
|
||||
public function isSelfLicensing(): bool
|
||||
{
|
||||
if ($this->isSelfLicensingCached !== null) {
|
||||
return $this->isSelfLicensingCached;
|
||||
}
|
||||
|
||||
$serverUrl = $this->getLicenseServerUrl();
|
||||
|
||||
// No server URL configured - not self-licensing
|
||||
if (empty($serverUrl)) {
|
||||
$this->isSelfLicensingCached = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse both URLs to compare domains
|
||||
$serverParsed = parse_url($serverUrl);
|
||||
$siteUrl = get_site_url();
|
||||
$siteParsed = parse_url($siteUrl);
|
||||
|
||||
// Get normalized domains (lowercase, no www prefix)
|
||||
$serverDomain = $this->normalizeDomain($serverParsed['host'] ?? '');
|
||||
$siteDomain = $this->normalizeDomain($siteParsed['host'] ?? '');
|
||||
|
||||
// If domains match, this is self-licensing
|
||||
if ($serverDomain === $siteDomain) {
|
||||
$this->isSelfLicensingCached = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->isSelfLicensingCached = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a domain for comparison (lowercase, strip www)
|
||||
*/
|
||||
private function normalizeDomain(string $domain): string
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
|
||||
// Strip www. prefix
|
||||
if (str_starts_with($domain, 'www.')) {
|
||||
$domain = substr($domain, 4);
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
141
src/Plugin.php
141
src/Plugin.php
@@ -10,6 +10,8 @@ declare(strict_types=1);
|
||||
namespace Jeremias\WcLicensedProduct;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
|
||||
use Jeremias\WcLicensedProduct\Admin\DownloadWidgetController;
|
||||
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||
@@ -22,6 +24,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;
|
||||
@@ -96,6 +99,7 @@ final class Plugin
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
|
||||
'auto_reload' => true, // Always check for template changes
|
||||
'autoescape' => 'html', // Explicitly enable HTML autoescape for XSS protection
|
||||
]);
|
||||
|
||||
// Add WordPress functions as Twig functions
|
||||
@@ -119,13 +123,23 @@ 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();
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -134,11 +148,19 @@ final class Plugin
|
||||
(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();
|
||||
new DashboardWidgetController($this->licenseManager);
|
||||
new DownloadWidgetController($this->versionManager);
|
||||
|
||||
// Show admin notice if unlicensed and not on localhost
|
||||
if (!$isLicensed && !$licenseChecker->isLocalhost()) {
|
||||
add_action('admin_notices', [$this, 'showUnlicensedNotice']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +186,9 @@ final class Plugin
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// 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']);
|
||||
@@ -171,6 +196,7 @@ final class Plugin
|
||||
// Also hook into payment complete for immediate license generation
|
||||
add_action('woocommerce_payment_complete', [$this, 'onOrderCompleted']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order completion - generate licenses
|
||||
@@ -182,22 +208,98 @@ final class Plugin
|
||||
return;
|
||||
}
|
||||
|
||||
// Try new multi-domain format first
|
||||
$domainData = $order->get_meta('_licensed_product_domains');
|
||||
if (!empty($domainData) && is_array($domainData)) {
|
||||
$this->generateLicensesMultiDomain($order, $domainData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to legacy single domain format
|
||||
$this->generateLicensesSingleDomain($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate licenses for new multi-domain format
|
||||
*/
|
||||
private function generateLicensesMultiDomain(\WC_Order $order, array $domainData): void
|
||||
{
|
||||
$orderId = $order->get_id();
|
||||
$customerId = $order->get_customer_id();
|
||||
|
||||
// Index domains by product ID (and variation ID for variable products)
|
||||
$domainsByProduct = [];
|
||||
foreach ($domainData as $item) {
|
||||
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
|
||||
$productId = (int) $item['product_id'];
|
||||
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
|
||||
$key = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
|
||||
$domainsByProduct[$key] = [
|
||||
'domains' => $item['domains'],
|
||||
'variation_id' => $variationId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate licenses for each licensed product
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if ($domain) {
|
||||
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the parent product ID (for variations, this is the main product)
|
||||
$productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
|
||||
$variationId = $item->get_variation_id();
|
||||
|
||||
// Look up domains - first try with variation, then without
|
||||
$key = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
|
||||
$domainInfo = $domainsByProduct[$key] ?? $domainsByProduct[(string) $productId] ?? null;
|
||||
$domains = $domainInfo['domains'] ?? [];
|
||||
|
||||
// Generate a license for each domain
|
||||
foreach ($domains as $domain) {
|
||||
if (!empty($domain)) {
|
||||
$this->licenseManager->generateLicense(
|
||||
$orderId,
|
||||
$product->get_id(),
|
||||
$order->get_customer_id(),
|
||||
$domain
|
||||
$productId,
|
||||
$customerId,
|
||||
$domain,
|
||||
$variationId > 0 ? $variationId : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate licenses for legacy single domain format
|
||||
*/
|
||||
private function generateLicensesSingleDomain(\WC_Order $order): void
|
||||
{
|
||||
$domain = $order->get_meta('_licensed_product_domain');
|
||||
if (empty($domain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $this->licenseManager->isLicensedProduct($product)) {
|
||||
// Get the parent product ID (for variations, this is the main product)
|
||||
$productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
|
||||
$variationId = $item->get_variation_id();
|
||||
|
||||
$this->licenseManager->generateLicense(
|
||||
$order->get_id(),
|
||||
$productId,
|
||||
$order->get_customer_id(),
|
||||
$domain,
|
||||
$variationId > 0 ? $variationId : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Twig environment
|
||||
*/
|
||||
@@ -221,4 +323,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ namespace Jeremias\WcLicensedProduct\Product;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
|
||||
/**
|
||||
* Registers and handles the Licensed product type for WooCommerce
|
||||
* Registers and handles the Licensed product types for WooCommerce
|
||||
* Supports both simple licensed products and variable licensed products
|
||||
*/
|
||||
final class LicensedProductType
|
||||
{
|
||||
@@ -29,7 +30,7 @@ final class LicensedProductType
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Register product type
|
||||
// Register product types
|
||||
add_filter('product_type_selector', [$this, 'addProductType']);
|
||||
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
|
||||
|
||||
@@ -39,31 +40,62 @@ final class LicensedProductType
|
||||
|
||||
// Save product meta
|
||||
add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']);
|
||||
add_action('woocommerce_process_product_meta_licensed-variable', [$this, 'saveProductMeta']);
|
||||
|
||||
// Show price and add to cart for licensed products
|
||||
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
|
||||
add_action('woocommerce_licensed-variable_add_to_cart', [$this, 'variableAddToCartTemplate']);
|
||||
|
||||
// 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']);
|
||||
|
||||
// Variable product support - variation settings
|
||||
add_action('woocommerce_variation_options', [$this, 'addVariationOptions'], 10, 3);
|
||||
add_action('woocommerce_product_after_variable_attributes', [$this, 'addVariationFields'], 10, 3);
|
||||
add_action('woocommerce_save_product_variation', [$this, 'saveVariationFields'], 10, 2);
|
||||
|
||||
// Admin scripts for licensed-variable type
|
||||
add_action('admin_footer', [$this, 'addVariableProductScripts']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product type to selector
|
||||
* Add product types to selector
|
||||
*/
|
||||
public function addProductType(array $types): array
|
||||
{
|
||||
$types['licensed'] = __('Licensed Product', 'wc-licensed-product');
|
||||
$types['licensed-variable'] = __('Licensed Variable Product', 'wc-licensed-product');
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product class for licensed type
|
||||
* Get product class for licensed types
|
||||
*/
|
||||
public function getProductClass(string $className, string $productType): string
|
||||
{
|
||||
if ($productType === 'licensed') {
|
||||
return LicensedProduct::class;
|
||||
}
|
||||
if ($productType === 'licensed-variable') {
|
||||
return LicensedVariableProduct::class;
|
||||
}
|
||||
// Handle variations of licensed-variable products
|
||||
if ($productType === 'variation') {
|
||||
// Check if parent is licensed-variable
|
||||
global $post;
|
||||
if ($post && $post->post_parent) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($post->post_parent);
|
||||
if ($parentType === 'licensed-variable') {
|
||||
return LicensedProductVariation::class;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $className;
|
||||
}
|
||||
|
||||
@@ -75,7 +107,7 @@ final class LicensedProductType
|
||||
$tabs['licensed_product'] = [
|
||||
'label' => __('License Settings', 'wc-licensed-product'),
|
||||
'target' => 'licensed_product_data',
|
||||
'class' => ['show_if_licensed'],
|
||||
'class' => ['show_if_licensed', 'show_if_licensed-variable'],
|
||||
'priority' => 21,
|
||||
];
|
||||
return $tabs;
|
||||
@@ -169,23 +201,31 @@ final class LicensedProductType
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function($) {
|
||||
// Show/hide panels based on product type
|
||||
$('select#product-type').change(function() {
|
||||
if ($(this).val() === 'licensed') {
|
||||
// Show/hide panels based on product type for license settings tab
|
||||
function toggleLicensedProductOptions() {
|
||||
var productType = $('#product-type').val();
|
||||
var isLicensed = productType === 'licensed';
|
||||
var isLicensedVariable = productType === 'licensed-variable';
|
||||
|
||||
if (isLicensed || isLicensedVariable) {
|
||||
// Show license settings tab
|
||||
$('.show_if_licensed').show();
|
||||
$('.show_if_licensed-variable').show();
|
||||
$('.general_options').show();
|
||||
$('.pricing').show();
|
||||
} else {
|
||||
$('.show_if_licensed').hide();
|
||||
}
|
||||
}).change();
|
||||
|
||||
// Show general tab for licensed products
|
||||
$('#product-type').on('change', function() {
|
||||
if ($(this).val() === 'licensed') {
|
||||
$('.general_tab').show();
|
||||
} else {
|
||||
// Hide license settings tab for other product types
|
||||
$('.show_if_licensed').hide();
|
||||
$('.show_if_licensed-variable').hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial state on page load
|
||||
toggleLicensedProductOptions();
|
||||
|
||||
// On product type change
|
||||
$('#product-type').on('change', toggleLicensedProductOptions);
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
@@ -230,9 +270,301 @@ final class LicensedProductType
|
||||
*/
|
||||
public function isVirtual(bool $isVirtual, \WC_Product $product): bool
|
||||
{
|
||||
if ($product->is_type('licensed')) {
|
||||
if ($product->is_type('licensed') || $product->is_type('licensed-variable')) {
|
||||
return true;
|
||||
}
|
||||
// Also handle variations of licensed-variable products
|
||||
if ($product->is_type('variation') && $product->get_parent_id()) {
|
||||
$parent = wc_get_product($product->get_parent_id());
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
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') && !$product->is_type('licensed-variable'))) {
|
||||
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') && !$product->is_type('licensed-variable'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var LicensedProduct|LicensedVariableProduct $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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cart template for variable licensed products
|
||||
*/
|
||||
public function variableAddToCartTemplate(): void
|
||||
{
|
||||
global $product;
|
||||
|
||||
if (!$product instanceof \WC_Product_Variable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get variations count to determine if we should load them via AJAX
|
||||
$getVariations = count($product->get_children()) <= apply_filters('woocommerce_ajax_variation_threshold', 30, $product);
|
||||
|
||||
wc_get_template(
|
||||
'single-product/add-to-cart/variable.php',
|
||||
[
|
||||
'available_variations' => $getVariations ? $product->get_available_variations() : false,
|
||||
'attributes' => $product->get_variation_attributes(),
|
||||
'selected_attributes' => $product->get_default_attributes(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add variation options (checkboxes next to variation header)
|
||||
*/
|
||||
public function addVariationOptions(int $loop, array $variationData, \WP_Post $variation): void
|
||||
{
|
||||
// Check if parent is licensed-variable
|
||||
$parentId = $variation->post_parent;
|
||||
$parentProduct = wc_get_product($parentId);
|
||||
|
||||
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isVirtual = get_post_meta($variation->ID, '_virtual', true);
|
||||
?>
|
||||
<label class="tips" data-tip="<?php esc_attr_e('Licensed products are always virtual', 'wc-licensed-product'); ?>">
|
||||
<input type="checkbox" class="checkbox" disabled checked />
|
||||
<?php esc_html_e('Virtual', 'wc-licensed-product'); ?>
|
||||
</label>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Add variation fields for license settings
|
||||
*/
|
||||
public function addVariationFields(int $loop, array $variationData, \WP_Post $variation): void
|
||||
{
|
||||
// Check if parent is licensed-variable
|
||||
$parentId = $variation->post_parent;
|
||||
$parentProduct = wc_get_product($parentId);
|
||||
|
||||
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get variation values
|
||||
$validityDays = get_post_meta($variation->ID, '_licensed_validity_days', true);
|
||||
$maxActivations = get_post_meta($variation->ID, '_licensed_max_activations', true);
|
||||
|
||||
// Get parent defaults for placeholder
|
||||
$parentValidityDays = $parentProduct->get_validity_days();
|
||||
$parentMaxActivations = $parentProduct->get_max_activations();
|
||||
|
||||
$parentValidityDisplay = $parentValidityDays !== null
|
||||
? sprintf(__('%d days', 'wc-licensed-product'), $parentValidityDays)
|
||||
: __('Lifetime', 'wc-licensed-product');
|
||||
|
||||
?>
|
||||
<div class="wclp-variation-license-settings">
|
||||
<p class="form-row form-row-first">
|
||||
<label><?php esc_html_e('License Duration (Days)', 'wc-licensed-product'); ?></label>
|
||||
<input type="number"
|
||||
name="wclp_validity_days[<?php echo esc_attr($loop); ?>]"
|
||||
class="short"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="<?php echo esc_attr($parentValidityDisplay); ?>"
|
||||
value="<?php echo esc_attr($validityDays); ?>"
|
||||
/>
|
||||
<span class="description"><?php esc_html_e('Leave empty for parent default. 0 = Lifetime.', 'wc-licensed-product'); ?></span>
|
||||
</p>
|
||||
<p class="form-row form-row-last">
|
||||
<label><?php esc_html_e('Max Activations', 'wc-licensed-product'); ?></label>
|
||||
<input type="number"
|
||||
name="wclp_max_activations[<?php echo esc_attr($loop); ?>]"
|
||||
class="short"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="<?php echo esc_attr($parentMaxActivations); ?>"
|
||||
value="<?php echo esc_attr($maxActivations); ?>"
|
||||
/>
|
||||
<span class="description"><?php esc_html_e('Leave empty for parent default.', 'wc-licensed-product'); ?></span>
|
||||
</p>
|
||||
</div>
|
||||
<style>
|
||||
.wclp-variation-license-settings {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wclp-variation-license-settings .description {
|
||||
display: block;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save variation fields
|
||||
*/
|
||||
public function saveVariationFields(int $variationId, int $loop): void
|
||||
{
|
||||
// Check if parent is licensed-variable
|
||||
$variation = wc_get_product($variationId);
|
||||
if (!$variation) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentProduct = wc_get_product($variation->get_parent_id());
|
||||
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save validity days
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified by WooCommerce
|
||||
if (isset($_POST['wclp_validity_days'][$loop])) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$validityDays = sanitize_text_field($_POST['wclp_validity_days'][$loop]);
|
||||
if ($validityDays !== '') {
|
||||
update_post_meta($variationId, '_licensed_validity_days', absint($validityDays));
|
||||
} else {
|
||||
delete_post_meta($variationId, '_licensed_validity_days');
|
||||
}
|
||||
}
|
||||
|
||||
// Save max activations
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
if (isset($_POST['wclp_max_activations'][$loop])) {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
$maxActivations = sanitize_text_field($_POST['wclp_max_activations'][$loop]);
|
||||
if ($maxActivations !== '') {
|
||||
update_post_meta($variationId, '_licensed_max_activations', absint($maxActivations));
|
||||
} else {
|
||||
delete_post_meta($variationId, '_licensed_max_activations');
|
||||
}
|
||||
}
|
||||
|
||||
// Set variation as virtual (licensed products are always virtual)
|
||||
update_post_meta($variationId, '_virtual', 'yes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add JavaScript for licensed-variable product type in admin
|
||||
*/
|
||||
public function addVariableProductScripts(): void
|
||||
{
|
||||
global $post, $pagenow;
|
||||
|
||||
if ($pagenow !== 'post.php' && $pagenow !== 'post-new.php') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$post || get_post_type($post) !== 'product') {
|
||||
return;
|
||||
}
|
||||
|
||||
?>
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function($) {
|
||||
// Show/hide panels based on product type
|
||||
function toggleLicensedVariableOptions() {
|
||||
var productType = $('#product-type').val();
|
||||
|
||||
if (productType === 'licensed-variable') {
|
||||
// Show variable product options
|
||||
$('.show_if_variable').show();
|
||||
$('.hide_if_variable').hide();
|
||||
|
||||
// Show licensed product options
|
||||
$('.show_if_licensed-variable').show();
|
||||
$('.show_if_licensed').show();
|
||||
|
||||
// Show general and variations tabs
|
||||
$('.general_tab').show();
|
||||
$('.variations_tab').show();
|
||||
$('.variations_options').show();
|
||||
|
||||
// Hide shipping tab (virtual products)
|
||||
$('.shipping_tab').hide();
|
||||
|
||||
// Ensure the variations panel can be displayed
|
||||
$('#variable_product_options').show();
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
toggleLicensedVariableOptions();
|
||||
|
||||
// On product type change
|
||||
$('#product-type').on('change', function() {
|
||||
// Use setTimeout to let WooCommerce finish its own processing first
|
||||
setTimeout(toggleLicensedVariableOptions, 100);
|
||||
});
|
||||
|
||||
// Re-apply after WooCommerce AJAX operations that may reset visibility
|
||||
$(document).on('woocommerce_variations_loaded', toggleLicensedVariableOptions);
|
||||
$(document).on('woocommerce_variations_added', toggleLicensedVariableOptions);
|
||||
$(document).on('woocommerce_variations_saved', toggleLicensedVariableOptions);
|
||||
|
||||
// Handle AJAX complete events for attribute saving
|
||||
$(document).ajaxComplete(function(event, xhr, settings) {
|
||||
// Check if this was a product data save or attribute action
|
||||
if (settings.data && (
|
||||
settings.data.indexOf('action=woocommerce_save_attributes') !== -1 ||
|
||||
settings.data.indexOf('action=woocommerce_load_variations') !== -1 ||
|
||||
settings.data.indexOf('action=woocommerce_add_variation') !== -1
|
||||
)) {
|
||||
setTimeout(toggleLicensedVariableOptions, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Also listen for the WooCommerce product type show/hide trigger
|
||||
$('body').on('woocommerce-product-type-change', toggleLicensedVariableOptions);
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
196
src/Product/LicensedProductVariation.php
Normal file
196
src/Product/LicensedProductVariation.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
/**
|
||||
* Licensed Product Variation Class
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Product
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Product;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use WC_Product_Variation;
|
||||
|
||||
/**
|
||||
* Licensed Product Variation type extending WooCommerce Product Variation
|
||||
*
|
||||
* Each variation can have its own license duration settings.
|
||||
*/
|
||||
class LicensedProductVariation extends WC_Product_Variation
|
||||
{
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($product = 0)
|
||||
{
|
||||
parent::__construct($product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always virtual
|
||||
*/
|
||||
public function is_virtual(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max activations for this variation
|
||||
* Falls back to parent product, then to default settings
|
||||
*/
|
||||
public function get_max_activations(): int
|
||||
{
|
||||
// Check variation-specific setting first
|
||||
$value = $this->get_meta('_licensed_max_activations', true);
|
||||
if ($value !== '' && $value !== null) {
|
||||
return max(1, (int) $value);
|
||||
}
|
||||
|
||||
// Fall back to parent product
|
||||
$parent = wc_get_product($this->get_parent_id());
|
||||
if ($parent && method_exists($parent, 'get_max_activations')) {
|
||||
return $parent->get_max_activations();
|
||||
}
|
||||
|
||||
return SettingsController::getDefaultMaxActivations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variation has custom max activations set
|
||||
*/
|
||||
public function has_custom_max_activations(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_max_activations', true);
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validity days for this variation
|
||||
* This is the primary license setting that varies per variation
|
||||
* Falls back to parent product, then to default settings
|
||||
*/
|
||||
public function get_validity_days(): ?int
|
||||
{
|
||||
// Check variation-specific setting first
|
||||
$value = $this->get_meta('_licensed_validity_days', true);
|
||||
if ($value !== '' && $value !== null) {
|
||||
$days = (int) $value;
|
||||
// 0 means lifetime
|
||||
return $days > 0 ? $days : null;
|
||||
}
|
||||
|
||||
// Fall back to parent product
|
||||
$parent = wc_get_product($this->get_parent_id());
|
||||
if ($parent && method_exists($parent, 'get_validity_days')) {
|
||||
return $parent->get_validity_days();
|
||||
}
|
||||
|
||||
return SettingsController::getDefaultValidityDays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variation has custom validity days set
|
||||
*/
|
||||
public function has_custom_validity_days(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_validity_days', true);
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license should be bound to major version
|
||||
* Falls back to parent product, then to default settings
|
||||
*/
|
||||
public function is_bound_to_version(): bool
|
||||
{
|
||||
// Check variation-specific setting first
|
||||
$value = $this->get_meta('_licensed_bind_to_version', true);
|
||||
if ($value !== '' && $value !== null) {
|
||||
return $value === 'yes';
|
||||
}
|
||||
|
||||
// Fall back to parent product
|
||||
$parent = wc_get_product($this->get_parent_id());
|
||||
if ($parent && method_exists($parent, 'is_bound_to_version')) {
|
||||
return $parent->is_bound_to_version();
|
||||
}
|
||||
|
||||
return SettingsController::getDefaultBindToVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variation has custom bind to version setting
|
||||
*/
|
||||
public function has_custom_bind_to_version(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_bind_to_version', true);
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the license duration label for display
|
||||
*/
|
||||
public function get_license_duration_label(): string
|
||||
{
|
||||
$days = $this->get_validity_days();
|
||||
|
||||
if ($days === null) {
|
||||
return __('Lifetime', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
if ($days === 30) {
|
||||
return __('Monthly', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
if ($days === 90) {
|
||||
return __('Quarterly', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
if ($days === 365) {
|
||||
return __('Yearly', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: %d: number of days */
|
||||
_n('%d day', '%d days', $days, 'wc-licensed-product'),
|
||||
$days
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current software version from parent product
|
||||
*/
|
||||
public function get_current_version(): string
|
||||
{
|
||||
$parent = wc_get_product($this->get_parent_id());
|
||||
if ($parent && method_exists($parent, 'get_current_version')) {
|
||||
return $parent->get_current_version();
|
||||
}
|
||||
|
||||
$versionManager = new VersionManager();
|
||||
$latestVersion = $versionManager->getLatestVersion($this->get_parent_id());
|
||||
|
||||
return $latestVersion ? $latestVersion->getVersion() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get major version number from parent product
|
||||
*/
|
||||
public function get_major_version(): int
|
||||
{
|
||||
$parent = wc_get_product($this->get_parent_id());
|
||||
if ($parent && method_exists($parent, 'get_major_version')) {
|
||||
return $parent->get_major_version();
|
||||
}
|
||||
|
||||
$versionManager = new VersionManager();
|
||||
$latestVersion = $versionManager->getLatestVersion($this->get_parent_id());
|
||||
|
||||
if ($latestVersion) {
|
||||
return $latestVersion->getMajorVersion();
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
151
src/Product/LicensedVariableProduct.php
Normal file
151
src/Product/LicensedVariableProduct.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
/**
|
||||
* Licensed Variable Product Class
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Product
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Product;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use WC_Product_Variable;
|
||||
|
||||
/**
|
||||
* Licensed Variable Product type extending WooCommerce Variable Product
|
||||
*
|
||||
* This allows selling license subscriptions with different durations
|
||||
* (e.g., monthly, yearly, lifetime) as product variations.
|
||||
*/
|
||||
class LicensedVariableProduct extends WC_Product_Variable
|
||||
{
|
||||
/**
|
||||
* Product type
|
||||
*/
|
||||
protected $product_type = 'licensed-variable';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($product = 0)
|
||||
{
|
||||
parent::__construct($product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product type
|
||||
*/
|
||||
public function get_type(): string
|
||||
{
|
||||
return 'licensed-variable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always virtual
|
||||
*/
|
||||
public function is_virtual(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are purchasable
|
||||
*/
|
||||
public function is_purchasable(): bool
|
||||
{
|
||||
return $this->exists() && $this->get_price() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max activations for this product (parent default)
|
||||
* Falls back to default settings if not set on product
|
||||
*/
|
||||
public function get_max_activations(): int
|
||||
{
|
||||
$value = $this->get_meta('_licensed_max_activations', true);
|
||||
if ($value !== '' && $value !== null) {
|
||||
return max(1, (int) $value);
|
||||
}
|
||||
return SettingsController::getDefaultMaxActivations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product has custom max activations set
|
||||
*/
|
||||
public function has_custom_max_activations(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_max_activations', true);
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validity days (parent default - variations override this)
|
||||
* Falls back to default settings if not set on product
|
||||
*/
|
||||
public function get_validity_days(): ?int
|
||||
{
|
||||
$value = $this->get_meta('_licensed_validity_days', true);
|
||||
if ($value !== '' && $value !== null) {
|
||||
return (int) $value > 0 ? (int) $value : null;
|
||||
}
|
||||
return SettingsController::getDefaultValidityDays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product has custom validity days set
|
||||
*/
|
||||
public function has_custom_validity_days(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_validity_days', true);
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if license should be bound to major version
|
||||
* Falls back to default settings if not set on product
|
||||
*/
|
||||
public function is_bound_to_version(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_bind_to_version', true);
|
||||
if ($value !== '' && $value !== null) {
|
||||
return $value === 'yes';
|
||||
}
|
||||
return SettingsController::getDefaultBindToVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product has custom bind to version setting
|
||||
*/
|
||||
public function has_custom_bind_to_version(): bool
|
||||
{
|
||||
$value = $this->get_meta('_licensed_bind_to_version', true);
|
||||
return $value !== '' && $value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current software version (derived from latest product version)
|
||||
*/
|
||||
public function get_current_version(): string
|
||||
{
|
||||
$versionManager = new VersionManager();
|
||||
$latestVersion = $versionManager->getLatestVersion($this->get_id());
|
||||
|
||||
return $latestVersion ? $latestVersion->getVersion() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get major version number from version string
|
||||
*/
|
||||
public function get_major_version(): int
|
||||
{
|
||||
$versionManager = new VersionManager();
|
||||
$latestVersion = $versionManager->getLatestVersion($this->get_id());
|
||||
|
||||
if ($latestVersion) {
|
||||
return $latestVersion->getMajorVersion();
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ class ProductVersion
|
||||
private ?string $downloadUrl;
|
||||
private ?int $attachmentId;
|
||||
private ?string $fileHash;
|
||||
private int $downloadCount;
|
||||
private bool $isActive;
|
||||
private \DateTimeInterface $releasedAt;
|
||||
private \DateTimeInterface $createdAt;
|
||||
@@ -44,6 +45,7 @@ class ProductVersion
|
||||
$version->downloadUrl = $data['download_url'] ?: null;
|
||||
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
|
||||
$version->fileHash = $data['file_hash'] ?? null;
|
||||
$version->downloadCount = (int) ($data['download_count'] ?? 0);
|
||||
$version->isActive = (bool) $data['is_active'];
|
||||
$version->releasedAt = new \DateTimeImmutable($data['released_at']);
|
||||
$version->createdAt = new \DateTimeImmutable($data['created_at']);
|
||||
@@ -144,6 +146,11 @@ class ProductVersion
|
||||
return $this->fileHash;
|
||||
}
|
||||
|
||||
public function getDownloadCount(): int
|
||||
{
|
||||
return $this->downloadCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the download URL from attachment
|
||||
*/
|
||||
@@ -197,6 +204,7 @@ class ProductVersion
|
||||
'download_url' => $this->downloadUrl,
|
||||
'attachment_id' => $this->attachmentId,
|
||||
'file_hash' => $this->fileHash,
|
||||
'download_count' => $this->downloadCount,
|
||||
'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'),
|
||||
|
||||
@@ -276,4 +276,98 @@ class VersionManager
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment download count for a version
|
||||
*/
|
||||
public function incrementDownloadCount(int $versionId): bool
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getVersionsTable();
|
||||
$result = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$tableName} SET download_count = download_count + 1 WHERE id = %d",
|
||||
$versionId
|
||||
)
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total download count across all versions
|
||||
*/
|
||||
public function getTotalDownloadCount(): int
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getVersionsTable();
|
||||
$count = $wpdb->get_var("SELECT COALESCE(SUM(download_count), 0) FROM {$tableName}");
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download statistics per product
|
||||
*/
|
||||
public function getDownloadStatistics(): array
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = Installer::getVersionsTable();
|
||||
|
||||
// Get total downloads
|
||||
$totalDownloads = $this->getTotalDownloadCount();
|
||||
|
||||
// Get downloads per product (top 10)
|
||||
$byProduct = $wpdb->get_results(
|
||||
"SELECT product_id, SUM(download_count) as downloads
|
||||
FROM {$tableName}
|
||||
GROUP BY product_id
|
||||
ORDER BY downloads DESC
|
||||
LIMIT 10",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Get downloads per version (top 10)
|
||||
$byVersion = $wpdb->get_results(
|
||||
"SELECT id, product_id, version, download_count
|
||||
FROM {$tableName}
|
||||
WHERE download_count > 0
|
||||
ORDER BY download_count DESC
|
||||
LIMIT 10",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Enrich product data with names
|
||||
$productsWithNames = [];
|
||||
foreach ($byProduct ?: [] as $row) {
|
||||
$product = wc_get_product((int) $row['product_id']);
|
||||
$productsWithNames[] = [
|
||||
'product_id' => (int) $row['product_id'],
|
||||
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||
'downloads' => (int) $row['downloads'],
|
||||
];
|
||||
}
|
||||
|
||||
// Enrich version data with product names
|
||||
$versionsWithNames = [];
|
||||
foreach ($byVersion ?: [] as $row) {
|
||||
$product = wc_get_product((int) $row['product_id']);
|
||||
$versionsWithNames[] = [
|
||||
'version_id' => (int) $row['id'],
|
||||
'product_id' => (int) $row['product_id'],
|
||||
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
|
||||
'version' => $row['version'],
|
||||
'downloads' => (int) $row['download_count'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $totalDownloads,
|
||||
'by_product' => $productsWithNames,
|
||||
'by_version' => $versionsWithNames,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="wrap">
|
||||
<h1 class="wp-heading-inline">{{ __('Licenses') }}</h1>
|
||||
<a href="{{ admin_url }}?action=export_csv" class="page-title-action">
|
||||
<a href="{{ export_csv_url() }}" class="page-title-action">
|
||||
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
|
||||
{{ __('Export CSV') }}
|
||||
</a>
|
||||
@@ -143,8 +143,8 @@
|
||||
</td>
|
||||
<td class="wclp-editable-cell" data-field="status" data-license-id="{{ item.license.id }}">
|
||||
<span class="wclp-display-value">
|
||||
<span class="license-status license-status-{{ item.license.status }}">
|
||||
{{ item.license.status|capitalize }}
|
||||
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
|
||||
{{ esc_html(item.license.status)|capitalize }}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="wclp-edit-btn button-link" title="{{ __('Edit') }}">
|
||||
@@ -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>
|
||||
|
||||
@@ -1,75 +1,153 @@
|
||||
{% if not has_licenses %}
|
||||
{% if not has_packages %}
|
||||
<p>{{ __('You have no licenses yet.') }}</p>
|
||||
{% else %}
|
||||
<div class="woocommerce-licenses">
|
||||
{% for item in licenses %}
|
||||
<div class="license-card">
|
||||
<div class="license-header">
|
||||
{% for package in packages %}
|
||||
<div class="license-package">
|
||||
<div class="package-header">
|
||||
<div class="package-title">
|
||||
<h3>
|
||||
{% if item.product_url %}
|
||||
<a href="{{ esc_url(item.product_url) }}">{{ esc_html(item.product_name) }}</a>
|
||||
{% if package.product_url %}
|
||||
<a href="{{ esc_url(package.product_url) }}">{{ esc_html(package.product_name) }}</a>
|
||||
{% else %}
|
||||
{{ esc_html(item.product_name) }}
|
||||
{{ esc_html(package.product_name) }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
<span class="license-status license-status-{{ item.license.status }}">
|
||||
{{ item.license.status|capitalize }}
|
||||
<span class="package-order">
|
||||
{{ __('Order') }}
|
||||
{% if package.order_url %}
|
||||
<a href="{{ esc_url(package.order_url) }}">#{{ esc_html(package.order_number) }}</a>
|
||||
{% else %}
|
||||
#{{ esc_html(package.order_number) }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<span class="package-license-count">
|
||||
{{ package.licenses|length }} {{ package.licenses|length == 1 ? __('License') : __('Licenses') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="license-details">
|
||||
<div class="license-key-row">
|
||||
<label>{{ __('License Key:') }}</label>
|
||||
<code class="license-key" data-license-key="{{ esc_attr(item.license.licenseKey) }}">
|
||||
{{ esc_html(item.license.licenseKey) }}
|
||||
</code>
|
||||
<button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(item.license.licenseKey) }}" title="{{ __('Copy to clipboard') }}">
|
||||
<div class="package-licenses">
|
||||
{% for license in package.licenses %}
|
||||
<div class="license-entry license-entry-{{ esc_attr(license.status) }}">
|
||||
<div class="license-row-primary">
|
||||
<div class="license-key-group">
|
||||
<code class="license-key">{{ esc_html(license.license_key) }}</code>
|
||||
<span class="license-status license-status-{{ esc_attr(license.status) }}">
|
||||
{{ esc_html(license.status)|capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="license-actions">
|
||||
<button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(license.license_key) }}" title="{{ __('Copy to clipboard') }}">
|
||||
<span class="dashicons dashicons-clipboard"></span>
|
||||
</button>
|
||||
{% if license.is_transferable %}
|
||||
<button type="button" class="wclp-transfer-btn"
|
||||
data-license-id="{{ license.id }}"
|
||||
data-current-domain="{{ esc_attr(license.domain) }}"
|
||||
title="{{ __('Transfer to new domain') }}">
|
||||
<span class="dashicons dashicons-randomize"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="license-row-secondary">
|
||||
<span class="license-meta-item license-domain">
|
||||
<span class="dashicons dashicons-admin-site-alt3"></span>
|
||||
{{ esc_html(license.domain) }}
|
||||
</span>
|
||||
<span class="license-meta-item license-expiry">
|
||||
<span class="dashicons dashicons-calendar-alt"></span>
|
||||
{% if license.expires_at %}
|
||||
{{ license.expires_at|date('Y-m-d') }}
|
||||
{% else %}
|
||||
<span class="lifetime">{{ __('Lifetime') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if signing_enabled and license.customer_secret %}
|
||||
<div class="license-row-secret">
|
||||
<button type="button" class="secret-toggle" aria-expanded="false">
|
||||
<span class="dashicons dashicons-lock"></span>
|
||||
{{ __('API Verification Secret') }}
|
||||
<span class="dashicons dashicons-arrow-down-alt2 toggle-arrow"></span>
|
||||
</button>
|
||||
<div class="secret-content" style="display: none;">
|
||||
<p class="secret-description">
|
||||
{{ __('Use this secret to verify signed API responses. Keep it secure.') }}
|
||||
</p>
|
||||
<div class="secret-value-wrapper">
|
||||
<code class="secret-value">{{ esc_html(license.customer_secret) }}</code>
|
||||
<button type="button" class="copy-secret-btn" data-secret="{{ esc_attr(license.customer_secret) }}" title="{{ __('Copy to clipboard') }}">
|
||||
<span class="dashicons dashicons-clipboard"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="license-info-row">
|
||||
<span class="license-domain-display" data-license-id="{{ item.license.id }}">
|
||||
<strong>{{ __('Domain:') }}</strong>
|
||||
<span class="domain-value">{{ esc_html(item.license.domain) }}</span>
|
||||
{% if item.license.status == 'active' or item.license.status == 'inactive' %}
|
||||
<button type="button" class="wclp-transfer-btn"
|
||||
data-license-id="{{ item.license.id }}"
|
||||
data-current-domain="{{ esc_attr(item.license.domain) }}"
|
||||
title="{{ __('Transfer to new domain') }}">
|
||||
<span class="dashicons dashicons-randomize"></span>
|
||||
{{ __('Transfer') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span><strong>{{ __('Expires:') }}</strong>
|
||||
{% if item.license.expiresAt %}
|
||||
{{ item.license.expiresAt|date('Y-m-d') }}
|
||||
{% else %}
|
||||
{{ __('Never') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if item.downloads is defined and item.downloads is not empty %}
|
||||
<div class="license-downloads">
|
||||
{% if package.downloads is defined and package.downloads is not empty %}
|
||||
<div class="package-downloads">
|
||||
<h4>{{ __('Available Downloads') }}</h4>
|
||||
<ul class="download-list">
|
||||
{% for download in item.downloads %}
|
||||
<li>
|
||||
{# Show only the latest version (first item) #}
|
||||
{% set latest = package.downloads|first %}
|
||||
<li class="download-item download-item-latest">
|
||||
<div class="download-row-file">
|
||||
<a href="{{ esc_url(latest.download_url) }}" class="download-link">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
{{ esc_html(latest.filename ?: 'Version ' ~ latest.version) }}
|
||||
</a>
|
||||
<span class="download-version-badge">{{ __('Latest') }}</span>
|
||||
</div>
|
||||
<div class="download-row-meta">
|
||||
<span class="download-date">{{ esc_html(latest.released_at) }}</span>
|
||||
{% if latest.file_hash %}
|
||||
<span class="download-hash" title="{{ esc_attr(latest.file_hash) }}">
|
||||
<span class="dashicons dashicons-shield"></span>
|
||||
<code>{{ latest.file_hash[:12] }}...</code>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{# Show older versions in collapsible if more than one version exists #}
|
||||
{% if package.downloads|length > 1 %}
|
||||
<div class="older-versions-section">
|
||||
<button type="button" class="older-versions-toggle" aria-expanded="false">
|
||||
<span class="dashicons dashicons-arrow-down-alt2"></span>
|
||||
{{ __('Older versions') }} ({{ package.downloads|length - 1 }})
|
||||
</button>
|
||||
<ul class="download-list older-versions-list" style="display: none;">
|
||||
{% for download in package.downloads|slice(1) %}
|
||||
<li class="download-item">
|
||||
<div class="download-row-file">
|
||||
<a href="{{ esc_url(download.download_url) }}" class="download-link">
|
||||
<span class="dashicons dashicons-download"></span>
|
||||
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }}
|
||||
</a>
|
||||
<span class="download-version">v{{ esc_html(download.version) }}</span>
|
||||
</div>
|
||||
<div class="download-row-meta">
|
||||
<span class="download-date">{{ esc_html(download.released_at) }}</span>
|
||||
{% if download.file_hash %}
|
||||
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
|
||||
<span class="dashicons dashicons-shield"></span>
|
||||
<code>{{ download.file_hash[:12] }}...</code>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.2.0
|
||||
* Version: 0.5.8
|
||||
* 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.2.0');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.5.8');
|
||||
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