You've already forked wc-licensed-product
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 086755cb11 | |||
| 0b58de193e | |||
| ae49b262fa | |||
| 5d5bb7e595 | |||
| bee9854c18 | |||
| c31df1e8c4 | |||
| 8cac742f57 |
54
CHANGELOG.md
54
CHANGELOG.md
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
152
CLAUDE.md
152
CLAUDE.md
@@ -32,10 +32,6 @@ 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.
|
||||
|
||||
### Version 0.5.2
|
||||
|
||||
*No planned bugfixes yet.*
|
||||
|
||||
### Version 0.6.0
|
||||
|
||||
*No planned features yet.*
|
||||
@@ -1293,3 +1289,151 @@ Bug fix release improving admin UI usability for version management and license
|
||||
- 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
|
||||
|
||||
@@ -110,6 +110,16 @@
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -118,7 +128,8 @@
|
||||
const [domains, setDomains] = useState(() => {
|
||||
const init = {};
|
||||
products.forEach(p => {
|
||||
init[p.product_id] = Array(p.quantity).fill('');
|
||||
const key = getProductKey(p);
|
||||
init[key] = Array(p.quantity).fill('');
|
||||
});
|
||||
return init;
|
||||
});
|
||||
@@ -128,16 +139,16 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (productId, index, value) => {
|
||||
const handleChange = (productKey, index, value) => {
|
||||
const normalized = normalizeDomain(value);
|
||||
const newDomains = { ...domains };
|
||||
if (!newDomains[productId]) newDomains[productId] = [];
|
||||
newDomains[productId] = [...newDomains[productId]];
|
||||
newDomains[productId][index] = normalized;
|
||||
if (!newDomains[productKey]) newDomains[productKey] = [];
|
||||
newDomains[productKey] = [...newDomains[productKey]];
|
||||
newDomains[productKey][index] = normalized;
|
||||
setDomains(newDomains);
|
||||
|
||||
// Validate
|
||||
const key = `${productId}_${index}`;
|
||||
const key = `${productKey}_${index}`;
|
||||
const newErrors = { ...errors };
|
||||
if (normalized && !isValidDomain(normalized)) {
|
||||
newErrors[key] = settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product');
|
||||
@@ -145,14 +156,14 @@
|
||||
delete newErrors[key];
|
||||
}
|
||||
|
||||
// Check for duplicates within same product
|
||||
const productDomains = newDomains[productId].filter(d => d);
|
||||
// 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[productId].forEach((d, idx) => {
|
||||
newDomains[productKey].forEach((d, idx) => {
|
||||
const normalizedD = normalizeDomain(d);
|
||||
const dupKey = `${productId}_${idx}`;
|
||||
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) {
|
||||
@@ -163,11 +174,19 @@
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
// Update hidden field
|
||||
const data = Object.entries(newDomains).map(([pid, doms]) => ({
|
||||
product_id: parseInt(pid, 10),
|
||||
domains: doms.filter(d => d),
|
||||
})).filter(item => item.domains.length > 0);
|
||||
// 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) {
|
||||
@@ -192,35 +211,43 @@
|
||||
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 => createElement(
|
||||
'div',
|
||||
{
|
||||
key: product.product_id,
|
||||
style: {
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '4px',
|
||||
}
|
||||
},
|
||||
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
|
||||
product.name + (product.quantity > 1 ? ` (×${product.quantity})` : '')
|
||||
),
|
||||
Array.from({ length: product.quantity }, (_, i) => {
|
||||
const key = `${product.product_id}_${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[product.product_id]?.[i] || '',
|
||||
onChange: (val) => handleChange(product.product_id, i, val),
|
||||
placeholder: settings.fieldPlaceholder || 'example.com',
|
||||
help: errors[key] || '',
|
||||
})
|
||||
);
|
||||
})
|
||||
)),
|
||||
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',
|
||||
@@ -291,10 +318,19 @@
|
||||
<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 => `
|
||||
${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;">
|
||||
${product.name}${product.quantity > 1 ? ` (×${product.quantity})` : ''}
|
||||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
||||
</strong>
|
||||
${Array.from({ length: product.quantity }, (_, i) => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
@@ -302,14 +338,20 @@
|
||||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_domains[${product.product_id}][${i}]"
|
||||
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('')}
|
||||
`}).join('')}
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
|
||||
4
composer.lock
generated
4
composer.lock
generated
@@ -12,7 +12,7 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
||||
"reference": "64d215cb265a64ff318cfbb954dd128b0076dc1d"
|
||||
"reference": "5e4b5a970f75d0163c5496581d963a24ade4f276"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
@@ -52,7 +52,7 @@
|
||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
||||
},
|
||||
"time": "2026-01-24T13:32:11+00:00"
|
||||
"time": "2026-01-26T15:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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
|
||||
@@ -157,16 +157,23 @@ final class ResponseSigner
|
||||
* 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
|
||||
{
|
||||
// HKDF-like key derivation
|
||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
||||
// RFC 5869 HKDF using PHP's native implementation
|
||||
// Must match client's ResponseSignature::deriveKey() exactly
|
||||
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
||||
return bin2hex($binaryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,7 +111,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -257,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' => [
|
||||
@@ -281,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;
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -295,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;
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -320,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
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -141,7 +142,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
/**
|
||||
* Get licensed products from cart with quantities
|
||||
*
|
||||
* @return array<int, array{product_id: int, name: string, quantity: int}>
|
||||
* @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
|
||||
*/
|
||||
private function getLicensedProductsFromCart(): array
|
||||
{
|
||||
@@ -152,13 +153,49 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
$licensedProducts = [];
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -57,7 +58,7 @@ final class CheckoutController
|
||||
/**
|
||||
* Get licensed products from cart with quantities
|
||||
*
|
||||
* @return array<int, array{product_id: int, name: string, quantity: int}>
|
||||
* @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
|
||||
*/
|
||||
private function getLicensedProductsFromCart(): array
|
||||
{
|
||||
@@ -68,13 +69,51 @@ final class CheckoutController
|
||||
$licensedProducts = [];
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,22 +189,32 @@ final class CheckoutController
|
||||
<?php esc_html_e('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
|
||||
<?php foreach ($licensedProducts as $productId => $productData): ?>
|
||||
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>">
|
||||
<?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']);
|
||||
printf(' ×%d', $productData['quantity']);
|
||||
}
|
||||
?>
|
||||
</h4>
|
||||
|
||||
<?php for ($i = 0; $i < $productData['quantity']; $i++): ?>
|
||||
<?php
|
||||
$fieldName = sprintf('licensed_domains[%d][%d]', $productId, $i);
|
||||
$fieldId = sprintf('licensed_domain_%d_%d', $productId, $i);
|
||||
$savedValue = $this->getSavedDomainValue($productId, $i);
|
||||
$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); ?>">
|
||||
@@ -186,6 +235,9 @@ final class CheckoutController
|
||||
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>
|
||||
@@ -197,6 +249,7 @@ final class CheckoutController
|
||||
.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; }
|
||||
@@ -207,9 +260,17 @@ final class CheckoutController
|
||||
/**
|
||||
* Get saved domain value from session/POST
|
||||
*/
|
||||
private function getSavedDomainValue(int $productId, int $index): string
|
||||
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]);
|
||||
}
|
||||
@@ -218,7 +279,11 @@ final class CheckoutController
|
||||
if (WC()->session) {
|
||||
$sessionDomains = WC()->session->get('licensed_product_domains', []);
|
||||
foreach ($sessionDomains as $item) {
|
||||
if (isset($item['product_id']) && (int) $item['product_id'] === $productId) {
|
||||
$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];
|
||||
}
|
||||
@@ -272,8 +337,12 @@ final class CheckoutController
|
||||
{
|
||||
$licensedDomains = $_POST['licensed_domains'] ?? [];
|
||||
|
||||
foreach ($licensedProducts as $productId => $productData) {
|
||||
$productDomains = $licensedDomains[$productId] ?? [];
|
||||
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++) {
|
||||
@@ -308,7 +377,7 @@ final class CheckoutController
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicate domains within same product
|
||||
// Check for duplicate domains within same product/variation
|
||||
if (in_array($normalizedDomain, $normalizedDomains, true)) {
|
||||
wc_add_notice(
|
||||
sprintf(
|
||||
@@ -369,10 +438,15 @@ final class CheckoutController
|
||||
private function saveMultiDomainFields(\WC_Order $order, array $licensedProducts): void
|
||||
{
|
||||
$licensedDomains = $_POST['licensed_domains'] ?? [];
|
||||
$licensedVariationIds = $_POST['licensed_variation_ids'] ?? [];
|
||||
$domainData = [];
|
||||
|
||||
foreach ($licensedProducts as $productId => $productData) {
|
||||
$productDomains = $licensedDomains[$productId] ?? [];
|
||||
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++) {
|
||||
@@ -383,10 +457,17 @@ final class CheckoutController
|
||||
}
|
||||
|
||||
if (!empty($normalizedDomains)) {
|
||||
$domainData[] = [
|
||||
$entry = [
|
||||
'product_id' => $productId,
|
||||
'domains' => $normalizedDomains,
|
||||
];
|
||||
|
||||
// Include variation_id if present
|
||||
if ($variationId > 0) {
|
||||
$entry['variation_id'] = $variationId;
|
||||
}
|
||||
|
||||
$domainData[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,8 +513,22 @@ final class CheckoutController
|
||||
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
|
||||
<?php foreach ($domainData as $item): ?>
|
||||
<?php
|
||||
$product = wc_get_product($item['product_id']);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
$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>
|
||||
@@ -482,8 +577,20 @@ final class CheckoutController
|
||||
if ($plainText) {
|
||||
echo "\n" . esc_html__('License Domains:', 'wc-licensed-product') . "\n";
|
||||
foreach ($domainData as $item) {
|
||||
$product = wc_get_product($item['product_id']);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
$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 {
|
||||
@@ -492,8 +599,19 @@ final class CheckoutController
|
||||
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
|
||||
<?php foreach ($domainData as $item): ?>
|
||||
<?php
|
||||
$product = wc_get_product($item['product_id']);
|
||||
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
$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>
|
||||
|
||||
@@ -100,6 +100,9 @@ final class StoreApiExtension
|
||||
'product_id' => [
|
||||
'type' => 'integer',
|
||||
],
|
||||
'variation_id' => [
|
||||
'type' => 'integer',
|
||||
],
|
||||
'domains' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
@@ -162,6 +165,7 @@ final class StoreApiExtension
|
||||
}
|
||||
|
||||
$productId = (int) $item['product_id'];
|
||||
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
|
||||
$domains = [];
|
||||
|
||||
foreach ($item['domains'] as $domain) {
|
||||
@@ -172,10 +176,17 @@ final class StoreApiExtension
|
||||
}
|
||||
|
||||
if (!empty($domains)) {
|
||||
$normalized[] = [
|
||||
$entry = [
|
||||
'product_id' => $productId,
|
||||
'domains' => $domains,
|
||||
];
|
||||
|
||||
// Include variation_id if present
|
||||
if ($variationId > 0) {
|
||||
$entry['variation_id'] = $variationId;
|
||||
}
|
||||
|
||||
$normalized[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,10 +278,23 @@ final class StoreApiExtension
|
||||
// Check for licensed_domains in classic format (from DOM injection)
|
||||
if (empty($domainData) && isset($requestData['licensed_domains']) && is_array($requestData['licensed_domains'])) {
|
||||
$domainData = [];
|
||||
foreach ($requestData['licensed_domains'] as $productId => $domains) {
|
||||
$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);
|
||||
@@ -279,10 +303,16 @@ final class StoreApiExtension
|
||||
}
|
||||
}
|
||||
if (!empty($normalizedDomains)) {
|
||||
$domainData[] = [
|
||||
'product_id' => (int) $productId,
|
||||
$entry = [
|
||||
'product_id' => $productId,
|
||||
'domains' => $normalizedDomains,
|
||||
];
|
||||
|
||||
if ($variationId > 0) {
|
||||
$entry['variation_id'] = $variationId;
|
||||
}
|
||||
|
||||
$domainData[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 +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;
|
||||
|
||||
// Normalize domain first for duplicate detection
|
||||
$normalizedDomain = $this->normalizeDomain($domain);
|
||||
|
||||
// Check if license already exists for this order, product, and domain
|
||||
$existing = $this->getLicenseByOrderProductAndDomain($orderId, $productId, $normalizedDomain);
|
||||
// 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')) {
|
||||
return null;
|
||||
// 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 (!$settingsProduct instanceof LicensedProduct) {
|
||||
$settingsProduct = new LicensedProduct($productId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have the LicensedProduct instance for type hints
|
||||
if (!$product instanceof LicensedProduct) {
|
||||
$product = new LicensedProduct($productId);
|
||||
if (!$settingsProduct) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate unique license key
|
||||
@@ -74,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);
|
||||
}
|
||||
|
||||
@@ -99,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']
|
||||
@@ -112,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
|
||||
*/
|
||||
|
||||
@@ -227,23 +227,35 @@ final class Plugin
|
||||
$orderId = $order->get_id();
|
||||
$customerId = $order->get_customer_id();
|
||||
|
||||
// Index domains by product ID for quick lookup
|
||||
// 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'])) {
|
||||
$domainsByProduct[(int) $item['product_id']] = $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')) {
|
||||
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$productId = $product->get_id();
|
||||
$domains = $domainsByProduct[$productId] ?? [];
|
||||
// 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) {
|
||||
@@ -252,7 +264,8 @@ final class Plugin
|
||||
$orderId,
|
||||
$productId,
|
||||
$customerId,
|
||||
$domain
|
||||
$domain,
|
||||
$variationId > 0 ? $variationId : null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -271,12 +284,17 @@ final class Plugin
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
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(),
|
||||
$product->get_id(),
|
||||
$productId,
|
||||
$order->get_customer_id(),
|
||||
$domain
|
||||
$domain,
|
||||
$variationId > 0 ? $variationId : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +40,11 @@ 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);
|
||||
@@ -51,25 +54,48 @@ final class LicensedProductType
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -81,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;
|
||||
@@ -236,9 +262,16 @@ 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;
|
||||
}
|
||||
|
||||
@@ -253,7 +286,7 @@ final class LicensedProductType
|
||||
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -272,11 +305,11 @@ final class LicensedProductType
|
||||
{
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var LicensedProduct $product */
|
||||
/** @var LicensedProduct|LicensedVariableProduct $product */
|
||||
$version = $product->get_current_version();
|
||||
|
||||
if (empty($version)) {
|
||||
@@ -289,4 +322,200 @@ final class LicensedProductType
|
||||
esc_html($version)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cart template for variable licensed products
|
||||
*/
|
||||
public function variableAddToCartTemplate(): void
|
||||
{
|
||||
wc_get_template('single-product/add-to-cart/variable.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Hide shipping tab (virtual products)
|
||||
$('.shipping_tab').hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
toggleLicensedVariableOptions();
|
||||
|
||||
// On product type change
|
||||
$('#product-type').on('change', function() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.5.2
|
||||
* Version: 0.5.5
|
||||
* 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.5.2');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.5.5');
|
||||
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