You've already forked wc-licensed-product
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 548b2ae8af | |||
| e0001c3f4e | |||
| a879be989c | |||
| 40c08bf474 | |||
| 5826c744dc | |||
| 3a81544f30 | |||
| 89493aa5b6 | |||
| 46e5b5a1c5 | |||
| b89225c6d7 | |||
| 0ebd2d0103 | |||
| 6a10eada8c | |||
| f4da9e116a | |||
| 601a4f6da2 | |||
| 0758caefc7 | |||
| bcd3481ea3 | |||
| 60fb5cc13c | |||
| 1dc128a1e5 | |||
| f32758ab28 | |||
| ac1814cbb0 | |||
| 2d6bfa219a | |||
| 302f2e76ca | |||
| 5938aaed1b | |||
| 630a5859d3 | |||
| 36e1fdc20a | |||
| cbece2f279 | |||
| b50969f701 | |||
| d0af939f5e | |||
| c1a337aabe | |||
| ff0229061d | |||
| 7bbffa50b4 | |||
| e168b1a44b | |||
| eb8818aa81 | |||
| fddeda4a80 | |||
| b670bacf27 | |||
| f8f6434342 | |||
| dace416608 | |||
| 72017f4c62 | |||
| f9efe698ea | |||
| d2e3b41a00 | |||
| 4b6fafe500 | |||
| d29697ac62 | |||
| 142500cab0 | |||
| 20fb39d1a1 | |||
| 953aa6c8e8 | |||
| db4966caf2 | |||
| 9c4232f14f | |||
| 0638767ce3 | |||
| 9826c8181e | |||
| fa972ceaf0 | |||
| 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 |
228
.gitea/workflows/release.yml
Normal file
228
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,228 @@
|
||||
name: Create Release Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, xml, zip, intl, gettext
|
||||
tools: composer:v2
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
- name: Validate composer.json
|
||||
run: composer validate --no-check-lock --no-check-all
|
||||
|
||||
- name: Install Composer dependencies (production)
|
||||
run: |
|
||||
composer config platform.php 8.3.0
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
- name: Fix vendor symlink
|
||||
run: |
|
||||
# If client is a symlink, replace with actual files
|
||||
if [ -L "vendor/magdev/wc-licensed-product-client" ]; then
|
||||
echo "Found symlink, replacing with actual files..."
|
||||
TARGET=$(readlink -f vendor/magdev/wc-licensed-product-client)
|
||||
rm vendor/magdev/wc-licensed-product-client
|
||||
cp -r "$TARGET" vendor/magdev/wc-licensed-product-client
|
||||
fi
|
||||
ls -la vendor/magdev/
|
||||
|
||||
- name: Install gettext
|
||||
run: apt-get update && apt-get install -y gettext
|
||||
|
||||
- name: Compile translations
|
||||
run: |
|
||||
for po in languages/*.po; do
|
||||
if [ -f "$po" ]; then
|
||||
mo="${po%.po}.mo"
|
||||
echo "Compiling $po to $mo"
|
||||
msgfmt -o "$mo" "$po"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Verify plugin version matches tag
|
||||
run: |
|
||||
PLUGIN_VERSION=$(grep -oP "Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+" wc-licensed-product.php | head -1)
|
||||
TAG_VERSION=${{ steps.version.outputs.version }}
|
||||
if [ "$PLUGIN_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "Error: Plugin version ($PLUGIN_VERSION) does not match tag version ($TAG_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version verified: $PLUGIN_VERSION"
|
||||
|
||||
- name: Create release directory
|
||||
run: mkdir -p releases
|
||||
|
||||
- name: Build release package
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
PLUGIN_NAME="wc-licensed-product"
|
||||
RELEASE_FILE="releases/${PLUGIN_NAME}-${VERSION}.zip"
|
||||
|
||||
cd ..
|
||||
zip -r "${PLUGIN_NAME}/${RELEASE_FILE}" "${PLUGIN_NAME}" \
|
||||
-x "${PLUGIN_NAME}/.git/*" \
|
||||
-x "${PLUGIN_NAME}/.gitea/*" \
|
||||
-x "${PLUGIN_NAME}/.github/*" \
|
||||
-x "${PLUGIN_NAME}/.vscode/*" \
|
||||
-x "${PLUGIN_NAME}/.claude/*" \
|
||||
-x "${PLUGIN_NAME}/CLAUDE.md" \
|
||||
-x "${PLUGIN_NAME}/wp-core" \
|
||||
-x "${PLUGIN_NAME}/wp-core/*" \
|
||||
-x "${PLUGIN_NAME}/wp-plugins" \
|
||||
-x "${PLUGIN_NAME}/wp-plugins/*" \
|
||||
-x "${PLUGIN_NAME}/releases/*" \
|
||||
-x "${PLUGIN_NAME}/composer.lock" \
|
||||
-x "${PLUGIN_NAME}/*.log" \
|
||||
-x "${PLUGIN_NAME}/.gitignore" \
|
||||
-x "${PLUGIN_NAME}/.gitmodules" \
|
||||
-x "${PLUGIN_NAME}/.editorconfig" \
|
||||
-x "${PLUGIN_NAME}/phpcs.xml*" \
|
||||
-x "${PLUGIN_NAME}/phpunit.xml*" \
|
||||
-x "${PLUGIN_NAME}/tests/*" \
|
||||
-x "${PLUGIN_NAME}/*.po~" \
|
||||
-x "${PLUGIN_NAME}/*.bak" \
|
||||
-x "${PLUGIN_NAME}/lib/*" \
|
||||
-x "${PLUGIN_NAME}/lib/*/.git/*" \
|
||||
-x "${PLUGIN_NAME}/vendor/magdev/*/.git/*" \
|
||||
-x "${PLUGIN_NAME}/vendor/magdev/*/CLAUDE.md" \
|
||||
-x "*.DS_Store"
|
||||
|
||||
cd "${PLUGIN_NAME}"
|
||||
echo "Created: ${RELEASE_FILE}"
|
||||
ls -lh "${RELEASE_FILE}"
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
PLUGIN_NAME="wc-licensed-product"
|
||||
|
||||
cd releases
|
||||
sha256sum "${PLUGIN_NAME}-${VERSION}.zip" > "${PLUGIN_NAME}-${VERSION}.zip.sha256"
|
||||
echo "SHA256:"
|
||||
cat "${PLUGIN_NAME}-${VERSION}.zip.sha256"
|
||||
|
||||
- name: Verify package structure
|
||||
run: |
|
||||
set +o pipefail
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
PLUGIN_NAME="wc-licensed-product"
|
||||
|
||||
echo "Package contents (first 50 entries):"
|
||||
unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | head -50 || true
|
||||
|
||||
# Verify main plugin file exists
|
||||
if unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | grep -q "${PLUGIN_NAME}/${PLUGIN_NAME}.php"; then
|
||||
echo "Main plugin file: OK"
|
||||
else
|
||||
echo "ERROR: Main plugin file not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify vendor directory included
|
||||
if unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | grep -q "${PLUGIN_NAME}/vendor/"; then
|
||||
echo "Vendor directory: OK"
|
||||
else
|
||||
echo "ERROR: Vendor directory not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify lib directory excluded
|
||||
if unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | grep -q "${PLUGIN_NAME}/lib/"; then
|
||||
echo "WARNING: lib/ directory should be excluded"
|
||||
else
|
||||
echo "lib/ excluded: OK"
|
||||
fi
|
||||
|
||||
- name: Extract changelog for release notes
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | sed '$ d' | tail -n +2)
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="Release version ${VERSION}"
|
||||
fi
|
||||
echo "$NOTES" > release_notes.txt
|
||||
echo "Release notes extracted"
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SRC_GITEA_TOKEN }}
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
TAG_NAME=${{ github.ref_name }}
|
||||
PLUGIN_NAME="wc-licensed-product"
|
||||
|
||||
PRERELEASE="false"
|
||||
if [[ "$TAG_NAME" == *-* ]]; then
|
||||
PRERELEASE="true"
|
||||
fi
|
||||
|
||||
BODY=$(cat release_notes.txt)
|
||||
|
||||
# Check if release already exists and delete it
|
||||
EXISTING_RELEASE=$(curl -s \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}")
|
||||
|
||||
EXISTING_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty')
|
||||
if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
|
||||
echo "Deleting existing release ID: $EXISTING_ID"
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${EXISTING_ID}"
|
||||
echo "Existing release deleted"
|
||||
fi
|
||||
|
||||
# Create release via Gitea API
|
||||
RELEASE_RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${VERSION}\", \"body\": $(echo "$BODY" | jq -Rs .), \"draft\": false, \"prerelease\": ${PRERELEASE}}" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||
|
||||
if [ "$RELEASE_ID" == "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release:"
|
||||
echo "$RELEASE_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Created release ID: $RELEASE_ID"
|
||||
|
||||
# Upload release assets
|
||||
for file in "releases/${PLUGIN_NAME}-${VERSION}.zip" "releases/${PLUGIN_NAME}-${VERSION}.zip.sha256"; do
|
||||
if [ -f "$file" ]; then
|
||||
FILENAME=$(basename "$file")
|
||||
echo "Uploading $FILENAME..."
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$file" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
|
||||
echo "Uploaded $FILENAME"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Release created: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG_NAME}"
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "lib/wc-licensed-product-client"]
|
||||
path = lib/wc-licensed-product-client
|
||||
url = ../wc-licensed-product-client.git
|
||||
397
CHANGELOG.md
397
CHANGELOG.md
@@ -7,6 +7,403 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.3] - 2026-02-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Docker Environment Support:** API Verification Secret now visible on customer licenses page in Docker environments
|
||||
- Added `ResponseSigner::getServerSecret()` method to check multiple sources for server secret
|
||||
- Checks PHP constant, `getenv()`, `$_ENV`, and `$_SERVER` in priority order
|
||||
- Maintains full backward compatibility with standard WordPress installations
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `Plugin.php` to use `ResponseSigner::isSigningEnabled()` instead of direct constant check
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Root cause: Docker WordPress setups using `wp-config-docker.php` with `getenv_docker()` don't always define PHP constants
|
||||
- The environment variable was accessible but the constant wasn't being created
|
||||
- New `getServerSecret()` method centralizes all server secret retrieval logic
|
||||
|
||||
## [0.7.2] - 2026-01-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Gitea CI/CD Pipeline**: Automated release workflow triggered on version tags
|
||||
- Automatic package creation with proper WordPress subdirectory structure
|
||||
- SHA256 checksum generation for package integrity
|
||||
- Changelog extraction for release notes
|
||||
- Pre-release detection for hyphenated tags (e.g., `v0.7.2-rc1`)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Git Submodule Migration**: `magdev/wc-licensed-product-client` is now a git submodule
|
||||
- Located at `lib/wc-licensed-product-client` instead of being fetched via Composer VCS
|
||||
- Composer now uses `path` type repository pointing to local submodule
|
||||
- Improves version control clarity and development workflow
|
||||
- Symlinked to `vendor/` during `composer install`
|
||||
|
||||
### Developer Notes
|
||||
|
||||
- New file: `.gitea/workflows/release.yml` for CI/CD automation
|
||||
- Updated `composer.json`: Repository type changed from `vcs` to `path`
|
||||
- Created `.gitmodules` for submodule tracking
|
||||
- Release packages now exclude `lib/` directory (vendor has installed copy)
|
||||
- Submodule checkout required: `git submodule update --init --recursive`
|
||||
|
||||
## [0.7.1] - 2026-01-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed API Verification Secret not displayed in PHP fallback template on customer account licenses page
|
||||
- Response signing now includes `/update-check` endpoint (was missing from signed routes)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.2
|
||||
- Updated `symfony/http-client` dependency to v7.4.5
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Added customer secret display to `displayLicensesFallback()` method in `AccountController`
|
||||
- Added `/update-check` route to `ResponseSigner::shouldSign()` method for consistent signature headers
|
||||
- Verified server implementation aligns with updated client library documentation
|
||||
|
||||
## [0.7.0] - 2026-01-28
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed XSS vulnerability in checkout blocks DOM fallback injection
|
||||
- Unified IP detection for rate limiting across all REST API endpoints
|
||||
- Added rate limiting to license transfers (5 per hour) and downloads (30 per hour)
|
||||
- Added file size (2MB), row count (1000), and rate limiting to CSV import
|
||||
- Added JSON decode error handling in Store API extension
|
||||
- Added jQuery selector sanitization for license ID validation
|
||||
|
||||
### Added
|
||||
|
||||
- New `IpDetectionTrait` for shared IP detection logic with proxy support
|
||||
- New `RateLimitTrait` for reusable frontend rate limiting
|
||||
- New `src/Common/` directory for shared traits
|
||||
|
||||
### Changed
|
||||
|
||||
- RestApiController now uses IpDetectionTrait instead of inline methods
|
||||
- UpdateController now uses IpDetectionTrait for consistent rate limiting behind proxies
|
||||
- AccountController now uses RateLimitTrait for transfer rate limiting
|
||||
- DownloadController now uses RateLimitTrait for download rate limiting
|
||||
- Checkout blocks fallback uses safe DOM construction instead of innerHTML
|
||||
|
||||
## [0.6.1] - 2026-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Filter functionality on customer account licenses page (filter by product or domain)
|
||||
- Split auto-update settings into two options: "Enable Update Notifications" and "Automatically Install Updates"
|
||||
- New `isUpdateNotificationEnabled()`, `isAutoInstallEnabled()` static methods in SettingsController
|
||||
- WordPress auto-update filter integration (`auto_update_plugin`) for automatic installation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed admin license test popup showing empty product field
|
||||
- `handleAjaxTestLicense()` now enriches response with product name
|
||||
- Removed version field from test popup (version_id is only set for version-bound licenses)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.1
|
||||
- "Automatically Install Updates" is only selectable when "Enable Update Notifications" is enabled
|
||||
|
||||
## [0.6.0] - 2026-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- WordPress-style automatic update system for licensed plugins
|
||||
- Server-side `/update-check` REST API endpoint for WordPress-compatible update information
|
||||
- Client-side `PluginUpdateChecker` singleton for WordPress update integration
|
||||
- New "Auto-Updates" settings subtab with enable/disable and check frequency options
|
||||
- Secure download authentication via `X-License-Key` header
|
||||
- Response signing support for tamper-proof update responses
|
||||
- Configurable cache TTL for update checks (1-168 hours)
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated OpenAPI specification to version 0.6.0 with `/update-check` endpoint documentation
|
||||
|
||||
## [0.5.15] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tab rendering bug in WooCommerce product edit page when switching to licensed or licensed-variable product types
|
||||
- Simplified JavaScript to avoid conflicts with WooCommerce's native show/hide logic
|
||||
- Removed conflicting CSS rule for `.hide_if_licensed` that was causing layout issues
|
||||
- License Settings tab now uses CSS class toggle (`.wclp-active`) instead of jQuery `.show()/.hide()` for proper display
|
||||
- Variations tab now properly shows for licensed-variable products via `woocommerce_product_data_tabs` filter
|
||||
|
||||
## [0.5.14] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed Product Versions meta box not appearing for licensed-variable products
|
||||
- Product Versions meta box now always added to product pages, visibility controlled via CSS/JavaScript
|
||||
- Added `Installer::registerProductTypes()` to create product type terms in the `product_type` taxonomy
|
||||
- Product type terms are now ensured to exist on `woocommerce_init` hook for existing installations
|
||||
- Fixed License Settings tab and Product Versions visibility toggling when changing product types
|
||||
|
||||
## [0.5.13] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed licenses not showing in admin order form for licensed-variable products
|
||||
- `OrderLicenseController` now uses `LicenseManager::isLicensedProduct()` for consistent product type detection
|
||||
- Fixed expected licenses calculation for variable product orders
|
||||
- Fixed manual license generation from admin order page for variable products
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed debug logging from all source files (PHP and JavaScript)
|
||||
- Cleaned up checkout blocks integration, Store API extension, and checkout controller
|
||||
|
||||
## [0.5.12] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed stock indicator ("1 in stock") appearing in cart for licensed variable product variations
|
||||
- Override `get_children()` with direct SQL query to bypass WooCommerce's `is_type('variable')` check
|
||||
- Override `get_variation_attributes()` to properly load taxonomy attribute terms
|
||||
- Override `get_variation_prices()` to prevent fatal error with null `$this->prices_array`
|
||||
- Override `get_available_variations()` with empty `availability_html` for variations
|
||||
- Added `is_type()` override to return true for both 'licensed-variable' and 'variable' type checks
|
||||
- Added multiple stock-related filters: `woocommerce_get_availability_text`, `woocommerce_product_get_stock_quantity`, `woocommerce_product_variation_get_stock_quantity`
|
||||
- Improved `isLicensedProductOrVariation()` check using `WC_Product_Factory::get_product_type()` for reliable parent type detection
|
||||
|
||||
### Changed
|
||||
|
||||
- `LicensedProductVariation` now includes `get_availability()`, `managing_stock()`, and `is_purchasable()` overrides
|
||||
- Simplified `isVirtual()` to use shared `isLicensedProductOrVariation()` helper
|
||||
|
||||
## [0.5.11] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CRITICAL:** Fixed "sold out" message on licensed variable products by correcting `is_purchasable()` method
|
||||
- Variable products don't have a direct price - `is_purchasable()` now delegates to parent `WC_Product_Variable` class
|
||||
- Fixed variation class detection by using product ID parameter instead of unreliable global `$post`
|
||||
- Product class filter now properly accepts all 4 WooCommerce filter parameters for reliable variation detection
|
||||
|
||||
## [0.5.10] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed licensed variable products not showing variations even when attributes are defined
|
||||
- Re-load product via `wc_get_product()` to ensure correct class instance is used
|
||||
- Removed overly strict type check that was preventing variations from displaying
|
||||
- Now mirrors WooCommerce's standard `woocommerce_variable_add_to_cart()` implementation
|
||||
|
||||
## [0.5.9] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed frontend error on licensed variable products when no attributes are defined
|
||||
- Added null checks for `get_variation_attributes()`, `get_available_variations()`, and `get_default_attributes()`
|
||||
- Show informative message instead of error when product has no variations configured
|
||||
- Changed product type check from `instanceof` to `is_type()` for better compatibility
|
||||
|
||||
## [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
|
||||
|
||||
931
CLAUDE.md
931
CLAUDE.md
@@ -34,15 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
### Known Bugs
|
||||
|
||||
No known bugs at the moment.
|
||||
|
||||
### Version 0.3.8
|
||||
|
||||
No changes at the moment.
|
||||
|
||||
### Version 0.4.0
|
||||
|
||||
No changes at the moment.
|
||||
None currently tracked.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -63,6 +55,13 @@ No changes at the moment.
|
||||
- Nonce verification on form submissions
|
||||
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
|
||||
- Direct file access prevention via `ABSPATH` check
|
||||
- XSS-safe DOM construction in JavaScript (no `innerHTML` with user data)
|
||||
- Rate limiting on API endpoints (configurable via `WC_LICENSE_RATE_LIMIT`)
|
||||
- Rate limiting on frontend operations (transfers: 5/hour, downloads: 30/hour)
|
||||
- CSV import limits (2MB max, 1000 rows max, 5-minute cooldown)
|
||||
- IP detection with proxy support via `IpDetectionTrait` (supports `WC_LICENSE_TRUSTED_PROXIES`)
|
||||
- SQL injection prevention using `$wpdb->prepare()` throughout
|
||||
- Secure download URLs with hash verification using `hash_equals()`
|
||||
|
||||
### Translation Ready
|
||||
|
||||
@@ -205,11 +204,13 @@ wc-licensed-product/
|
||||
├── releases/ # Release packages (version 0.1.0+)
|
||||
├── src/
|
||||
│ ├── Admin/ # AdminController - license management UI
|
||||
│ ├── Api/ # RestApiController - license validation endpoints
|
||||
│ ├── Api/ # RestApiController, UpdateController - REST API endpoints
|
||||
│ ├── Checkout/ # CheckoutController - domain field at checkout
|
||||
│ ├── Email/ # WooCommerce email classes
|
||||
│ ├── Frontend/ # AccountController - customer licenses page
|
||||
│ ├── License/ # License model and LicenseManager
|
||||
│ └── Product/ # LicensedProduct type and LicensedProductType
|
||||
│ ├── Product/ # LicensedProduct type and LicensedProductType
|
||||
│ └── Update/ # PluginUpdateChecker - WordPress auto-update integration
|
||||
├── templates/
|
||||
│ ├── admin/ # Twig templates for admin views
|
||||
│ └── frontend/ # Twig templates for customer views
|
||||
@@ -227,11 +228,12 @@ Created on plugin activation via `Installer::createTables()`:
|
||||
|
||||
Base: `/wp-json/wc-licensed-product/v1/`
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ----------- | ------ | ------------------------------- |
|
||||
| `/validate` | POST | Validate license key for domain |
|
||||
| `/status` | POST | Get license status |
|
||||
| `/activate` | POST | Activate license on domain |
|
||||
| Endpoint | Method | Description |
|
||||
| --------------- | ------ | ---------------------------------- |
|
||||
| `/validate` | POST | Validate license key for domain |
|
||||
| `/status` | POST | Get license status |
|
||||
| `/activate` | POST | Activate license on domain |
|
||||
| `/update-check` | POST | Check for plugin updates (v0.6.0+) |
|
||||
|
||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||
|
||||
@@ -1091,3 +1093,900 @@ Fixed dashboard widget bugs, improved UI consistency, and added download trackin
|
||||
- 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
|
||||
|
||||
### 2026-01-27 - Version 0.5.8-0.5.11 - Licensed Variable Product Fixes
|
||||
|
||||
**Overview:**
|
||||
|
||||
Series of bug fixes for licensed variable products that were showing frontend errors and not displaying properly.
|
||||
|
||||
**v0.5.8 - Initial Fix:**
|
||||
|
||||
- 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`)
|
||||
- Added JavaScript event listeners for WooCommerce AJAX events to maintain admin variants tab visibility
|
||||
|
||||
**v0.5.9 - Null Checks:**
|
||||
|
||||
- Added null checks for `get_variation_attributes()`, `get_available_variations()`, and `get_default_attributes()`
|
||||
- Show informative message instead of error when product has no variations configured
|
||||
- Changed product type check from `instanceof` to `is_type()` for better compatibility
|
||||
|
||||
**v0.5.10 - Product Loading:**
|
||||
|
||||
- Re-load product via `wc_get_product()` to ensure correct class instance is used
|
||||
- Removed overly strict type check that was preventing variations from displaying
|
||||
|
||||
**v0.5.11 - Final Fix:**
|
||||
|
||||
- **CRITICAL:** Fixed "sold out" message on licensed variable products
|
||||
- `LicensedVariableProduct::is_purchasable()` now delegates to parent `WC_Product_Variable` class (variable products don't have direct prices - only variations do)
|
||||
- Fixed `getProductClass()` filter to accept all 4 WooCommerce parameters and use product_id for reliable variation parent detection
|
||||
- Added fallback to global `$post` when product_id not available
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Product/LicensedProductType.php` - Fixed `variableAddToCartTemplate()` and `getProductClass()` methods
|
||||
- `src/Product/LicensedVariableProduct.php` - Fixed `is_purchasable()` method
|
||||
- `wc-licensed-product.php` - Version bumps
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- WooCommerce `woocommerce_product_class` filter has 4 parameters: `$className`, `$productType`, `$postType`, `$productId`
|
||||
- Variable products delegate purchasability to their variations - checking `get_price()` on parent is incorrect
|
||||
- Variation parent detection must use product ID, not global `$post` which may not be set on frontend
|
||||
|
||||
**Release v0.5.11:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.11.zip` (857 KB)
|
||||
- SHA256: `32571178bfa8f0d0a03ed05b498d5f9b3c860104393a96732e86a03b6de298d2`
|
||||
- Committed to `dev` branch
|
||||
|
||||
### 2026-01-27 - Version 0.5.12 - Stock Display Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed stock indicator appearing in cart for licensed variable product variations.
|
||||
|
||||
**Bug Fix:**
|
||||
|
||||
- Fixed "1 in stock" message appearing in cart for licensed variable product variations
|
||||
- Added multiple WooCommerce filter overrides to suppress stock display
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Product/LicensedVariableProduct.php` - Override `get_children()`, `get_variation_attributes()`, `get_variation_prices()`, `get_available_variations()`, `is_type()`
|
||||
- `src/Product/LicensedProductVariation.php` - Added `get_availability()`, `managing_stock()`, `is_purchasable()` overrides
|
||||
- `src/Product/LicensedProductType.php` - Added stock-related filter hooks
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- `get_children()` uses direct SQL query to bypass WooCommerce's `is_type('variable')` check
|
||||
- `is_type()` override returns true for both 'licensed-variable' and 'variable' type checks
|
||||
- Stock filters: `woocommerce_get_availability_text`, `woocommerce_product_get_stock_quantity`, `woocommerce_product_variation_get_stock_quantity`
|
||||
|
||||
### 2026-01-27 - Version 0.5.13 - Admin Order License Display Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed licenses not showing in admin order form for licensed-variable products and removed debug logging.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- **CRITICAL:** Fixed licenses not appearing in admin order form for orders containing licensed-variable products
|
||||
- `OrderLicenseController` now uses `LicenseManager::isLicensedProduct()` for consistent product type detection across 4 locations
|
||||
- Fixed expected licenses calculation for variable product orders
|
||||
- Fixed manual license generation from admin order page for variable products
|
||||
|
||||
**Cleanup:**
|
||||
|
||||
- Removed all debug `error_log()` calls from PHP source files
|
||||
- Removed all debug `console.log()` calls from JavaScript files
|
||||
- Files cleaned: Plugin.php, CheckoutBlocksIntegration.php, StoreApiExtension.php, CheckoutController.php, checkout-blocks.js
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/OrderLicenseController.php` - Use `isLicensedProduct()` in 4 locations
|
||||
- `src/Plugin.php` - Remove debug logging
|
||||
- `src/Checkout/CheckoutBlocksIntegration.php` - Remove debug logging
|
||||
- `src/Checkout/StoreApiExtension.php` - Remove debug logging
|
||||
- `src/Checkout/CheckoutController.php` - Remove debug logging
|
||||
- `assets/js/checkout-blocks.js` - Remove debug logging
|
||||
|
||||
**Release v0.5.13:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.13.zip` (1.0 MB)
|
||||
- SHA256: `814710ad899529d0015494e4b332eace7d8e55aeda381fdf61f99274c0bf910c`
|
||||
- Committed to `dev` branch
|
||||
|
||||
### 2026-01-27 - Version 0.5.14 - Product Versions Meta Box Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed Product Versions meta box not appearing for licensed-variable products in admin.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Product Versions meta box now always added to product pages, visibility controlled via CSS/JavaScript
|
||||
- Added `Installer::registerProductTypes()` to create product type terms in the `product_type` taxonomy
|
||||
- Product type terms are now ensured to exist on `woocommerce_init` hook for existing installations
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/VersionAdminController.php` - Simplified `addVersionsMetaBox()` to always add meta box
|
||||
- `src/Installer.php` - Added `registerProductTypes()` method
|
||||
- `src/Product/LicensedProductType.php` - Added `ensureProductTypeTermsExist()` hook
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- WooCommerce's `WC_Product_Factory::get_product_type()` requires product type terms to exist in the `product_type` taxonomy
|
||||
- Meta box visibility is controlled via JavaScript based on selected product type
|
||||
- Taxonomy terms are registered on `woocommerce_init` hook to ensure WooCommerce is fully loaded
|
||||
|
||||
### 2026-01-27 - Version 0.5.15 - Tab Rendering Fix
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed tab rendering bug in WooCommerce product edit page when switching to licensed or licensed-variable product types.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Fixed tab rendering issue where License Settings and Variations tabs appeared shifted/overlapping
|
||||
- Simplified JavaScript to avoid conflicts with WooCommerce's native show/hide logic
|
||||
- Removed conflicting CSS rule for `.hide_if_licensed` that was causing layout issues
|
||||
- License Settings tab now uses CSS class toggle (`.wclp-active`) instead of jQuery `.show()/.hide()`
|
||||
- Variations tab properly shows for licensed-variable products via `woocommerce_product_data_tabs` filter
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Product/LicensedProductType.php` - Simplified `toggleOurElements()` JavaScript function, added `show_if_licensed-variable` class to variations tab
|
||||
- `assets/css/admin.css` - Removed `.hide_if_licensed` rule, updated tab visibility CSS to target `li.licensed_product_options`
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- jQuery's `.show()` sets `display: block` which can break `<li>` element layouts in tab lists
|
||||
- Using CSS class toggle (`addClass/removeClass`) preserves proper display values
|
||||
- WooCommerce product data tabs use class pattern `{tab_key}_options` (e.g., `licensed_product_options`)
|
||||
- The `woocommerce_product_data_tabs` filter allows adding classes to existing tabs like variations
|
||||
|
||||
**Release v0.5.15:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.5.15.zip` (862 KB)
|
||||
- SHA256: `47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278`
|
||||
- Committed to `dev` branch
|
||||
|
||||
### 2026-01-27 - Version 0.6.0 - WordPress Auto-Update System
|
||||
|
||||
**Overview:**
|
||||
|
||||
Major feature release implementing WordPress-style automatic updates. Licensed plugins can now receive updates through WordPress's native plugin update mechanism by checking against the license server.
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Api/UpdateController.php` - Server-side REST API endpoint for update checks
|
||||
- `src/Update/PluginUpdateChecker.php` - Client-side singleton for WordPress update integration
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Server-side `/update-check` REST API endpoint serving WordPress-compatible update information
|
||||
- Client-side `PluginUpdateChecker` singleton hooking into WordPress's native update system
|
||||
- Hooks: `pre_set_site_transient_update_plugins`, `plugins_api`, `http_request_args`
|
||||
- New "Auto-Updates" settings subtab with enable/disable toggle and check frequency
|
||||
- Configurable cache TTL for update checks (1-168 hours, default: 12)
|
||||
- Secure download authentication via `X-License-Key` header
|
||||
- Response signing support for tamper-proof update responses
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Plugin.php` - Added UpdateController and PluginUpdateChecker initialization
|
||||
- `src/Admin/SettingsController.php` - Added 'auto-updates' section with settings
|
||||
- `openapi.json` - Documented `/update-check` endpoint with request/response schemas
|
||||
- `languages/*` - Updated translations for new strings
|
||||
|
||||
**Settings Controller Changes:**
|
||||
|
||||
- Added `'auto-updates'` to `getSections()` for sub-tab navigation
|
||||
- New `getAutoUpdatesSettings()` method returning enable/frequency settings
|
||||
- New static methods: `isAutoUpdateEnabled()`, `getUpdateCheckFrequency()`
|
||||
|
||||
**UpdateController API:**
|
||||
|
||||
- Endpoint: `POST /wp-json/wc-licensed-product/v1/update-check`
|
||||
- Request: `license_key`, `domain`, `plugin_slug` (optional), `current_version` (optional)
|
||||
- Response: `update_available`, `version`, `download_url`, `package`, `changelog`, `tested`, `requires`, `requires_php`, etc.
|
||||
- License validation before serving update info
|
||||
- Secure download URL generation using existing DownloadController patterns
|
||||
|
||||
**PluginUpdateChecker Features:**
|
||||
|
||||
- Singleton pattern with `getInstance()`
|
||||
- Caching via WordPress transients (`wclp_update_info`)
|
||||
- Automatic cache clearing on settings save
|
||||
- Only activates when license server URL is configured and not self-licensing
|
||||
- `forceUpdateCheck()` method for manual refresh
|
||||
|
||||
**Configuration:**
|
||||
|
||||
To disable auto-updates programmatically:
|
||||
|
||||
```php
|
||||
define('WC_LICENSE_DISABLE_AUTO_UPDATE', true);
|
||||
```
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Update checker only registers when `SettingsController::getPluginLicenseServerUrl()` returns a value
|
||||
- Self-licensing detection prevents circular update checks (via `PluginLicenseChecker::isSelfLicensing()`)
|
||||
- Download URLs include license key in `X-License-Key` header for server-side verification
|
||||
- Uses Symfony HttpClient for server requests with 15s timeout
|
||||
- Cache TTL configurable from 1-168 hours in settings
|
||||
- OpenAPI spec updated to version 0.6.0 with full `/update-check` documentation
|
||||
|
||||
**Release v0.6.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.6.0.zip` (1.1 MB)
|
||||
- SHA256: `171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027`
|
||||
- Tagged as `v0.6.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-27 - Version 0.6.1 - UI Improvements & Bug Fixes
|
||||
|
||||
**Overview:**
|
||||
|
||||
Bug fix and improvement release addressing admin license testing, auto-update settings, and customer license filtering.
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- Filter functionality on customer account licenses page (filter by product or domain)
|
||||
- Split auto-update settings into "Enable Update Notifications" and "Automatically Install Updates"
|
||||
- WordPress `auto_update_plugin` filter integration for automatic installation
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- Fixed admin license test popup showing empty product field
|
||||
- Removed version field from test popup (version_id is only set for version-bound licenses)
|
||||
- `handleAjaxTestLicense()` now enriches response with product name
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/AdminController.php` - Enriched test license response with product name
|
||||
- `src/Admin/SettingsController.php` - Split auto-update settings, added static helper methods
|
||||
- `src/Update/PluginUpdateChecker.php` - Added `auto_update_plugin` filter, use new settings methods
|
||||
- `src/Frontend/AccountController.php` - Added filter functionality with `applyLicenseFilters()` method
|
||||
- `templates/frontend/licenses.html.twig` - Added filter form with product and domain dropdowns
|
||||
- `templates/admin/licenses.html.twig` - Removed version row from test license modal
|
||||
- `assets/css/frontend.css` - Added responsive styles for filter form
|
||||
- `languages/*` - Updated all translation files
|
||||
|
||||
**New methods in SettingsController:**
|
||||
|
||||
- `isUpdateNotificationEnabled()` - Check if update notifications are enabled
|
||||
- `isAutoInstallEnabled()` - Check if auto-install is enabled (requires notifications enabled)
|
||||
|
||||
**New methods in AccountController:**
|
||||
|
||||
- `applyLicenseFilters()` - Filter licenses by product ID and/or domain
|
||||
- `getFilterOptions()` - Get unique products and domains for filter dropdowns
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Filter form uses GET parameters: `filter_product` and `filter_domain`
|
||||
- Auto-install setting is disabled (greyed out) when update notifications are disabled
|
||||
- License test popup now only shows Product and Expires fields (version removed)
|
||||
- Domain filter uses case-insensitive partial matching via `stripos()`
|
||||
|
||||
**Dependency Updates:**
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1
|
||||
|
||||
### 2026-01-28 - Version 0.7.0 - Security Hardening
|
||||
|
||||
**Overview:**
|
||||
|
||||
Security-focused release with comprehensive audit and hardening. Performed OWASP Top 10 testing against live shop (shop.magdev.cc) and fixed identified vulnerabilities.
|
||||
|
||||
**Security Audit Results:**
|
||||
|
||||
- SQL injection: Protected (prepared statements throughout)
|
||||
- CSRF: Protected (nonce verification on all forms/AJAX)
|
||||
- Rate limiting: Working (429 responses after ~28 requests)
|
||||
- Access control: Working (403 for unauthorized access)
|
||||
- HTTPS: Enforced with proper redirect
|
||||
- Missing security headers noted: X-Frame-Options, HSTS, CSP (server-level configuration)
|
||||
|
||||
**Critical Fixes:**
|
||||
|
||||
- **XSS in checkout-blocks.js**: Replaced `innerHTML` template literals with safe DOM construction using `document.createElement()` and `textContent`
|
||||
- **IP Detection**: UpdateController was using raw `$_SERVER['REMOTE_ADDR']` without proxy support - now uses shared `IpDetectionTrait`
|
||||
|
||||
**New Files:**
|
||||
|
||||
- `src/Api/IpDetectionTrait.php` - Shared IP detection with proxy header support (Cloudflare, X-Forwarded-For, X-Real-IP)
|
||||
- `src/Common/RateLimitTrait.php` - Reusable rate limiting for frontend operations
|
||||
|
||||
**Security Enhancements:**
|
||||
|
||||
- Added rate limiting to license transfers (5/hour per user)
|
||||
- Added rate limiting to file downloads (30/hour per user)
|
||||
- Added CSV import limits: 2MB max file size, 1000 max rows, 5-minute cooldown
|
||||
- Added JSON error handling in StoreApiExtension
|
||||
- Added license ID validation in frontend.js to prevent selector injection
|
||||
|
||||
**Modified Files:**
|
||||
|
||||
- `assets/js/checkout-blocks.js` - XSS-safe DOM construction
|
||||
- `assets/js/frontend.js` - Added `sanitizeForSelector()` helper
|
||||
- `src/Api/RestApiController.php` - Use IpDetectionTrait, remove duplicate methods
|
||||
- `src/Api/UpdateController.php` - Use IpDetectionTrait for rate limiting
|
||||
- `src/Admin/AdminController.php` - CSV import security limits
|
||||
- `src/Frontend/AccountController.php` - Transfer rate limiting
|
||||
- `src/Frontend/DownloadController.php` - Download rate limiting
|
||||
- `src/Checkout/StoreApiExtension.php` - JSON error handling
|
||||
|
||||
**Technical Notes:**
|
||||
|
||||
- IpDetectionTrait supports `WC_LICENSE_TRUSTED_PROXIES` constant for proxy configuration
|
||||
- RateLimitTrait uses WordPress transients with user ID-based keys
|
||||
- CSV import constants: `MAX_IMPORT_FILE_SIZE = 2097152`, `MAX_IMPORT_ROWS = 1000`, `IMPORT_RATE_LIMIT_WINDOW = 300`
|
||||
|
||||
**Release v0.7.0:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.7.0.zip` (883 KB)
|
||||
- SHA256: `12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18`
|
||||
- Tagged as `v0.7.0` and pushed to `main` branch
|
||||
|
||||
### 2026-01-28 - Version 0.7.1 - Bug Fixes & Client Compatibility
|
||||
|
||||
**Overview:**
|
||||
|
||||
Bug fix release ensuring compatibility with updated `magdev/wc-licensed-product-client` v0.2.2 and fixing API Verification Secret display.
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
- **CRITICAL:** Fixed API Verification Secret not displaying on customer account licenses page when using PHP fallback (Twig unavailable)
|
||||
- Fixed `/update-check` endpoint responses not being signed (missing from `ResponseSigner::shouldSign()`)
|
||||
|
||||
**Dependency Updates:**
|
||||
|
||||
- Updated `magdev/wc-licensed-product-client` from `760e1e7` to `56abe8a` (v0.2.2)
|
||||
- Updated `symfony/http-client` from v7.4.4 to v7.4.5
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Frontend/AccountController.php` - Added customer secret display to PHP fallback method `displayLicensesFallback()`
|
||||
- `src/Api/ResponseSigner.php` - Added `/update-check` to `shouldSign()` method
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- PHP fallback template now includes the collapsible API Verification Secret section matching the Twig template
|
||||
- All four API endpoints (`/validate`, `/status`, `/activate`, `/update-check`) now include signature headers when `WC_LICENSE_SERVER_SECRET` is configured
|
||||
- Client library v0.2.2 verified compatible with server implementation
|
||||
|
||||
**Release v0.7.1:**
|
||||
|
||||
- Created release package: `releases/wc-licensed-product-0.7.1.zip` (886 KB)
|
||||
- SHA256: `6ffd0bdf47395436bbc28a029eff4c6d065f2b5b64c687b96ae36a74c3ee34ef`
|
||||
- Tagged as `v0.7.1` and pushed to `main` branch
|
||||
|
||||
### 2026-01-29 - Version 0.7.2 - Git Submodule & CI/CD Pipeline
|
||||
|
||||
**Overview:**
|
||||
|
||||
Infrastructure release converting the client library dependency to a git submodule and implementing automated CI/CD releases via Gitea Actions.
|
||||
|
||||
**Git Submodule Migration:**
|
||||
|
||||
- Converted `magdev/wc-licensed-product-client` from Composer VCS dependency to git submodule
|
||||
- Submodule located at `lib/wc-licensed-product-client`
|
||||
- Composer uses `path` type repository pointing to local submodule
|
||||
- Symlinked to `vendor/magdev/wc-licensed-product-client` during `composer install`
|
||||
|
||||
**Gitea CI/CD Pipeline:**
|
||||
|
||||
- New workflow at `.gitea/workflows/release.yml`
|
||||
- Triggers on version tags (`v*`)
|
||||
- Automated steps:
|
||||
- Checkout with recursive submodules
|
||||
- PHP 8.3 setup with required extensions
|
||||
- Composer dependency installation (production only)
|
||||
- Translation compilation (`.po` to `.mo`)
|
||||
- Version verification against plugin header
|
||||
- Release package creation with proper exclusions
|
||||
- SHA256 checksum generation
|
||||
- Package structure verification
|
||||
- Changelog extraction for release notes
|
||||
- Gitea release creation with asset upload
|
||||
- Pre-release detection for hyphenated tags
|
||||
|
||||
**New files:**
|
||||
|
||||
- `.gitea/workflows/release.yml` - Gitea Actions workflow for automated releases
|
||||
- `.gitmodules` - Git submodule configuration (created by git)
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `composer.json` - Changed repository type from `vcs` to `path`, URL to `lib/wc-licensed-product-client`
|
||||
- `CHANGELOG.md` - Added v0.7.2 release notes
|
||||
- `CLAUDE.md` - Removed v0.7.2 from roadmap, added session history
|
||||
|
||||
**Package Exclusions:**
|
||||
|
||||
Release packages exclude: `.git/`, `.gitea/`, `.gitmodules`, `lib/` (submodule source), `vendor/**/.git`, `tests/`, `CLAUDE.md`, `*.po~`, `wp-core`, `wp-plugins`, `composer.lock`
|
||||
|
||||
**Developer Workflow Changes:**
|
||||
|
||||
After cloning the repository, developers must now run:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
composer install
|
||||
```
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Path repository uses `*` version constraint with `symlink: false` option
|
||||
- CI replaces symlink with actual files via `cp -r` before packaging
|
||||
- CI uses `actions/checkout@v4` with `submodules: recursive` for proper submodule initialization
|
||||
- Release creation uses direct Gitea API calls (`/api/v1/repos/.../releases`)
|
||||
- Requires `SRC_GITEA_TOKEN` secret configured in Gitea repository settings
|
||||
- Workflow completed successfully: 57 seconds, all checks passed
|
||||
|
||||
**Release v0.7.2:**
|
||||
|
||||
- Automatically created by Gitea Actions CI/CD pipeline
|
||||
- Release package: 881 KiB with SHA256 checksum
|
||||
- First automated release - all future releases will use this workflow
|
||||
|
||||
**Additional fixes (same session):**
|
||||
|
||||
- Updated README.md with Auto-Updates section and Development section
|
||||
- Fixed CI/CD workflow to handle existing releases (delete before recreate)
|
||||
- When updating a tag, the workflow now checks for existing releases and deletes them first
|
||||
|
||||
**Lessons learned:**
|
||||
|
||||
- Gitea releases persist even when their tag is deleted - must delete release via API
|
||||
- Composer `symlink: false` doesn't always work - CI must manually replace symlinks with `cp -r`
|
||||
- Never create zip archives locally on this machine (fills up RAM indefinitely)
|
||||
- Gitea API endpoint for releases by tag: `GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}`
|
||||
|
||||
### 2026-02-01 - Bug Fix: API Verification Secret Not Visible
|
||||
|
||||
**Overview:**
|
||||
|
||||
Fixed the "API Verification Secret" (customer secret) not appearing on the customer account licenses page in Docker environments.
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
The `WC_LICENSE_SERVER_SECRET` constant was not being defined even though the environment variable was set. In Docker WordPress setups using `wp-config-docker.php`, the `getenv_docker()` function retrieves values from environment variables, but the constant wasn't being created properly. The plugin was only checking for the PHP constant, not the environment variable directly.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Added `ResponseSigner::getServerSecret()` static method that checks multiple sources for the server secret:
|
||||
|
||||
1. `WC_LICENSE_SERVER_SECRET` constant (standard WordPress configuration)
|
||||
2. `getenv('WC_LICENSE_SERVER_SECRET')` (Docker environments)
|
||||
3. `$_ENV['WC_LICENSE_SERVER_SECRET']` (some PHP configurations)
|
||||
4. `$_SERVER['WC_LICENSE_SERVER_SECRET']` (fallback)
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Api/ResponseSigner.php` - Added `getServerSecret()` method, updated `isSigningEnabled()` and `getCustomerSecretForLicense()` to use it
|
||||
- `src/Plugin.php` - Updated to use `ResponseSigner::isSigningEnabled()` instead of direct constant check
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- The fix maintains backward compatibility with standard WordPress installations using constants
|
||||
- Docker environments can now use environment variables directly without needing the constant to be defined
|
||||
- All three methods (`isSigningEnabled()`, `getCustomerSecretForLicense()`, and constructor) now use the centralized `getServerSecret()` method
|
||||
|
||||
147
README.md
147
README.md
@@ -11,29 +11,37 @@ 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)
|
||||
- **Frontend Rate Limiting**: Transfer requests (5/hour) and downloads (30/hour) protected against abuse
|
||||
- **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)
|
||||
- **WordPress Auto-Updates**: Receive plugin updates through WordPress's native update system
|
||||
- **Automated Releases**: CI/CD pipeline for consistent release packaging
|
||||
|
||||
### 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 Widget**: License statistics on WordPress admin dashboard
|
||||
- **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
|
||||
@@ -41,10 +49,12 @@ 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
|
||||
@@ -66,13 +76,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
|
||||
@@ -84,7 +108,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
|
||||
|
||||
@@ -110,17 +135,26 @@ 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
|
||||
|
||||
**Import Limits (Security):**
|
||||
|
||||
- Maximum file size: 2MB
|
||||
- Maximum rows per import: 1000
|
||||
- Cooldown between imports: 5 minutes
|
||||
|
||||
## 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
|
||||
- **XSS-Safe DOM Construction**: JavaScript uses `createElement()` and `textContent` instead of `innerHTML`
|
||||
- **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
|
||||
- **Rate Limiting**: API and frontend operations protected against abuse
|
||||
- **Import Limits**: CSV imports limited by file size, row count, and cooldown period
|
||||
|
||||
### Trusted Proxy Configuration
|
||||
|
||||
@@ -144,6 +178,18 @@ 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).
|
||||
@@ -173,6 +219,8 @@ openssl rand -hex 32
|
||||
|
||||
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:
|
||||
@@ -294,6 +342,41 @@ Content-Type: application/json
|
||||
| `max_activations_reached` | Maximum activations reached |
|
||||
| `rate_limit_exceeded` | Too many requests (wait and retry) |
|
||||
|
||||
## Auto-Updates
|
||||
|
||||
Licensed plugins can receive updates through WordPress's native plugin update system. When properly configured, WordPress will check the license server for updates and display them in the Plugins page.
|
||||
|
||||
### Configuration
|
||||
|
||||
In WooCommerce > Settings > Licensed Products > Auto-Updates:
|
||||
|
||||
- **Enable Update Notifications**: Show available updates in WordPress admin
|
||||
- **Automatically Install Updates**: Let WordPress install updates automatically
|
||||
- **Update Check Frequency**: How often to check for updates (1-168 hours)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. The plugin periodically checks the configured license server for updates
|
||||
2. If a newer version is available and the license is valid, WordPress shows the update
|
||||
3. Updates can be installed manually or automatically (if enabled)
|
||||
4. Downloads are authenticated using the license key
|
||||
|
||||
### API Endpoint
|
||||
|
||||
The update check uses the `/update-check` REST API endpoint:
|
||||
|
||||
```http
|
||||
POST /wp-json/wc-licensed-product/v1/update-check
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"license_key": "XXXX-XXXX-XXXX-XXXX",
|
||||
"domain": "example.com",
|
||||
"plugin_slug": "my-plugin",
|
||||
"current_version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
## License Statuses
|
||||
|
||||
- **Active**: License is valid and usable
|
||||
@@ -323,6 +406,60 @@ For issues and feature requests, please visit:
|
||||
|
||||
Marco Graetsch
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
After cloning the repository, initialize the git submodule and install dependencies:
|
||||
|
||||
```bash
|
||||
git clone https://src.bundespruefstelle.ch/magdev/wc-licensed-product.git
|
||||
cd wc-licensed-product
|
||||
git submodule update --init --recursive
|
||||
composer install
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
- `src/` - PHP source files (PSR-4 autoloaded)
|
||||
- `assets/` - CSS and JavaScript files
|
||||
- `templates/` - Twig templates for admin and frontend views
|
||||
- `languages/` - Translation files (.pot, .po, .mo)
|
||||
- `lib/` - Git submodule for the client library
|
||||
- `docs/` - API documentation and client examples
|
||||
|
||||
### Creating Releases
|
||||
|
||||
Releases are automatically created by the Gitea CI/CD pipeline when a version tag is pushed:
|
||||
|
||||
```bash
|
||||
# Update version in wc-licensed-product.php (both header and constant)
|
||||
# Update CHANGELOG.md with release notes
|
||||
git add -A && git commit -m "Release v0.7.3"
|
||||
git tag -a v0.7.3 -m "Release v0.7.3"
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
The pipeline will:
|
||||
|
||||
1. Build production dependencies
|
||||
2. Compile translations
|
||||
3. Create the release package with proper WordPress structure
|
||||
4. Generate SHA256 checksum
|
||||
5. Publish to Gitea releases
|
||||
|
||||
### Translations
|
||||
|
||||
To add or update translations:
|
||||
|
||||
```bash
|
||||
# Extract strings to .pot template
|
||||
# (Use a tool like wp-cli or poedit)
|
||||
|
||||
# Compile .po files to .mo for production
|
||||
for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0-or-later
|
||||
|
||||
@@ -50,15 +50,21 @@ code.file-hash {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* License Product Tab */
|
||||
#woocommerce-product-data .show_if_licensed {
|
||||
display: block !important;
|
||||
/* License Settings Tab - Hidden by default, shown via JS based on product type */
|
||||
/* WooCommerce creates tab with class: {tab_key}_options (licensed_product_options) */
|
||||
#woocommerce-product-data ul.wc-tabs li.licensed_product_options {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#woocommerce-product-data .hide_if_licensed {
|
||||
display: none !important;
|
||||
/* When shown, restore proper display for tab list items */
|
||||
#woocommerce-product-data ul.wc-tabs li.licensed_product_options.wclp-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Variations tab visibility for licensed-variable is handled by WooCommerce */
|
||||
/* We add show_if_licensed-variable class to the variations tab via PHP filter */
|
||||
|
||||
|
||||
/* Action Buttons */
|
||||
.wp-list-table .button-link-delete {
|
||||
color: #a00;
|
||||
@@ -201,7 +207,8 @@ code.file-hash {
|
||||
}
|
||||
|
||||
.licenses-table .row-actions {
|
||||
visibility: visible;
|
||||
visibility: visible !important;
|
||||
position: static !important;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,13 +37,270 @@
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
/* License Cards */
|
||||
/* Filter Form */
|
||||
.wclp-filter-form {
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1em;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wclp-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.wclp-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.wclp-filter-field label {
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.wclp-filter-field select {
|
||||
width: 100%;
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.wclp-filter-field select:focus {
|
||||
border-color: #0073aa;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px #0073aa;
|
||||
}
|
||||
|
||||
.wclp-filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.wclp-filter-actions .button {
|
||||
padding: 0.5em 1em;
|
||||
font-size: 0.95em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wclp-filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wclp-filter-field {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.wclp-filter-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wclp-filter-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 +441,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;
|
||||
@@ -282,6 +541,71 @@
|
||||
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 */
|
||||
#licensed-product-domain-field {
|
||||
margin-top: 2em;
|
||||
@@ -333,6 +657,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;
|
||||
@@ -354,33 +724,44 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -556,3 +937,118 @@
|
||||
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,518 @@
|
||||
(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, useEffect, useCallback } = 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, extensionCartUpdate } = wc.blocksCheckout;
|
||||
|
||||
// Debounce function for API updates
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Debounced API update function
|
||||
const updateStoreApi = useCallback(
|
||||
debounce((normalizedDomain) => {
|
||||
if (extensionCartUpdate) {
|
||||
extensionCartUpdate({
|
||||
namespace: 'wc-licensed-product',
|
||||
data: {
|
||||
licensed_product_domain: normalizedDomain,
|
||||
},
|
||||
}).catch(err => {
|
||||
console.error('[WCLP] Store API update error:', err);
|
||||
});
|
||||
}
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
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 Store API when valid
|
||||
updateStoreApi(normalized);
|
||||
}
|
||||
|
||||
// Update extension data for server-side processing
|
||||
setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized);
|
||||
// Store in hidden input for form submission (fallback)
|
||||
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({});
|
||||
|
||||
// Debounced API update function
|
||||
const updateStoreApi = useCallback(
|
||||
debounce((domainsData) => {
|
||||
if (extensionCartUpdate) {
|
||||
extensionCartUpdate({
|
||||
namespace: 'wc-licensed-product',
|
||||
data: {
|
||||
licensed_product_domains: domainsData,
|
||||
},
|
||||
}).catch(err => {
|
||||
console.error('[WCLP] Store API update error:', err);
|
||||
});
|
||||
}
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Build domain data for Store API
|
||||
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);
|
||||
|
||||
// Update Store API
|
||||
updateStoreApi(data);
|
||||
|
||||
// Update hidden field (fallback)
|
||||
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: () => {
|
||||
return 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;';
|
||||
|
||||
// Helper function to create elements with text content (XSS-safe)
|
||||
function createEl(tag, textContent, styles) {
|
||||
var el = document.createElement(tag);
|
||||
if (textContent) el.textContent = textContent;
|
||||
if (styles) el.style.cssText = styles;
|
||||
return el;
|
||||
}
|
||||
|
||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||
// Build header safely using DOM methods
|
||||
var header = createEl('h4', settings.sectionTitle || 'License Domains', 'margin: 0 0 8px 0;');
|
||||
container.appendChild(header);
|
||||
|
||||
var desc = createEl('p', settings.fieldDescription || 'Enter a unique domain for each license.',
|
||||
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||
container.appendChild(desc);
|
||||
|
||||
// Build product sections
|
||||
settings.licensedProducts.forEach(function(product) {
|
||||
var productKey = product.variation_id && product.variation_id > 0
|
||||
? product.product_id + '_' + product.variation_id
|
||||
: String(product.product_id);
|
||||
var durationLabel = product.duration_label || '';
|
||||
var displayName = durationLabel
|
||||
? product.name + ' (' + durationLabel + ')'
|
||||
: product.name;
|
||||
|
||||
var productDiv = createEl('div', null, 'margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;');
|
||||
|
||||
var nameEl = createEl('strong', displayName + (product.quantity > 1 ? ' ×' + product.quantity : ''),
|
||||
'display: block; margin-bottom: 8px;');
|
||||
productDiv.appendChild(nameEl);
|
||||
|
||||
// Create input fields for each quantity
|
||||
for (var i = 0; i < product.quantity; i++) {
|
||||
var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
|
||||
|
||||
var label = createEl('label', (settings.licenseLabel || 'License %d:').replace('%d', i + 1),
|
||||
'display: block; margin-bottom: 4px;');
|
||||
fieldDiv.appendChild(label);
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = 'licensed_domains[' + productKey + '][' + i + ']';
|
||||
input.placeholder = settings.fieldPlaceholder || 'example.com';
|
||||
input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;';
|
||||
fieldDiv.appendChild(input);
|
||||
|
||||
// Hidden variation ID if applicable
|
||||
if (product.variation_id && product.variation_id > 0) {
|
||||
var hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'licensed_variation_ids[' + productKey + ']';
|
||||
hiddenInput.value = String(product.variation_id);
|
||||
fieldDiv.appendChild(hiddenInput);
|
||||
}
|
||||
|
||||
productDiv.appendChild(fieldDiv);
|
||||
}
|
||||
|
||||
container.appendChild(productDiv);
|
||||
});
|
||||
} else {
|
||||
// Single domain mode - build safely using DOM methods
|
||||
var header = createEl('h4', settings.sectionTitle || 'License Domain', 'margin: 0 0 8px 0;');
|
||||
container.appendChild(header);
|
||||
|
||||
var desc = createEl('p', settings.fieldDescription || 'Enter the domain where you will use the license.',
|
||||
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||
container.appendChild(desc);
|
||||
|
||||
var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
|
||||
|
||||
var label = createEl('label', settings.singleDomainLabel || 'Domain', 'display: block; margin-bottom: 4px;');
|
||||
fieldDiv.appendChild(label);
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = 'licensed_product_domain';
|
||||
input.placeholder = settings.fieldPlaceholder || 'example.com';
|
||||
input.style.cssText = 'width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;';
|
||||
fieldDiv.appendChild(input);
|
||||
|
||||
container.appendChild(fieldDiv);
|
||||
}
|
||||
|
||||
if (contactInfo) {
|
||||
contactInfo.parentNode.insertBefore(container, contactInfo.nextSibling);
|
||||
} else if (paymentMethods) {
|
||||
paymentMethods.parentNode.insertBefore(container, paymentMethods);
|
||||
} else {
|
||||
insertionPoint.appendChild(container);
|
||||
}
|
||||
|
||||
// Add event listeners to sync with Store API
|
||||
const debouncedUpdate = debounce(function() {
|
||||
if (!extensionCartUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||
// Collect multi-domain data
|
||||
const domainsData = settings.licensedProducts.map(function(product) {
|
||||
const productKey = product.variation_id && product.variation_id > 0
|
||||
? product.product_id + '_' + product.variation_id
|
||||
: String(product.product_id);
|
||||
|
||||
const domains = [];
|
||||
for (let i = 0; i < product.quantity; i++) {
|
||||
const input = container.querySelector('input[name="licensed_domains[' + productKey + '][' + i + ']"]');
|
||||
if (input && input.value.trim()) {
|
||||
domains.push(normalizeDomain(input.value));
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
product_id: product.product_id,
|
||||
domains: domains,
|
||||
};
|
||||
if (product.variation_id && product.variation_id > 0) {
|
||||
entry.variation_id = product.variation_id;
|
||||
}
|
||||
return entry;
|
||||
}).filter(function(item) { return item.domains.length > 0; });
|
||||
|
||||
extensionCartUpdate({
|
||||
namespace: 'wc-licensed-product',
|
||||
data: {
|
||||
licensed_product_domains: domainsData,
|
||||
},
|
||||
}).catch(function(err) {
|
||||
console.error('[WCLP] Store API update error:', err);
|
||||
});
|
||||
} else {
|
||||
// Single domain
|
||||
const input = container.querySelector('input[name="licensed_product_domain"]');
|
||||
if (input) {
|
||||
const domain = normalizeDomain(input.value);
|
||||
extensionCartUpdate({
|
||||
namespace: 'wc-licensed-product',
|
||||
data: {
|
||||
licensed_product_domain: domain,
|
||||
},
|
||||
}).catch(function(err) {
|
||||
console.error('[WCLP] Store API update error:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Attach event listeners to all domain inputs
|
||||
container.querySelectorAll('input[type="text"]').forEach(function(input) {
|
||||
input.addEventListener('input', debouncedUpdate);
|
||||
input.addEventListener('change', debouncedUpdate);
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
})();
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
$modal: null,
|
||||
$form: null,
|
||||
|
||||
/**
|
||||
* Sanitize a value for safe use in jQuery selectors
|
||||
* License IDs should be numeric only
|
||||
*/
|
||||
sanitizeForSelector: function(value) {
|
||||
return String(value).replace(/[^\d]/g, '');
|
||||
},
|
||||
|
||||
init: function() {
|
||||
this.$modal = $('#wclp-transfer-modal');
|
||||
this.$form = $('#wclp-transfer-form');
|
||||
@@ -19,12 +27,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 +48,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
|
||||
*/
|
||||
@@ -109,6 +179,11 @@
|
||||
var licenseId = $btn.data('license-id');
|
||||
var currentDomain = $btn.data('current-domain');
|
||||
|
||||
// Validate license ID is numeric
|
||||
if (!licenseId || !/^\d+$/.test(String(licenseId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$('#transfer-license-id').val(licenseId);
|
||||
$('#transfer-current-domain').text(currentDomain);
|
||||
$('#transfer-new-domain').val('');
|
||||
@@ -173,9 +248,12 @@
|
||||
.removeClass('error').addClass('success').show();
|
||||
|
||||
// Update the domain display in the license card
|
||||
var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]');
|
||||
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
||||
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain);
|
||||
var safeLicenseId = self.sanitizeForSelector(licenseId);
|
||||
if (safeLicenseId) {
|
||||
var $domainDisplay = $('.license-domain-display[data-license-id="' + safeLicenseId + '"]');
|
||||
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
||||
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain);
|
||||
}
|
||||
|
||||
// Close modal after a short delay
|
||||
setTimeout(function() {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -174,6 +174,24 @@
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract version from filename
|
||||
* Supports patterns like: plugin-v1.2.3.zip, plugin-1.2.3.zip, v1.2.3.zip
|
||||
@@ -244,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('');
|
||||
|
||||
@@ -12,14 +12,17 @@
|
||||
],
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git"
|
||||
"type": "path",
|
||||
"url": "lib/wc-licensed-product-client",
|
||||
"options": {
|
||||
"symlink": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3.0",
|
||||
"twig/twig": "^3.0",
|
||||
"magdev/wc-licensed-product-client": "dev-main"
|
||||
"magdev/wc-licensed-product-client": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
27
composer.lock
generated
27
composer.lock
generated
@@ -4,15 +4,15 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "05af8ab515abe7e689c610724b54e27a",
|
||||
"content-hash": "f13b7ed9531068d0180f28adc8a80397",
|
||||
"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": "64d215cb265a64ff318cfbb954dd128b0076dc1d"
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "lib/wc-licensed-product-client",
|
||||
"reference": "f9281ec5fb23bf1993ab0240e0347c835009a10f"
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
@@ -24,7 +24,6 @@
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -52,7 +51,9 @@
|
||||
"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"
|
||||
"transport-options": {
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
@@ -380,16 +381,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v7.4.3",
|
||||
"version": "v7.4.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616"
|
||||
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616",
|
||||
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -457,7 +458,7 @@
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.3"
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -477,7 +478,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-23T14:50:43+00:00"
|
||||
"time": "2026-01-27T16:16:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
|
||||
@@ -8,18 +8,20 @@ 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
|
||||
|
||||
- PHP 7.4+ (8.0+ recommended)
|
||||
- PHP 8.3+
|
||||
- A server secret stored securely (not in version control)
|
||||
|
||||
## Server Configuration
|
||||
@@ -49,25 +51,33 @@ php -r "echo bin2hex(random_bytes(32));"
|
||||
|
||||
### Key Derivation
|
||||
|
||||
Each license key gets a unique signing key derived from the server secret:
|
||||
Each license key gets a unique signing key derived from the server secret using RFC 5869 HKDF:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Derive a unique signing key for a license.
|
||||
*
|
||||
* @param string $licenseKey The license key
|
||||
* @param string $serverSecret The server's master secret
|
||||
* @return string The derived key (hex encoded)
|
||||
* Uses PHP's native hash_hkdf() function per RFC 5869.
|
||||
*
|
||||
* @param string $licenseKey The license key (used as "info" context)
|
||||
* @param string $serverSecret The server's master secret (used as IKM)
|
||||
* @return string The derived key (hex encoded, 64 characters)
|
||||
*/
|
||||
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||
{
|
||||
// HKDF-like key derivation
|
||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
||||
// HKDF key derivation per RFC 5869
|
||||
// IKM: server_secret, Length: 32 bytes, Info: license_key
|
||||
return bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey));
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** This uses PHP's native `hash_hkdf()` function (available since PHP 7.1.2). The parameters are:
|
||||
|
||||
- **Algorithm:** sha256
|
||||
- **IKM (Input Keying Material):** server_secret
|
||||
- **Length:** 32 bytes (256 bits)
|
||||
- **Info:** license_key (context-specific information)
|
||||
|
||||
### Response Signing
|
||||
|
||||
Sign every API response before sending:
|
||||
@@ -86,8 +96,8 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
||||
$timestamp = time();
|
||||
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($responseData);
|
||||
// Recursively sort keys for consistent ordering (important for nested arrays!)
|
||||
$responseData = recursive_key_sort($responseData);
|
||||
|
||||
// Build signature payload
|
||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
@@ -101,6 +111,20 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
||||
'X-License-Timestamp' => (string) $timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys alphabetically.
|
||||
*/
|
||||
function recursive_key_sort(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = recursive_key_sort($value);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
### WordPress REST API Integration
|
||||
@@ -212,7 +236,7 @@ class ResponseSigner
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
ksort($data);
|
||||
$data = $this->recursiveKeySort($data);
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
$data,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
@@ -224,11 +248,21 @@ class ResponseSigner
|
||||
];
|
||||
}
|
||||
|
||||
private function recursiveKeySort(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->recursiveKeySort($value);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function deriveKey(string $licenseKey): string
|
||||
{
|
||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||
// HKDF key derivation per RFC 5869
|
||||
return bin2hex(hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,8 +294,8 @@ signature = HMAC-SHA256(
|
||||
|
||||
Where:
|
||||
|
||||
- `derive_signing_key` uses HKDF-like derivation (see above)
|
||||
- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode
|
||||
- `derive_signing_key` uses RFC 5869 HKDF: `hash_hkdf('sha256', server_secret, 32, license_key)`
|
||||
- `canonical_json` recursively sorts keys alphabetically, no escaping of slashes/unicode
|
||||
- Result is hex-encoded (64 characters)
|
||||
|
||||
## Testing
|
||||
@@ -323,13 +357,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
1
lib/wc-licensed-product-client
Submodule
1
lib/wc-licensed-product-client
Submodule
Submodule lib/wc-licensed-product-client added at 56abe8a97c
272
openapi.json
272
openapi.json
@@ -3,7 +3,7 @@
|
||||
"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.\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",
|
||||
"version": "0.6.0",
|
||||
"contact": {
|
||||
"name": "Marco Graetsch",
|
||||
"url": "https://src.bundespruefstelle.ch/magdev",
|
||||
@@ -332,6 +332,148 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/update-check": {
|
||||
"post": {
|
||||
"operationId": "checkForUpdates",
|
||||
"summary": "Check for plugin updates",
|
||||
"description": "Checks if a newer version of the licensed product is available. Returns WordPress-compatible update information that can be used to integrate with WordPress's native plugin update system.",
|
||||
"tags": ["Plugin Updates"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateCheckRequest"
|
||||
},
|
||||
"example": {
|
||||
"license_key": "ABCD-1234-EFGH-5678",
|
||||
"domain": "example.com",
|
||||
"plugin_slug": "my-licensed-plugin",
|
||||
"current_version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"application/x-www-form-urlencoded": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateCheckRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Update check completed successfully",
|
||||
"headers": {
|
||||
"X-License-Signature": {
|
||||
"$ref": "#/components/headers/X-License-Signature"
|
||||
},
|
||||
"X-License-Timestamp": {
|
||||
"$ref": "#/components/headers/X-License-Timestamp"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateCheckResponse"
|
||||
},
|
||||
"examples": {
|
||||
"update_available": {
|
||||
"summary": "Update is available",
|
||||
"value": {
|
||||
"success": true,
|
||||
"update_available": true,
|
||||
"version": "1.2.0",
|
||||
"slug": "my-licensed-plugin",
|
||||
"plugin": "my-licensed-plugin/my-licensed-plugin.php",
|
||||
"download_url": "https://example.com/license-download/123-456-abc123",
|
||||
"package": "https://example.com/license-download/123-456-abc123",
|
||||
"last_updated": "2026-01-27",
|
||||
"tested": "6.7",
|
||||
"requires": "6.0",
|
||||
"requires_php": "8.3",
|
||||
"changelog": "## 1.2.0\n- New feature added\n- Bug fixes",
|
||||
"package_hash": "sha256:abc123def456...",
|
||||
"name": "My Licensed Plugin",
|
||||
"homepage": "https://example.com/product/my-plugin"
|
||||
}
|
||||
},
|
||||
"no_update": {
|
||||
"summary": "No update available",
|
||||
"value": {
|
||||
"success": true,
|
||||
"update_available": false,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "License validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"examples": {
|
||||
"license_invalid": {
|
||||
"summary": "License is not valid",
|
||||
"value": {
|
||||
"success": false,
|
||||
"update_available": false,
|
||||
"error": "license_invalid",
|
||||
"message": "License validation failed."
|
||||
}
|
||||
},
|
||||
"domain_mismatch": {
|
||||
"summary": "Domain mismatch",
|
||||
"value": {
|
||||
"success": false,
|
||||
"update_available": false,
|
||||
"error": "domain_mismatch",
|
||||
"message": "This license is not valid for this domain."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "License or product not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"examples": {
|
||||
"license_not_found": {
|
||||
"summary": "License not found",
|
||||
"value": {
|
||||
"success": false,
|
||||
"update_available": false,
|
||||
"error": "license_not_found",
|
||||
"message": "License not found."
|
||||
}
|
||||
},
|
||||
"product_not_found": {
|
||||
"summary": "Product not found",
|
||||
"value": {
|
||||
"success": false,
|
||||
"update_available": false,
|
||||
"error": "product_not_found",
|
||||
"message": "Licensed product not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/RateLimitExceeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -516,6 +658,130 @@
|
||||
"description": "Seconds until rate limit resets"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateCheckRequest": {
|
||||
"type": "object",
|
||||
"required": ["license_key", "domain"],
|
||||
"properties": {
|
||||
"license_key": {
|
||||
"type": "string",
|
||||
"description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)",
|
||||
"maxLength": 64,
|
||||
"example": "ABCD-1234-EFGH-5678"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain the plugin is installed on",
|
||||
"maxLength": 255,
|
||||
"example": "example.com"
|
||||
},
|
||||
"plugin_slug": {
|
||||
"type": "string",
|
||||
"description": "The plugin slug (optional, for identification)",
|
||||
"example": "my-licensed-plugin"
|
||||
},
|
||||
"current_version": {
|
||||
"type": "string",
|
||||
"description": "Currently installed version for comparison",
|
||||
"example": "1.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateCheckResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the request was successful"
|
||||
},
|
||||
"update_available": {
|
||||
"type": "boolean",
|
||||
"description": "Whether an update is available"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Latest available version"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Plugin slug for WordPress"
|
||||
},
|
||||
"plugin": {
|
||||
"type": "string",
|
||||
"description": "Plugin basename (slug/slug.php)"
|
||||
},
|
||||
"download_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Secure download URL for the update package"
|
||||
},
|
||||
"package": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Alias for download_url (WordPress compatibility)"
|
||||
},
|
||||
"last_updated": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"description": "Date of the latest release"
|
||||
},
|
||||
"tested": {
|
||||
"type": "string",
|
||||
"description": "Highest WordPress version tested with"
|
||||
},
|
||||
"requires": {
|
||||
"type": "string",
|
||||
"description": "Minimum required WordPress version"
|
||||
},
|
||||
"requires_php": {
|
||||
"type": "string",
|
||||
"description": "Minimum required PHP version"
|
||||
},
|
||||
"changelog": {
|
||||
"type": "string",
|
||||
"description": "Release notes/changelog for the update"
|
||||
},
|
||||
"package_hash": {
|
||||
"type": "string",
|
||||
"description": "SHA256 hash of the package for integrity verification",
|
||||
"example": "sha256:abc123..."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Product name"
|
||||
},
|
||||
"homepage": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Product homepage URL"
|
||||
},
|
||||
"icons": {
|
||||
"type": "object",
|
||||
"description": "Plugin icons for WordPress admin",
|
||||
"properties": {
|
||||
"1x": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"2x": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"type": "object",
|
||||
"description": "Content sections for plugin info modal",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"changelog": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
@@ -577,6 +843,10 @@
|
||||
{
|
||||
"name": "License Activation",
|
||||
"description": "Activate licenses on domains"
|
||||
},
|
||||
{
|
||||
"name": "Plugin Updates",
|
||||
"description": "Check for plugin updates via WordPress-compatible API"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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.10.zip
Normal file
BIN
releases/wc-licensed-product-0.5.10.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.10.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.10.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
2bbc0655f724e201367247f0e40974ddce6d7c559987e661f2b06b43294fc99f wc-licensed-product-0.5.10.zip
|
||||
BIN
releases/wc-licensed-product-0.5.11.zip
Normal file
BIN
releases/wc-licensed-product-0.5.11.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.11.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.11.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
32571178bfa8f0d0a03ed05b498d5f9b3c860104393a96732e86a03b6de298d2 wc-licensed-product-0.5.11.zip
|
||||
1
releases/wc-licensed-product-0.5.12.sha256
Normal file
1
releases/wc-licensed-product-0.5.12.sha256
Normal file
@@ -0,0 +1 @@
|
||||
20bb5cd453de9bca781864430ebd152c82f660b6f9fc3f09107ba03489a71d75 /home/magdev/workspaces/php/wordpress/wp-content/plugins/wc-licensed-product/releases/wc-licensed-product-0.5.12.zip
|
||||
BIN
releases/wc-licensed-product-0.5.12.zip
Normal file
BIN
releases/wc-licensed-product-0.5.12.zip
Normal file
Binary file not shown.
BIN
releases/wc-licensed-product-0.5.13.zip
Normal file
BIN
releases/wc-licensed-product-0.5.13.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.13.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.13.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
814710ad899529d0015494e4b332eace7d8e55aeda381fdf61f99274c0bf910c wc-licensed-product-0.5.13.zip
|
||||
1
releases/wc-licensed-product-0.5.15.sha256
Normal file
1
releases/wc-licensed-product-0.5.15.sha256
Normal file
@@ -0,0 +1 @@
|
||||
47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278 wc-licensed-product-0.5.15.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
|
||||
BIN
releases/wc-licensed-product-0.5.8.zip
Normal file
BIN
releases/wc-licensed-product-0.5.8.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.8.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.8.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
670c2f5182ea7140ccf9533c2b4179daf7890019a244973f467f2a5c7622b9f4 wc-licensed-product-0.5.8.zip
|
||||
BIN
releases/wc-licensed-product-0.5.9.zip
Normal file
BIN
releases/wc-licensed-product-0.5.9.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.5.9.zip.sha256
Normal file
1
releases/wc-licensed-product-0.5.9.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
fae77dab56cb8f46693cf44fe6a1dc38ad0526d881cab2cd1f0878b234afaa8b wc-licensed-product-0.5.9.zip
|
||||
1
releases/wc-licensed-product-0.6.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.6.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027 releases/wc-licensed-product-0.6.0.zip
|
||||
1
releases/wc-licensed-product-0.6.1.zip.sha256
Normal file
1
releases/wc-licensed-product-0.6.1.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
f1f1cbdfdd6cda7b20cbd2b88ab4697cde38d987e04cda1f52e885d7818d32f5 wc-licensed-product-0.6.1.zip
|
||||
BIN
releases/wc-licensed-product-0.7.0.zip
Normal file
BIN
releases/wc-licensed-product-0.7.0.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.7.0.zip.sha256
Normal file
1
releases/wc-licensed-product-0.7.0.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18 wc-licensed-product-0.7.0.zip
|
||||
BIN
releases/wc-licensed-product-0.7.1.zip
Normal file
BIN
releases/wc-licensed-product-0.7.1.zip
Normal file
Binary file not shown.
1
releases/wc-licensed-product-0.7.1.zip.sha256
Normal file
1
releases/wc-licensed-product-0.7.1.zip.sha256
Normal file
@@ -0,0 +1 @@
|
||||
6ffd0bdf47395436bbc28a029eff4c6d065f2b5b64c687b96ae36a74c3ee34ef wc-licensed-product-0.7.1.zip
|
||||
@@ -18,6 +18,21 @@ use Twig\Environment;
|
||||
*/
|
||||
final class AdminController
|
||||
{
|
||||
/**
|
||||
* Maximum CSV file size in bytes (2MB)
|
||||
*/
|
||||
private const MAX_IMPORT_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Maximum rows to import per file
|
||||
*/
|
||||
private const MAX_IMPORT_ROWS = 1000;
|
||||
|
||||
/**
|
||||
* Minimum time between imports in seconds (5 minutes)
|
||||
*/
|
||||
private const IMPORT_RATE_LIMIT_WINDOW = 300;
|
||||
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
@@ -379,6 +394,19 @@ final class AdminController
|
||||
// Validate the license using LicenseManager
|
||||
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||
|
||||
// Enrich result with product name for display in the popup
|
||||
if (!empty($result['valid']) && isset($result['license'])) {
|
||||
// Get product name
|
||||
$productId = $result['license']['product_id'] ?? null;
|
||||
if ($productId) {
|
||||
$product = wc_get_product($productId);
|
||||
$result['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
// Flatten expires_at for easier access in JavaScript
|
||||
$result['expires_at'] = $result['license']['expires_at'] ?? null;
|
||||
}
|
||||
|
||||
wp_send_json_success($result);
|
||||
}
|
||||
|
||||
@@ -640,6 +668,23 @@ final class AdminController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check file size limit
|
||||
if ($file['size'] > self::MAX_IMPORT_FILE_SIZE) {
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=size'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check rate limit for imports
|
||||
$lastImport = get_transient('wclp_last_csv_import_' . get_current_user_id());
|
||||
if ($lastImport !== false && (time() - $lastImport) < self::IMPORT_RATE_LIMIT_WINDOW) {
|
||||
$retryAfter = self::IMPORT_RATE_LIMIT_WINDOW - (time() - $lastImport);
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=rate_limit&retry_after=' . $retryAfter));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set rate limit marker
|
||||
set_transient('wclp_last_csv_import_' . get_current_user_id(), time(), self::IMPORT_RATE_LIMIT_WINDOW);
|
||||
|
||||
// Read the CSV file
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
if (!$handle) {
|
||||
@@ -666,6 +711,7 @@ final class AdminController
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$errors = [];
|
||||
$rowCount = 0;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
// Skip empty rows
|
||||
@@ -673,6 +719,24 @@ final class AdminController
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check row limit
|
||||
$rowCount++;
|
||||
if ($rowCount > self::MAX_IMPORT_ROWS) {
|
||||
fclose($handle);
|
||||
$this->addNotice(
|
||||
sprintf(
|
||||
/* translators: %1$d: max rows, %2$d: imported count, %3$d: updated count */
|
||||
__('Import stopped: Maximum of %1$d rows allowed. %2$d imported, %3$d updated.', 'wc-licensed-product'),
|
||||
self::MAX_IMPORT_ROWS,
|
||||
$imported,
|
||||
$updated
|
||||
),
|
||||
'warning'
|
||||
);
|
||||
wp_redirect(admin_url('admin.php?page=wc-licenses&import_success=partial'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Map CSV columns (expected format from export):
|
||||
// ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At
|
||||
// For import we need: License Key (or generate), Product ID, Customer ID, Domain, Status, Max Activations, Expires At
|
||||
@@ -1605,12 +1669,11 @@ final class AdminController
|
||||
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>';
|
||||
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></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 += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><span class="license-lifetime"><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></span></td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
@@ -1688,6 +1751,21 @@ final class AdminController
|
||||
case 'read':
|
||||
esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product');
|
||||
break;
|
||||
case 'size':
|
||||
printf(
|
||||
/* translators: %s: max file size */
|
||||
esc_html__('File too large. Maximum size is %s.', 'wc-licensed-product'),
|
||||
esc_html(size_format(self::MAX_IMPORT_FILE_SIZE))
|
||||
);
|
||||
break;
|
||||
case 'rate_limit':
|
||||
$retryAfter = isset($_GET['retry_after']) ? absint($_GET['retry_after']) : self::IMPORT_RATE_LIMIT_WINDOW;
|
||||
printf(
|
||||
/* translators: %d: seconds to wait */
|
||||
esc_html__('Please wait %d seconds before importing again.', 'wc-licensed-product'),
|
||||
$retryAfter
|
||||
);
|
||||
break;
|
||||
default:
|
||||
esc_html_e('An error occurred during import.', 'wc-licensed-product');
|
||||
}
|
||||
@@ -1696,6 +1774,20 @@ final class AdminController
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="notice notice-info" style="max-width: 800px;">
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %1$s: max file size, %2$d: max rows, %3$d: rate limit minutes */
|
||||
esc_html__('Import limits: Maximum file size %1$s, maximum %2$d rows per import. You can import again after %3$d minutes.', 'wc-licensed-product'),
|
||||
esc_html(size_format(self::MAX_IMPORT_FILE_SIZE)),
|
||||
self::MAX_IMPORT_ROWS,
|
||||
(int) (self::IMPORT_RATE_LIMIT_WINDOW / 60)
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: 800px; padding: 20px;">
|
||||
<h2><?php esc_html_e('Import Licenses from CSV', 'wc-licensed-product'); ?></h2>
|
||||
|
||||
|
||||
@@ -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']);
|
||||
@@ -82,7 +83,7 @@ final class OrderLicenseController
|
||||
$hasLicensedProduct = false;
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
if ($product && $this->licenseManager->isLicensedProduct($product)) {
|
||||
$hasLicensedProduct = true;
|
||||
break;
|
||||
}
|
||||
@@ -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,29 +106,71 @@ 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>
|
||||
<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>
|
||||
<div class="wclp-inline-edit">
|
||||
<input type="text"
|
||||
id="wclp-order-domain"
|
||||
class="regular-text"
|
||||
value="<?php echo esc_attr($orderDomain); ?>"
|
||||
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">
|
||||
<?php esc_html_e('Save', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<span class="spinner"></span>
|
||||
<span class="wclp-status-message"></span>
|
||||
</div>
|
||||
<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>
|
||||
<div class="wclp-inline-edit">
|
||||
<input type="text"
|
||||
id="wclp-order-domain"
|
||||
class="regular-text"
|
||||
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">
|
||||
<?php esc_html_e('Save', 'wc-licensed-product'); ?>
|
||||
</button>
|
||||
<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 && $this->licenseManager->isLicensedProduct($product)) {
|
||||
$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 || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
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 || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ final class SettingsController
|
||||
{
|
||||
return [
|
||||
'' => __('Plugin License', 'wc-licensed-product'),
|
||||
'auto-updates' => __('Auto-Updates', 'wc-licensed-product'),
|
||||
'defaults' => __('Default Settings', 'wc-licensed-product'),
|
||||
'notifications' => __('Notifications', 'wc-licensed-product'),
|
||||
];
|
||||
@@ -112,6 +113,7 @@ final class SettingsController
|
||||
$currentSection = $this->getCurrentSection();
|
||||
|
||||
return match ($currentSection) {
|
||||
'auto-updates' => $this->getAutoUpdatesSettings(),
|
||||
'defaults' => $this->getDefaultsSettings(),
|
||||
'notifications' => $this->getNotificationsSettings(),
|
||||
default => $this->getPluginLicenseSettings(),
|
||||
@@ -160,6 +162,56 @@ final class SettingsController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto-updates settings
|
||||
*/
|
||||
private function getAutoUpdatesSettings(): array
|
||||
{
|
||||
$autoInstallDisabled = !self::isUpdateNotificationEnabled();
|
||||
|
||||
return [
|
||||
'auto_update_section_title' => [
|
||||
'name' => __('Auto-Updates', 'wc-licensed-product'),
|
||||
'type' => 'title',
|
||||
'desc' => __('Configure automatic plugin updates from the license server.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_section_auto_update',
|
||||
],
|
||||
'update_notification_enabled' => [
|
||||
'name' => __('Enable Update Notifications', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => __('Check for and display available updates from the license server in WordPress admin.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_update_notification_enabled',
|
||||
'default' => 'yes',
|
||||
],
|
||||
'plugin_auto_install_enabled' => [
|
||||
'name' => __('Automatically Install Updates', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => $autoInstallDisabled
|
||||
? __('Enable "Update Notifications" above to use this option.', 'wc-licensed-product')
|
||||
: __('Automatically install updates when they become available (requires update notifications enabled).', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_plugin_auto_install_enabled',
|
||||
'default' => 'no',
|
||||
'custom_attributes' => $autoInstallDisabled ? ['disabled' => 'disabled'] : [],
|
||||
],
|
||||
'update_check_frequency' => [
|
||||
'name' => __('Check Frequency (Hours)', 'wc-licensed-product'),
|
||||
'type' => 'number',
|
||||
'desc' => __('How often to check for updates (in hours).', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_update_check_frequency',
|
||||
'default' => '12',
|
||||
'custom_attributes' => [
|
||||
'min' => '1',
|
||||
'max' => '168',
|
||||
'step' => '1',
|
||||
],
|
||||
],
|
||||
'auto_update_section_end' => [
|
||||
'type' => 'sectionend',
|
||||
'id' => 'wc_licensed_product_section_auto_update_end',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default license settings
|
||||
*/
|
||||
@@ -173,7 +225,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',
|
||||
@@ -184,7 +236,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',
|
||||
@@ -196,12 +248,19 @@ 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',
|
||||
@@ -387,6 +446,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
|
||||
@@ -445,6 +512,44 @@ final class SettingsController
|
||||
return !empty($secret) ? (string) $secret : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if update notifications are enabled
|
||||
*/
|
||||
public static function isUpdateNotificationEnabled(): bool
|
||||
{
|
||||
return get_option('wc_licensed_product_update_notification_enabled', 'yes') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-updates are enabled (legacy alias for isUpdateNotificationEnabled)
|
||||
*/
|
||||
public static function isAutoUpdateEnabled(): bool
|
||||
{
|
||||
return self::isUpdateNotificationEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if automatic installation of updates is enabled
|
||||
*/
|
||||
public static function isAutoInstallEnabled(): bool
|
||||
{
|
||||
// Auto-install requires notifications to be enabled first
|
||||
if (!self::isUpdateNotificationEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return get_option('wc_licensed_product_plugin_auto_install_enabled', 'no') === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update check frequency in hours
|
||||
*/
|
||||
public static function getUpdateCheckFrequency(): int
|
||||
{
|
||||
$value = get_option('wc_licensed_product_update_check_frequency', 12);
|
||||
return max(1, min(168, (int) $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX verify license request
|
||||
*/
|
||||
|
||||
@@ -43,25 +43,21 @@ final class VersionAdminController
|
||||
|
||||
/**
|
||||
* Add versions meta box to product edit page
|
||||
* Always adds the meta box - visibility is controlled via CSS/JavaScript based on product type
|
||||
*/
|
||||
public function addVersionsMetaBox(): void
|
||||
{
|
||||
global $post;
|
||||
|
||||
// Only add meta box for licensed products or new products
|
||||
if ($post && $post->post_type === 'product') {
|
||||
$product = wc_get_product($post->ID);
|
||||
// Show for licensed products or new products (where type might be selected later)
|
||||
if (!$product || $product->is_type('licensed') || $post->post_status === 'auto-draft') {
|
||||
add_meta_box(
|
||||
'wc_licensed_product_versions',
|
||||
__('Product Versions', 'wc-licensed-product'),
|
||||
[$this, 'renderVersionsMetaBox'],
|
||||
'product',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
add_meta_box(
|
||||
'wc_licensed_product_versions',
|
||||
__('Product Versions', 'wc-licensed-product'),
|
||||
[$this, 'renderVersionsMetaBox'],
|
||||
'product',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,12 +276,13 @@ final class VersionAdminController
|
||||
}
|
||||
|
||||
// Verify product exists and is of type licensed
|
||||
$product = wc_get_product($productId);
|
||||
if (!$product) {
|
||||
// Use WC_Product_Factory::get_product_type() for reliable type detection
|
||||
$productType = \WC_Product_Factory::get_product_type($productId);
|
||||
if (!$productType) {
|
||||
wp_send_json_error(['message' => __('Product not found.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
if (!$product->is_type('licensed')) {
|
||||
if (!in_array($productType, ['licensed', 'licensed-variable'], true)) {
|
||||
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
|
||||
168
src/Api/IpDetectionTrait.php
Normal file
168
src/Api/IpDetectionTrait.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* IP Detection Trait
|
||||
*
|
||||
* Provides shared IP detection logic for API controllers with proxy support.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Api
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
/**
|
||||
* Trait for detecting client IP addresses with proxy support
|
||||
*
|
||||
* Security note: Only trust proxy headers when explicitly configured.
|
||||
* Set WC_LICENSE_TRUSTED_PROXIES constant in wp-config.php to enable proxy header support.
|
||||
*
|
||||
* Examples:
|
||||
* define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
|
||||
* define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
|
||||
*/
|
||||
trait IpDetectionTrait
|
||||
{
|
||||
/**
|
||||
* Get client IP address with proxy support
|
||||
*
|
||||
* @return string Client IP address
|
||||
*/
|
||||
protected 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',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
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
|
||||
*/
|
||||
protected 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)) {
|
||||
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
|
||||
*/
|
||||
protected 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
|
||||
*/
|
||||
protected 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);
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,7 @@ final class ResponseSigner
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||
? WC_LICENSE_SERVER_SECRET
|
||||
: '';
|
||||
$this->serverSecret = self::getServerSecret();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +77,8 @@ final class ResponseSigner
|
||||
|
||||
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/activate')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/update-check');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,9 +146,93 @@ 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 = self::getServerSecret();
|
||||
|
||||
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 !empty(self::getServerSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server secret from constant or environment variable
|
||||
*
|
||||
* Checks in order:
|
||||
* 1. WC_LICENSE_SERVER_SECRET constant (preferred)
|
||||
* 2. WC_LICENSE_SERVER_SECRET environment variable (Docker fallback)
|
||||
*
|
||||
* @return string The server secret, or empty string if not configured
|
||||
*/
|
||||
public static function getServerSecret(): string
|
||||
{
|
||||
// First check the constant (standard WordPress configuration)
|
||||
if (defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET)) {
|
||||
return WC_LICENSE_SERVER_SECRET;
|
||||
}
|
||||
|
||||
// Fallback to environment variable (Docker environments)
|
||||
$envSecret = getenv('WC_LICENSE_SERVER_SECRET');
|
||||
if ($envSecret !== false && !empty($envSecret)) {
|
||||
return $envSecret;
|
||||
}
|
||||
|
||||
// Also check $_ENV and $_SERVER (some PHP configurations)
|
||||
if (!empty($_ENV['WC_LICENSE_SERVER_SECRET'])) {
|
||||
return $_ENV['WC_LICENSE_SERVER_SECRET'];
|
||||
}
|
||||
|
||||
if (!empty($_SERVER['WC_LICENSE_SERVER_SECRET'])) {
|
||||
return $_SERVER['WC_LICENSE_SERVER_SECRET'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,38 @@ use WP_REST_Server;
|
||||
*/
|
||||
final class RestApiController
|
||||
{
|
||||
use IpDetectionTrait;
|
||||
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 +77,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 +92,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,158 +112,10 @@ 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',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
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
|
||||
*/
|
||||
@@ -257,7 +132,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 +157,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 +175,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 +207,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
|
||||
*/
|
||||
|
||||
353
src/Api/UpdateController.php
Normal file
353
src/Api/UpdateController.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
/**
|
||||
* Update Controller
|
||||
*
|
||||
* REST API endpoint for plugin update checks
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Api
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Jeremias\WcLicensedProduct\Product\ProductVersion;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Handles REST API endpoint for plugin update checks
|
||||
*
|
||||
* This endpoint allows licensed plugins to check for updates from this WooCommerce store.
|
||||
* It validates the license and returns WordPress-compatible update information.
|
||||
*/
|
||||
final class UpdateController
|
||||
{
|
||||
use IpDetectionTrait;
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
/**
|
||||
* Default rate limit: requests per window per IP
|
||||
*/
|
||||
private const DEFAULT_RATE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Default rate limit window in seconds
|
||||
*/
|
||||
private const DEFAULT_RATE_WINDOW = 60;
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager, VersionManager $versionManager)
|
||||
{
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->versionManager = $versionManager;
|
||||
$this->registerHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
add_action('rest_api_init', [$this, 'registerRoutes']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current IP
|
||||
*
|
||||
* @return WP_REST_Response|null Returns error response if rate limited, null if OK
|
||||
*/
|
||||
private function checkRateLimit(): ?WP_REST_Response
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$transientKey = 'wclp_update_rate_' . md5($ip);
|
||||
$rateLimit = $this->getRateLimit();
|
||||
$rateWindow = $this->getRateWindow();
|
||||
|
||||
$data = get_transient($transientKey);
|
||||
|
||||
if ($data === false) {
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
|
||||
return null;
|
||||
}
|
||||
|
||||
$count = (int) ($data['count'] ?? 0);
|
||||
$start = (int) ($data['start'] ?? time());
|
||||
|
||||
if (time() - $start >= $rateWindow) {
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($count >= $rateLimit) {
|
||||
$retryAfter = $rateWindow - (time() - $start);
|
||||
$response = new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'rate_limit_exceeded',
|
||||
'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'),
|
||||
'retry_after' => $retryAfter,
|
||||
], 429);
|
||||
$response->header('Retry-After', (string) $retryAfter);
|
||||
return $response;
|
||||
}
|
||||
|
||||
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
public function registerRoutes(): void
|
||||
{
|
||||
register_rest_route(self::NAMESPACE, '/update-check', [
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'handleUpdateCheck'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'license_key' => [
|
||||
'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;
|
||||
},
|
||||
],
|
||||
'plugin_slug' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
'current_version' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update check request
|
||||
*/
|
||||
public function handleUpdateCheck(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$rateLimitResponse = $this->checkRateLimit();
|
||||
if ($rateLimitResponse !== null) {
|
||||
return $rateLimitResponse;
|
||||
}
|
||||
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
$currentVersion = $request->get_param('current_version');
|
||||
|
||||
// Validate license
|
||||
$validationResult = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||
|
||||
if (!$validationResult['valid']) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'update_available' => false,
|
||||
'error' => $validationResult['error'] ?? 'license_invalid',
|
||||
'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'),
|
||||
], $validationResult['error'] === 'license_not_found' ? 404 : 403);
|
||||
}
|
||||
|
||||
// Get license to access product ID
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
if (!$license) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'update_available' => false,
|
||||
'error' => 'license_not_found',
|
||||
'message' => __('License not found.', 'wc-licensed-product'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
$productId = $license->getProductId();
|
||||
$product = wc_get_product($productId);
|
||||
|
||||
if (!$product) {
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'update_available' => false,
|
||||
'error' => 'product_not_found',
|
||||
'message' => __('Licensed product not found.', 'wc-licensed-product'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Get latest version based on major version binding
|
||||
$latestVersion = $this->getLatestVersionForLicense($license);
|
||||
|
||||
if (!$latestVersion) {
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'update_available' => false,
|
||||
'version' => $currentVersion ?? '0.0.0',
|
||||
'message' => __('No versions available for this product.', 'wc-licensed-product'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if update is available
|
||||
$updateAvailable = $currentVersion
|
||||
? version_compare($latestVersion->getVersion(), $currentVersion, '>')
|
||||
: true;
|
||||
|
||||
// Build response
|
||||
$response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable);
|
||||
|
||||
return new WP_REST_Response($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest version for a license, respecting major version binding
|
||||
*/
|
||||
private function getLatestVersionForLicense($license): ?ProductVersion
|
||||
{
|
||||
$productId = $license->getProductId();
|
||||
|
||||
// Check if license is bound to a specific version
|
||||
$versionId = $license->getVersionId();
|
||||
if ($versionId) {
|
||||
$boundVersion = $this->versionManager->getVersionById($versionId);
|
||||
if ($boundVersion) {
|
||||
// Get latest version for this major version
|
||||
return $this->versionManager->getLatestVersionForMajor(
|
||||
$productId,
|
||||
$boundVersion->getMajorVersion()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No version binding, return latest overall
|
||||
return $this->versionManager->getLatestVersion($productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WordPress-compatible update response
|
||||
*/
|
||||
private function buildUpdateResponse($product, ProductVersion $version, $license, bool $updateAvailable): array
|
||||
{
|
||||
$productSlug = $product->get_slug();
|
||||
|
||||
// Generate secure download URL
|
||||
$downloadUrl = $this->generateUpdateDownloadUrl($license->getId(), $version->getId());
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'update_available' => $updateAvailable,
|
||||
'version' => $version->getVersion(),
|
||||
'slug' => $productSlug,
|
||||
'plugin' => $productSlug . '/' . $productSlug . '.php',
|
||||
'download_url' => $downloadUrl,
|
||||
'package' => $downloadUrl,
|
||||
'last_updated' => $version->getReleasedAt()->format('Y-m-d'),
|
||||
'tested' => $this->getTestedWpVersion(),
|
||||
'requires' => $this->getRequiredWpVersion(),
|
||||
'requires_php' => $this->getRequiredPhpVersion(),
|
||||
];
|
||||
|
||||
// Add changelog if available
|
||||
if ($version->getReleaseNotes()) {
|
||||
$response['changelog'] = $version->getReleaseNotes();
|
||||
$response['sections'] = [
|
||||
'description' => $product->get_short_description() ?: $product->get_description(),
|
||||
'changelog' => $version->getReleaseNotes(),
|
||||
];
|
||||
}
|
||||
|
||||
// Add package hash for integrity verification
|
||||
if ($version->getFileHash()) {
|
||||
$response['package_hash'] = 'sha256:' . $version->getFileHash();
|
||||
}
|
||||
|
||||
// Add product name and homepage
|
||||
$response['name'] = $product->get_name();
|
||||
$response['homepage'] = get_permalink($product->get_id());
|
||||
|
||||
// Add icons if product has featured image
|
||||
$imageId = $product->get_image_id();
|
||||
if ($imageId) {
|
||||
$iconUrl = wp_get_attachment_image_url($imageId, 'thumbnail');
|
||||
$iconUrl2x = wp_get_attachment_image_url($imageId, 'medium');
|
||||
if ($iconUrl) {
|
||||
$response['icons'] = [
|
||||
'1x' => $iconUrl,
|
||||
'2x' => $iconUrl2x ?: $iconUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure download URL for updates
|
||||
*/
|
||||
private function generateUpdateDownloadUrl(int $licenseId, int $versionId): string
|
||||
{
|
||||
$data = $licenseId . '-' . $versionId . '-' . wp_salt('auth');
|
||||
$hash = substr(hash('sha256', $data), 0, 16);
|
||||
$downloadKey = $licenseId . '-' . $versionId . '-' . $hash;
|
||||
|
||||
return home_url('license-download/' . $downloadKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tested WordPress version from plugin headers
|
||||
*/
|
||||
private function getTestedWpVersion(): string
|
||||
{
|
||||
return get_option('wc_licensed_product_tested_wp', '6.7');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required WordPress version from plugin headers
|
||||
*/
|
||||
private function getRequiredWpVersion(): string
|
||||
{
|
||||
return get_option('wc_licensed_product_requires_wp', '6.0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required PHP version
|
||||
*/
|
||||
private function getRequiredPhpVersion(): string
|
||||
{
|
||||
return get_option('wc_licensed_product_requires_php', '8.3');
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
*/
|
||||
public function get_script_data(): array
|
||||
{
|
||||
$isMultiDomain = SettingsController::isMultiDomainEnabled();
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
$hasLicensedProducts = !empty($licensedProducts);
|
||||
|
||||
return [
|
||||
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
|
||||
'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'),
|
||||
'hasLicensedProducts' => $hasLicensedProducts,
|
||||
'licensedProducts' => $licensedProducts,
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -110,18 +137,74 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
* Check if cart contains licensed products
|
||||
*/
|
||||
private function cartHasLicensedProducts(): bool
|
||||
{
|
||||
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 false;
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$licensedProducts = [];
|
||||
$cartContents = WC()->cart->get_cart();
|
||||
|
||||
foreach ($cartContents as $cartKey => $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
|
||||
// Use WC_Product_Factory::get_product_type() for reliable parent type check
|
||||
$parentId = $product->get_parent_id();
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
|
||||
if ($parentType === '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,116 @@ 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 $cartItemKey => $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
|
||||
// Use WC_Product_Factory::get_product_type() for reliable parent type check
|
||||
$parentId = $product->get_parent_id();
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
|
||||
if ($parentType === '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 +170,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 +181,319 @@ 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'])
|
||||
: '';
|
||||
|
||||
if (empty($domain)) {
|
||||
wc_add_notice(
|
||||
__('Please enter a domain for your license activation.', 'wc-licensed-product'),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (!$this->isValidDomain($normalizedDomain)) {
|
||||
wc_add_notice(
|
||||
__('Please enter a valid domain name.', 'wc-licensed-product'),
|
||||
'error'
|
||||
);
|
||||
// Check if multi-domain licensing is enabled
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->validateMultiDomainFields($licensedProducts);
|
||||
} else {
|
||||
$this->validateSingleDomainField();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save domain field to order meta
|
||||
* Validate single domain field
|
||||
*/
|
||||
public function saveDomainField(int $orderId): void
|
||||
private function validateSingleDomainField(): void
|
||||
{
|
||||
if (!$this->cartHasLicensedProducts()) {
|
||||
$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;
|
||||
}
|
||||
|
||||
if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) {
|
||||
$domain = sanitize_text_field($_POST['licensed_product_domain']);
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (!$this->isValidDomain($normalizedDomain)) {
|
||||
wc_add_notice(__('Please enter a valid domain for your license.', 'wc-licensed-product'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$order = wc_get_order($orderId);
|
||||
if ($order) {
|
||||
$order->update_meta_data('_licensed_product_domain', $normalizedDomain);
|
||||
$order->save();
|
||||
/**
|
||||
* 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(
|
||||
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'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
|
||||
if (!$this->isValidDomain($normalizedDomain)) {
|
||||
wc_add_notice(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display domain in admin order view
|
||||
* Save domain fields to order meta
|
||||
*/
|
||||
public function saveDomainField(int $orderId): void
|
||||
{
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
|
||||
if (empty($licensedProducts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = wc_get_order($orderId);
|
||||
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 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 +508,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 +573,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,30 +130,116 @@ 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);
|
||||
if (WC()->session) {
|
||||
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', '') : '';
|
||||
$requestData = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Handle JSON decode errors gracefully
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$requestData = null;
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -131,4 +252,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 (json_last_error() === JSON_ERROR_NONE && 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', []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
91
src/Common/RateLimitTrait.php
Normal file
91
src/Common/RateLimitTrait.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limit Trait
|
||||
*
|
||||
* Provides rate limiting functionality for frontend operations.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Common
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Common;
|
||||
|
||||
/**
|
||||
* Trait for implementing rate limiting on user actions
|
||||
*
|
||||
* Uses WordPress transients for storage. Rate limits are per-user when logged in,
|
||||
* or per-IP when not logged in.
|
||||
*/
|
||||
trait RateLimitTrait
|
||||
{
|
||||
/**
|
||||
* Check rate limit for a user action
|
||||
*
|
||||
* @param string $action Action identifier (e.g., 'transfer', 'download')
|
||||
* @param int $limit Maximum attempts per window
|
||||
* @param int $window Time window in seconds
|
||||
* @return bool True if within limit, false if exceeded
|
||||
*/
|
||||
protected function checkUserRateLimit(string $action, int $limit, int $window): bool
|
||||
{
|
||||
$userId = get_current_user_id();
|
||||
$key = $userId > 0
|
||||
? (string) $userId
|
||||
: 'ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
|
||||
$transientKey = 'wclp_rate_' . $action . '_' . $key;
|
||||
$data = get_transient($transientKey);
|
||||
|
||||
if ($data === false) {
|
||||
// First request, start counting
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $window);
|
||||
return true;
|
||||
}
|
||||
|
||||
$count = (int) ($data['count'] ?? 0);
|
||||
$start = (int) ($data['start'] ?? time());
|
||||
|
||||
// Check if window has expired
|
||||
if (time() - $start >= $window) {
|
||||
// Reset counter
|
||||
set_transient($transientKey, ['count' => 1, 'start' => time()], $window);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if ($count >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $window);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time until rate limit resets
|
||||
*
|
||||
* @param string $action Action identifier
|
||||
* @param int $window Time window in seconds (must match the one used in checkUserRateLimit)
|
||||
* @return int Seconds until rate limit resets, or 0 if not rate limited
|
||||
*/
|
||||
protected function getRateLimitRetryAfter(string $action, int $window): int
|
||||
{
|
||||
$userId = get_current_user_id();
|
||||
$key = $userId > 0
|
||||
? (string) $userId
|
||||
: 'ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
|
||||
$transientKey = 'wclp_rate_' . $action . '_' . $key;
|
||||
$data = get_transient($transientKey);
|
||||
|
||||
if ($data === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$start = (int) ($data['start'] ?? time());
|
||||
|
||||
return max(0, $window - (time() - $start));
|
||||
}
|
||||
}
|
||||
@@ -194,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
|
||||
{
|
||||
@@ -203,94 +203,117 @@ 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;">
|
||||
<?php echo esc_html($license->getLicenseKey()); ?>
|
||||
</code>
|
||||
<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;">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($licenses as $item) : ?>
|
||||
<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()); ?>
|
||||
</code>
|
||||
</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">
|
||||
<?php
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<table style="width: 100%; border-collapse: collapse; background: #fff;">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 ($product['licenses'] as $license) : ?>
|
||||
<tr>
|
||||
<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: 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 = $license->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?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'); ?>
|
||||
@@ -302,29 +325,33 @@ 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";
|
||||
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 = $item['license']->getExpiresAt();
|
||||
echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
echo "\n\n";
|
||||
$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";
|
||||
|
||||
@@ -9,6 +9,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Twig\Environment;
|
||||
@@ -18,6 +20,8 @@ use Twig\Environment;
|
||||
*/
|
||||
final class AccountController
|
||||
{
|
||||
use RateLimitTrait;
|
||||
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
@@ -105,137 +109,410 @@ final class AccountController
|
||||
return;
|
||||
}
|
||||
|
||||
// Get filter parameters from URL
|
||||
$filterProductId = isset($_GET['filter_product']) ? absint($_GET['filter_product']) : 0;
|
||||
$filterDomain = isset($_GET['filter_domain']) ? sanitize_text_field(wp_unslash($_GET['filter_domain'])) : '';
|
||||
|
||||
$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());
|
||||
// Apply filters
|
||||
$filteredLicenses = $this->applyLicenseFilters($licenses, $filterProductId, $filterDomain);
|
||||
|
||||
// Get available downloads for this license
|
||||
$downloads = [];
|
||||
if ($license->getStatus() === 'active') {
|
||||
$versions = $this->versionManager->getVersionsByProduct($license->getProductId());
|
||||
foreach ($versions as $version) {
|
||||
if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
|
||||
$downloads[] = [
|
||||
'version' => $version->getVersion(),
|
||||
'version_id' => $version->getId(),
|
||||
'filename' => $version->getDownloadFilename(),
|
||||
'download_url' => $this->downloadController->generateDownloadUrl(
|
||||
$license->getId(),
|
||||
$version->getId()
|
||||
),
|
||||
'release_notes' => $version->getReleaseNotes(),
|
||||
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
|
||||
'file_hash' => $version->getFileHash(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Group licenses by product+order into "packages"
|
||||
$packages = $this->groupLicensesIntoPackages($filteredLicenses);
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
// Get unique products and domains for filter dropdowns
|
||||
$filterOptions = $this->getFilterOptions($licenses);
|
||||
|
||||
try {
|
||||
echo $this->twig->render('frontend/licenses.html.twig', [
|
||||
'licenses' => $enrichedLicenses,
|
||||
'has_licenses' => !empty($enrichedLicenses),
|
||||
'packages' => $packages,
|
||||
'has_packages' => !empty($packages),
|
||||
'signing_enabled' => ResponseSigner::isSigningEnabled(),
|
||||
'filter_products' => $filterOptions['products'],
|
||||
'filter_domains' => $filterOptions['domains'],
|
||||
'current_filter_product' => $filterProductId,
|
||||
'current_filter_domain' => $filterDomain,
|
||||
'is_filtered' => $filterProductId > 0 || !empty($filterDomain),
|
||||
'licenses_url' => wc_get_account_endpoint_url('licenses'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to PHP template if Twig fails
|
||||
$this->displayLicensesFallback($enrichedLicenses);
|
||||
$this->displayLicensesFallback($packages, $filterOptions, $filterProductId, $filterDomain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to licenses
|
||||
*
|
||||
* @param array $licenses Array of License objects
|
||||
* @param int $productId Filter by product ID (0 for all)
|
||||
* @param string $domain Filter by domain (empty for all)
|
||||
* @return array Filtered array of License objects
|
||||
*/
|
||||
private function applyLicenseFilters(array $licenses, int $productId, string $domain): array
|
||||
{
|
||||
if ($productId === 0 && empty($domain)) {
|
||||
return $licenses;
|
||||
}
|
||||
|
||||
return array_filter($licenses, function ($license) use ($productId, $domain) {
|
||||
// Filter by product
|
||||
if ($productId > 0 && $license->getProductId() !== $productId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by domain (partial match)
|
||||
if (!empty($domain) && stripos($license->getDomain(), $domain) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique filter options from licenses
|
||||
*
|
||||
* @param array $licenses Array of License objects
|
||||
* @return array Array with 'products' and 'domains' keys
|
||||
*/
|
||||
private function getFilterOptions(array $licenses): array
|
||||
{
|
||||
$products = [];
|
||||
$domains = [];
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
// Collect unique products
|
||||
$productId = $license->getProductId();
|
||||
if (!isset($products[$productId])) {
|
||||
$product = wc_get_product($productId);
|
||||
$products[$productId] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
|
||||
}
|
||||
|
||||
// Collect unique domains
|
||||
$domain = $license->getDomain();
|
||||
if (!in_array($domain, $domains, true)) {
|
||||
$domains[] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort products by name, domains alphabetically
|
||||
asort($products);
|
||||
sort($domains);
|
||||
|
||||
return [
|
||||
'products' => $products,
|
||||
'domains' => $domains,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
$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[] = [
|
||||
'version' => $version->getVersion(),
|
||||
'version_id' => $version->getId(),
|
||||
'filename' => $version->getDownloadFilename(),
|
||||
'download_url' => $this->downloadController->generateDownloadUrl(
|
||||
$licenseId,
|
||||
$version->getId()
|
||||
),
|
||||
'release_notes' => $version->getReleaseNotes(),
|
||||
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
|
||||
'file_hash' => $version->getFileHash(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $downloads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback display method if Twig is unavailable
|
||||
*/
|
||||
private function displayLicensesFallback(array $enrichedLicenses): void
|
||||
{
|
||||
if (empty($enrichedLicenses)) {
|
||||
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
|
||||
private function displayLicensesFallback(
|
||||
array $packages,
|
||||
array $filterOptions = [],
|
||||
int $currentFilterProduct = 0,
|
||||
string $currentFilterDomain = ''
|
||||
): void {
|
||||
$isFiltered = $currentFilterProduct > 0 || !empty($currentFilterDomain);
|
||||
$licensesUrl = wc_get_account_endpoint_url('licenses');
|
||||
|
||||
// Display filter form if we have filter options
|
||||
if (!empty($filterOptions['products']) || !empty($filterOptions['domains'])) {
|
||||
?>
|
||||
<div class="wclp-filter-form">
|
||||
<form method="get" action="<?php echo esc_url($licensesUrl); ?>">
|
||||
<div class="wclp-filter-row">
|
||||
<?php if (!empty($filterOptions['products'])): ?>
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_product"><?php esc_html_e('Product', 'wc-licensed-product'); ?></label>
|
||||
<select name="filter_product" id="filter_product">
|
||||
<option value=""><?php esc_html_e('All Products', 'wc-licensed-product'); ?></option>
|
||||
<?php foreach ($filterOptions['products'] as $id => $name): ?>
|
||||
<option value="<?php echo esc_attr($id); ?>" <?php selected($currentFilterProduct, $id); ?>>
|
||||
<?php echo esc_html($name); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($filterOptions['domains'])): ?>
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_domain"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></label>
|
||||
<select name="filter_domain" id="filter_domain">
|
||||
<option value=""><?php esc_html_e('All Domains', 'wc-licensed-product'); ?></option>
|
||||
<?php foreach ($filterOptions['domains'] as $domain): ?>
|
||||
<option value="<?php echo esc_attr($domain); ?>" <?php selected($currentFilterDomain, $domain); ?>>
|
||||
<?php echo esc_html($domain); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="wclp-filter-actions">
|
||||
<button type="submit" class="button"><?php esc_html_e('Filter', 'wc-licensed-product'); ?></button>
|
||||
<?php if ($isFiltered): ?>
|
||||
<a href="<?php echo esc_url($licensesUrl); ?>" class="button"><?php esc_html_e('Clear', 'wc-licensed-product'); ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
if (empty($packages)) {
|
||||
if ($isFiltered) {
|
||||
echo '<p>' . esc_html__('No licenses found matching your filters.', 'wc-licensed-product') . '</p>';
|
||||
} else {
|
||||
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'); ?>">
|
||||
<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)): ?>
|
||||
<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()); ?>"
|
||||
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>
|
||||
<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>
|
||||
<?php if ($license['is_transferable']): ?>
|
||||
<button type="button" class="wclp-transfer-btn"
|
||||
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>
|
||||
</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 class="license-meta-item license-expiry">
|
||||
<span class="dashicons dashicons-calendar-alt"></span>
|
||||
<?php
|
||||
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>
|
||||
<?php if (ResponseSigner::isSigningEnabled() && !empty($license['customer_secret'])): ?>
|
||||
<div class="license-row-secret">
|
||||
<button type="button" class="secret-toggle" aria-expanded="false">
|
||||
<span class="dashicons dashicons-lock"></span>
|
||||
<?php esc_html_e('API Verification Secret', 'wc-licensed-product'); ?>
|
||||
<span class="dashicons dashicons-arrow-down-alt2 toggle-arrow"></span>
|
||||
</button>
|
||||
<div class="secret-content" style="display: none;">
|
||||
<p class="secret-description">
|
||||
<?php esc_html_e('Use this secret to verify signed API responses. Keep it secure.', 'wc-licensed-product'); ?>
|
||||
</p>
|
||||
<div class="secret-value-wrapper">
|
||||
<code class="secret-value"><?php echo esc_html($license['customer_secret']); ?></code>
|
||||
<button type="button" class="copy-secret-btn" data-secret="<?php echo esc_attr($license['customer_secret']); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>">
|
||||
<span class="dashicons dashicons-clipboard"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong>
|
||||
<?php
|
||||
$expiresAt = $item['license']->getExpiresAt();
|
||||
echo $expiresAt
|
||||
? esc_html($expiresAt->format(get_option('date_format')))
|
||||
: esc_html__('Never', 'wc-licensed-product');
|
||||
?>
|
||||
</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>
|
||||
<a href="<?php echo esc_url($download['download_url']); ?>" class="download-link">
|
||||
<?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($download['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $download['version'])); ?>
|
||||
<?php echo esc_html($latest['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $latest['version'])); ?>
|
||||
</a>
|
||||
<span class="download-version">v<?php echo esc_html($download['version']); ?></span>
|
||||
<span class="download-date"><?php echo esc_html($download['released_at']); ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<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>
|
||||
</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>
|
||||
@@ -321,6 +598,15 @@ final class AccountController
|
||||
*/
|
||||
public function handleTransferRequest(): void
|
||||
{
|
||||
// Rate limit: 5 transfer attempts per hour per user
|
||||
if (!$this->checkUserRateLimit('transfer', 5, 3600)) {
|
||||
$retryAfter = $this->getRateLimitRetryAfter('transfer', 3600);
|
||||
wp_send_json_error([
|
||||
'message' => __('Too many transfer attempts. Please try again later.', 'wc-licensed-product'),
|
||||
'retry_after' => $retryAfter,
|
||||
], 429);
|
||||
}
|
||||
|
||||
// Verify nonce
|
||||
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
|
||||
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
|
||||
|
||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
|
||||
@@ -17,6 +18,8 @@ use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
*/
|
||||
final class DownloadController
|
||||
{
|
||||
use RateLimitTrait;
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
|
||||
@@ -110,6 +113,15 @@ final class DownloadController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate limit: 30 downloads per hour per user
|
||||
if (!$this->checkUserRateLimit('download', 30, 3600)) {
|
||||
wp_die(
|
||||
__('Too many download attempts. Please try again later.', 'wc-licensed-product'),
|
||||
__('Download Error', 'wc-licensed-product'),
|
||||
['response' => 429]
|
||||
);
|
||||
}
|
||||
|
||||
// Get license
|
||||
$license = $this->licenseManager->getLicenseById($licenseId);
|
||||
if (!$license) {
|
||||
|
||||
@@ -31,6 +31,7 @@ final class Installer
|
||||
{
|
||||
self::createTables();
|
||||
self::createCacheDir();
|
||||
self::registerProductTypes();
|
||||
|
||||
// Set version in options
|
||||
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
|
||||
@@ -43,6 +44,28 @@ final class Installer
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom product type terms in the product_type taxonomy
|
||||
* This is required for WC_Product_Factory::get_product_type() to work correctly
|
||||
*/
|
||||
public static function registerProductTypes(): void
|
||||
{
|
||||
// Ensure WooCommerce taxonomies are registered
|
||||
if (!taxonomy_exists('product_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register 'licensed' product type term if it doesn't exist
|
||||
if (!term_exists('licensed', 'product_type')) {
|
||||
wp_insert_term('licensed', 'product_type');
|
||||
}
|
||||
|
||||
// Register 'licensed-variable' product type term if it doesn't exist
|
||||
if (!term_exists('licensed-variable', 'product_type')) {
|
||||
wp_insert_term('licensed-variable', 'product_type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run on plugin deactivation
|
||||
*/
|
||||
|
||||
@@ -11,12 +11,51 @@ 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;
|
||||
}
|
||||
|
||||
// Check for our custom variation class
|
||||
if ($product instanceof LicensedProductVariation) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variation of a licensed-variable product
|
||||
// Use WC_Product_Factory::get_product_type() for reliable parent type check
|
||||
// This queries the database directly and doesn't depend on product class loading
|
||||
$parentId = $product->get_parent_id();
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
if ($parentType === 'licensed-variable') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique license key
|
||||
*/
|
||||
@@ -40,29 +79,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')) {
|
||||
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);
|
||||
|
||||
// Verify parent is licensed-variable using DB-level type check
|
||||
$parentType = \WC_Product_Factory::get_product_type($productId);
|
||||
if ($parentType !== '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
|
||||
@@ -71,16 +144,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 +169,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 +182,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 +244,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
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,11 @@ final class PluginLicenseChecker
|
||||
*/
|
||||
private ?bool $isLocalhostCached = null;
|
||||
|
||||
/**
|
||||
* Cached self-licensing check result
|
||||
*/
|
||||
private ?bool $isSelfLicensingCached = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
@@ -84,6 +89,11 @@ final class PluginLicenseChecker
|
||||
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) {
|
||||
@@ -107,6 +117,11 @@ final class PluginLicenseChecker
|
||||
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();
|
||||
@@ -176,6 +191,7 @@ final class PluginLicenseChecker
|
||||
delete_transient(self::CACHE_KEY);
|
||||
delete_transient(self::ERROR_CACHE_KEY);
|
||||
$this->isLocalhostCached = null;
|
||||
$this->isSelfLicensingCached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +231,60 @@ final class PluginLicenseChecker
|
||||
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
|
||||
*/
|
||||
|
||||
109
src/Plugin.php
109
src/Plugin.php
@@ -17,6 +17,7 @@ use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||
use Jeremias\WcLicensedProduct\Api\UpdateController;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||
use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension;
|
||||
@@ -27,6 +28,7 @@ use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
@@ -139,12 +141,13 @@ final class Plugin
|
||||
new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController);
|
||||
}
|
||||
|
||||
// Always initialize REST API and email controller
|
||||
// Always initialize REST API, update API, and email controller
|
||||
new RestApiController($this->licenseManager);
|
||||
new UpdateController($this->licenseManager, $this->versionManager);
|
||||
new LicenseEmailController($this->licenseManager);
|
||||
|
||||
// Initialize response signing if server secret is configured
|
||||
if (defined('WC_LICENSE_SERVER_SECRET') && WC_LICENSE_SERVER_SECRET !== '') {
|
||||
if (ResponseSigner::isSigningEnabled()) {
|
||||
(new ResponseSigner())->register();
|
||||
}
|
||||
|
||||
@@ -162,6 +165,12 @@ final class Plugin
|
||||
add_action('admin_notices', [$this, 'showUnlicensedNotice']);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize update checker if license server is configured (client-side updates)
|
||||
$serverUrl = SettingsController::getPluginLicenseServerUrl();
|
||||
if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) {
|
||||
PluginUpdateChecker::getInstance()->register();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,22 +217,106 @@ 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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$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
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,14 @@ class LicensedProduct extends WC_Product
|
||||
return $this->exists() && $this->get_price() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always in stock (virtual, no inventory)
|
||||
*/
|
||||
public function is_in_stock(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max activations for this product
|
||||
* Falls back to default settings if not set on product
|
||||
|
||||
@@ -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,9 +30,12 @@ final class LicensedProductType
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// Register product type
|
||||
// Ensure product type terms exist in taxonomy (for WC_Product_Factory::get_product_type())
|
||||
add_action('woocommerce_init', [$this, 'ensureProductTypeTermsExist']);
|
||||
|
||||
// Register product types
|
||||
add_filter('product_type_selector', [$this, 'addProductType']);
|
||||
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
|
||||
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 4);
|
||||
|
||||
// Add product data tabs
|
||||
add_filter('woocommerce_product_data_tabs', [$this, 'addProductDataTab']);
|
||||
@@ -39,51 +43,121 @@ 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']);
|
||||
|
||||
// Use variable product add-to-cart handler for licensed-variable products
|
||||
add_filter('woocommerce_add_to_cart_handler', [$this, 'addToCartHandler'], 10, 2);
|
||||
|
||||
// Make product virtual by default
|
||||
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
|
||||
|
||||
// Hide stock HTML for licensed products
|
||||
add_filter('woocommerce_get_stock_html', [$this, 'hideStockHtml'], 10, 2);
|
||||
add_filter('woocommerce_get_availability', [$this, 'hideAvailability'], 10, 2);
|
||||
add_filter('woocommerce_get_availability_text', [$this, 'hideAvailabilityText'], 10, 2);
|
||||
add_filter('woocommerce_product_get_stock_quantity', [$this, 'hideStockQuantity'], 10, 2);
|
||||
add_filter('woocommerce_product_variation_get_stock_quantity', [$this, 'hideStockQuantity'], 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
|
||||
* Ensure product type terms exist in the product_type taxonomy
|
||||
* This is required for WC_Product_Factory::get_product_type() to work correctly
|
||||
*/
|
||||
public function ensureProductTypeTermsExist(): void
|
||||
{
|
||||
\Jeremias\WcLicensedProduct\Installer::registerProductTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @param string $className Default class name
|
||||
* @param string $productType Product type
|
||||
* @param string $postType Post type (usually 'product' or 'product_variation')
|
||||
* @param mixed $productId Product ID (can be int or string)
|
||||
*/
|
||||
public function getProductClass(string $className, string $productType): string
|
||||
public function getProductClass(string $className, string $productType, string $postType = '', $productId = 0): string
|
||||
{
|
||||
if ($productType === 'licensed') {
|
||||
return LicensedProduct::class;
|
||||
}
|
||||
if ($productType === 'licensed-variable') {
|
||||
return LicensedVariableProduct::class;
|
||||
}
|
||||
// Handle variations of licensed-variable products
|
||||
// Check both by product type and by post type for variations
|
||||
if ($productType === 'variation' || $postType === 'product_variation') {
|
||||
// Get parent ID from the product post
|
||||
$parentId = 0;
|
||||
$productIdInt = (int) $productId;
|
||||
if ($productIdInt > 0) {
|
||||
$parentId = wp_get_post_parent_id($productIdInt);
|
||||
}
|
||||
// Fallback to global $post if product ID not available
|
||||
if (!$parentId) {
|
||||
global $post;
|
||||
if ($post && $post->post_parent) {
|
||||
$parentId = (int) $post->post_parent;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
if ($parentType === 'licensed-variable') {
|
||||
return LicensedProductVariation::class;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $className;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product data tab for license settings
|
||||
* Also modify variations tab to show for licensed-variable products
|
||||
*/
|
||||
public function addProductDataTab(array $tabs): array
|
||||
{
|
||||
// Add our License Settings tab
|
||||
$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,
|
||||
];
|
||||
|
||||
// Make Variations tab also show for licensed-variable products
|
||||
if (isset($tabs['variations'])) {
|
||||
$tabs['variations']['class'][] = 'show_if_licensed-variable';
|
||||
}
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
@@ -173,27 +247,6 @@ final class LicensedProductType
|
||||
?>
|
||||
</div>
|
||||
</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_if_licensed').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();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
@@ -231,19 +284,111 @@ final class LicensedProductType
|
||||
wc_get_template('single-product/add-to-cart/simple.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the variable product add-to-cart handler for licensed-variable products
|
||||
* WooCommerce uses product type to determine which handler to use
|
||||
*/
|
||||
public function addToCartHandler(string $handler, \WC_Product $product): string
|
||||
{
|
||||
if ($product->is_type('licensed-variable')) {
|
||||
return 'variable';
|
||||
}
|
||||
return $handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide stock HTML for licensed products (they're always virtual/in-stock)
|
||||
*/
|
||||
public function hideStockHtml(string $html, \WC_Product $product): string
|
||||
{
|
||||
if ($this->isLicensedProductOrVariation($product)) {
|
||||
return '';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide availability data for licensed products (they're always virtual/in-stock)
|
||||
*/
|
||||
public function hideAvailability(array $availability, \WC_Product $product): array
|
||||
{
|
||||
if ($this->isLicensedProductOrVariation($product)) {
|
||||
return [
|
||||
'availability' => '',
|
||||
'class' => '',
|
||||
];
|
||||
}
|
||||
return $availability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide availability text for licensed products
|
||||
*/
|
||||
public function hideAvailabilityText(string $availability, \WC_Product $product): string
|
||||
{
|
||||
if ($this->isLicensedProductOrVariation($product)) {
|
||||
return '';
|
||||
}
|
||||
return $availability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide stock quantity for licensed products (return null = no stock display)
|
||||
*
|
||||
* @param int|null $quantity
|
||||
* @param \WC_Product $product
|
||||
* @return int|null
|
||||
*/
|
||||
public function hideStockQuantity($quantity, \WC_Product $product)
|
||||
{
|
||||
if ($this->isLicensedProductOrVariation($product)) {
|
||||
return null;
|
||||
}
|
||||
return $quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product is a licensed product or variation of one
|
||||
*/
|
||||
private function isLicensedProductOrVariation(\WC_Product $product): bool
|
||||
{
|
||||
// Direct licensed products
|
||||
if ($product->is_type('licensed') || $product->is_type('licensed-variable')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check by class name for our custom variation class
|
||||
if ($product instanceof LicensedProductVariation) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if this is a variation with a licensed-variable parent
|
||||
// Use WC_Product_Factory::get_product_type() to get parent type directly from DB
|
||||
// This is more reliable than loading the full product object
|
||||
$parentId = $product->get_parent_id();
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
if ($parentType === 'licensed-variable') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make licensed products virtual by default
|
||||
*/
|
||||
public function isVirtual(bool $isVirtual, \WC_Product $product): bool
|
||||
{
|
||||
if ($product->is_type('licensed')) {
|
||||
if ($this->isLicensedProductOrVariation($product)) {
|
||||
return true;
|
||||
}
|
||||
return $isVirtual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend styles for licensed products on single product pages
|
||||
* Enqueue frontend styles and scripts for licensed products on single product pages
|
||||
*/
|
||||
public function enqueueFrontendStyles(): void
|
||||
{
|
||||
@@ -253,7 +398,7 @@ final class LicensedProductType
|
||||
|
||||
global $product;
|
||||
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,6 +408,11 @@ final class LicensedProductType
|
||||
[],
|
||||
WC_LICENSED_PRODUCT_VERSION
|
||||
);
|
||||
|
||||
// For licensed-variable products, enqueue WooCommerce variation scripts
|
||||
if ($product->is_type('licensed-variable')) {
|
||||
wp_enqueue_script('wc-add-to-cart-variation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,11 +422,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 +439,277 @@ final class LicensedProductType
|
||||
esc_html($version)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to cart template for variable licensed products
|
||||
* This mirrors WooCommerce's woocommerce_variable_add_to_cart() function
|
||||
*/
|
||||
public function variableAddToCartTemplate(): void
|
||||
{
|
||||
global $product;
|
||||
|
||||
// The hook woocommerce_licensed-variable_add_to_cart only fires for this product type
|
||||
// so we just need to verify the product exists
|
||||
if (!$product) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we're working with a product that has variable product methods
|
||||
// Re-load the product to ensure we get the correct class instance
|
||||
$productId = $product->get_id();
|
||||
$variableProduct = wc_get_product($productId);
|
||||
|
||||
if (!$variableProduct || !method_exists($variableProduct, 'get_variation_attributes')) {
|
||||
// Fallback to simple add to cart if not a variable product
|
||||
wc_get_template('single-product/add-to-cart/simple.php');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global $product to use the correctly loaded instance
|
||||
// This ensures the template has the right product type
|
||||
$product = $variableProduct;
|
||||
|
||||
// Get variations count to determine if we should load them via AJAX
|
||||
$children = $variableProduct->get_children();
|
||||
$getVariations = count($children) <= apply_filters('woocommerce_ajax_variation_threshold', 30, $variableProduct);
|
||||
|
||||
// Get template variables - WooCommerce expects these to be set
|
||||
$availableVariations = $getVariations ? $variableProduct->get_available_variations() : false;
|
||||
$attributes = $variableProduct->get_variation_attributes();
|
||||
$selectedAttributes = $variableProduct->get_default_attributes();
|
||||
|
||||
// Ensure arrays (WooCommerce template expects arrays, not null)
|
||||
if (!is_array($attributes)) {
|
||||
$attributes = [];
|
||||
}
|
||||
if (!is_array($selectedAttributes)) {
|
||||
$selectedAttributes = [];
|
||||
}
|
||||
|
||||
wc_get_template(
|
||||
'single-product/add-to-cart/variable.php',
|
||||
[
|
||||
'available_variations' => $availableVariations,
|
||||
'attributes' => $attributes,
|
||||
'selected_attributes' => $selectedAttributes,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 product types in admin
|
||||
* Handles visibility of License Settings tab and Product Versions meta box
|
||||
*/
|
||||
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($) {
|
||||
// Handle our custom License Settings tab, Product Versions meta box,
|
||||
// and show_if_licensed-variable elements
|
||||
function toggleOurElements() {
|
||||
var productType = $('#product-type').val();
|
||||
var isLicensed = productType === 'licensed';
|
||||
var isLicensedVariable = productType === 'licensed-variable';
|
||||
|
||||
// License Settings tab - use CSS class for visibility
|
||||
var $licenseTab = $('li.licensed_product_options');
|
||||
if (isLicensed || isLicensedVariable) {
|
||||
$licenseTab.addClass('wclp-active');
|
||||
} else {
|
||||
$licenseTab.removeClass('wclp-active');
|
||||
// If License Settings panel is active, switch to General tab
|
||||
if ($('#licensed_product_data').is(':visible')) {
|
||||
$('li.general_options a').trigger('click');
|
||||
}
|
||||
}
|
||||
|
||||
// Product Versions meta box
|
||||
var $metaBox = $('#wc_licensed_product_versions');
|
||||
if (isLicensed || isLicensedVariable) {
|
||||
$metaBox.css('display', '');
|
||||
} else {
|
||||
$metaBox.css('display', 'none');
|
||||
}
|
||||
|
||||
// Handle show_if_licensed-variable elements (like Variations tab)
|
||||
// WooCommerce doesn't know about our custom product types
|
||||
if (isLicensedVariable) {
|
||||
$('.show_if_licensed-variable').show();
|
||||
// Also show elements that should be visible for variable products
|
||||
// since licensed-variable is a variable product type
|
||||
$('.show_if_variable').show();
|
||||
$('.hide_if_variable').hide();
|
||||
} else {
|
||||
// Let WooCommerce handle show_if_variable elements
|
||||
// We only need to hide our custom class when not licensed-variable
|
||||
// Don't hide show_if_licensed-variable when it's licensed (simple)
|
||||
if (!isLicensed) {
|
||||
$('.show_if_licensed-variable').not('.show_if_licensed').hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial setup - run after WooCommerce has initialized
|
||||
setTimeout(toggleOurElements, 10);
|
||||
|
||||
// On product type change - run after WooCommerce has processed
|
||||
$('#product-type').on('change', function() {
|
||||
setTimeout(toggleOurElements, 100);
|
||||
});
|
||||
|
||||
// Re-apply after WooCommerce AJAX operations
|
||||
$(document).on('woocommerce_variations_loaded woocommerce_variations_added woocommerce_variations_saved', function() {
|
||||
setTimeout(toggleOurElements, 10);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
|
||||
251
src/Product/LicensedProductVariation.php
Normal file
251
src/Product/LicensedProductVariation.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always in stock (virtual, no inventory)
|
||||
*/
|
||||
public function is_in_stock(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get availability - empty for licensed products (no stock indicator)
|
||||
*/
|
||||
public function get_availability(): array
|
||||
{
|
||||
return [
|
||||
'availability' => '',
|
||||
'class' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't manage stock for licensed products
|
||||
*/
|
||||
public function managing_stock(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variation is purchasable
|
||||
* Override to handle custom parent product type
|
||||
*/
|
||||
public function is_purchasable(): bool
|
||||
{
|
||||
// Check if variation exists
|
||||
if (!$this->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check parent product status
|
||||
$parentId = $this->get_parent_id();
|
||||
$parentStatus = get_post_status($parentId);
|
||||
|
||||
if ($parentStatus !== 'publish' && !current_user_can('edit_post', $parentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if variation has a price
|
||||
$price = $this->get_price();
|
||||
if ($price === '' || $price === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return apply_filters('woocommerce_variation_is_purchasable', true, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
350
src/Product/LicensedVariableProduct.php
Normal file
350
src/Product/LicensedVariableProduct.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<?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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product is of a certain type
|
||||
* Override to return true for 'variable' as well, so WooCommerce internal
|
||||
* checks pass (many methods in WC_Product_Variable check is_type('variable'))
|
||||
*/
|
||||
public function is_type($type): bool
|
||||
{
|
||||
if (is_array($type)) {
|
||||
return in_array($this->get_type(), $type, true) || in_array('variable', $type, true);
|
||||
}
|
||||
return $this->get_type() === $type || 'variable' === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always virtual
|
||||
*/
|
||||
public function is_virtual(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed variable products are purchasable if the parent check passes
|
||||
* Variable products don't have a direct price - their variations do
|
||||
*/
|
||||
public function is_purchasable(): bool
|
||||
{
|
||||
// Use the parent WC_Product_Variable logic
|
||||
// which checks exists() and status, not price
|
||||
return parent::is_purchasable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are always in stock (virtual, no inventory)
|
||||
*/
|
||||
public function is_in_stock(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get children (variations) for this product
|
||||
* Override because WC_Product_Variable::get_children() checks is_type('variable')
|
||||
* which fails for our 'licensed-variable' type
|
||||
*/
|
||||
public function get_children($context = 'view'): array
|
||||
{
|
||||
if (!$this->get_id()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query variations directly from database since WooCommerce's data store
|
||||
// doesn't work properly with custom variable product types
|
||||
global $wpdb;
|
||||
$children = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT ID FROM {$wpdb->posts}
|
||||
WHERE post_parent = %d
|
||||
AND post_type = 'product_variation'
|
||||
AND post_status IN ('publish', 'private')
|
||||
ORDER BY menu_order ASC, ID ASC",
|
||||
$this->get_id()
|
||||
));
|
||||
|
||||
$children = array_map('intval', $children);
|
||||
|
||||
if ('view' === $context) {
|
||||
$children = apply_filters('woocommerce_get_children', $children, $this, false);
|
||||
}
|
||||
|
||||
return is_array($children) ? $children : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variation attributes for this product
|
||||
* Override because WC_Product_Variable uses data_store which doesn't work
|
||||
* properly with custom variable product types
|
||||
*/
|
||||
public function get_variation_attributes(): array
|
||||
{
|
||||
$attributes = $this->get_attributes();
|
||||
|
||||
if (!$attributes || !is_array($attributes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$variation_attributes = [];
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
// For WC_Product_Attribute objects
|
||||
if ($attribute instanceof \WC_Product_Attribute) {
|
||||
if ($attribute->get_variation()) {
|
||||
$attribute_name = $attribute->get_name();
|
||||
|
||||
// For taxonomy attributes, get term slugs
|
||||
if ($attribute->is_taxonomy()) {
|
||||
$attribute_terms = wc_get_product_terms(
|
||||
$this->get_id(),
|
||||
$attribute_name,
|
||||
['fields' => 'slugs']
|
||||
);
|
||||
$variation_attributes[$attribute_name] = $attribute_terms;
|
||||
} else {
|
||||
// For custom attributes, get options directly
|
||||
$variation_attributes[$attribute_name] = $attribute->get_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
// For array-based attributes (older format)
|
||||
elseif (is_array($attribute) && !empty($attribute['is_variation'])) {
|
||||
$attribute_name = $attribute['name'];
|
||||
$values = isset($attribute['value']) ? explode('|', $attribute['value']) : [];
|
||||
$variation_attributes[$attribute_name] = array_map('trim', $values);
|
||||
}
|
||||
}
|
||||
|
||||
return $variation_attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variation prices (regular, sale, and final prices)
|
||||
* Override because WC_Product_Variable uses data_store which doesn't work
|
||||
* properly with custom variable product types
|
||||
*/
|
||||
public function get_variation_prices($for_display = false): array
|
||||
{
|
||||
$children = $this->get_children();
|
||||
|
||||
if (empty($children)) {
|
||||
return [
|
||||
'price' => [],
|
||||
'regular_price' => [],
|
||||
'sale_price' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$prices = [
|
||||
'price' => [],
|
||||
'regular_price' => [],
|
||||
'sale_price' => [],
|
||||
];
|
||||
|
||||
foreach ($children as $child_id) {
|
||||
$variation = wc_get_product($child_id);
|
||||
if ($variation) {
|
||||
$price = $variation->get_price();
|
||||
$regular_price = $variation->get_regular_price();
|
||||
$sale_price = $variation->get_sale_price();
|
||||
|
||||
if ('' !== $price) {
|
||||
$prices['price'][$child_id] = $price;
|
||||
}
|
||||
if ('' !== $regular_price) {
|
||||
$prices['regular_price'][$child_id] = $regular_price;
|
||||
}
|
||||
if ('' !== $sale_price) {
|
||||
$prices['sale_price'][$child_id] = $sale_price;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort prices
|
||||
asort($prices['price']);
|
||||
asort($prices['regular_price']);
|
||||
asort($prices['sale_price']);
|
||||
|
||||
$this->prices_array = $prices;
|
||||
|
||||
return $this->prices_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available variations for this product
|
||||
* Override because WC_Product_Variable uses data_store which doesn't work
|
||||
* properly with custom variable product types
|
||||
*/
|
||||
public function get_available_variations($return = 'array')
|
||||
{
|
||||
$children = $this->get_children();
|
||||
$available_variations = [];
|
||||
|
||||
foreach ($children as $child_id) {
|
||||
$variation = wc_get_product($child_id);
|
||||
|
||||
if (!$variation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if variation should be available
|
||||
if (!$variation->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if purchasable (has price)
|
||||
if (!$variation->is_purchasable()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build variation data
|
||||
if ($return === 'array') {
|
||||
$variationData = $this->get_available_variation($variation);
|
||||
// Override availability_html to be empty for licensed products
|
||||
$variationData['availability_html'] = '';
|
||||
$available_variations[] = $variationData;
|
||||
} else {
|
||||
$available_variations[] = $variation;
|
||||
}
|
||||
}
|
||||
|
||||
if ($return === 'array') {
|
||||
$available_variations = array_values(array_filter($available_variations));
|
||||
}
|
||||
|
||||
return $available_variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
439
src/Update/PluginUpdateChecker.php
Normal file
439
src/Update/PluginUpdateChecker.php
Normal file
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Update Checker
|
||||
*
|
||||
* Checks for plugin updates from the configured license server.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Update
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Update;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
/**
|
||||
* Handles checking for plugin updates from the license server
|
||||
*
|
||||
* This class hooks into WordPress's native plugin update system to check for
|
||||
* updates from the configured license server. It validates the license and
|
||||
* provides download authentication.
|
||||
*/
|
||||
final class PluginUpdateChecker
|
||||
{
|
||||
/**
|
||||
* Cache key for update info
|
||||
*/
|
||||
private const CACHE_KEY = 'wclp_update_info';
|
||||
|
||||
/**
|
||||
* Default cache TTL (12 hours)
|
||||
*/
|
||||
private const DEFAULT_CACHE_TTL = 43200;
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Plugin slug
|
||||
*/
|
||||
private string $pluginSlug;
|
||||
|
||||
/**
|
||||
* Plugin basename (slug/slug.php)
|
||||
*/
|
||||
private string $pluginBasename;
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
$this->pluginSlug = 'wc-licensed-product';
|
||||
$this->pluginBasename = WC_LICENSED_PRODUCT_PLUGIN_BASENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks for update checking
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Skip if update notifications are disabled
|
||||
if ($this->isUpdateNotificationDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
add_filter('pre_set_site_transient_update_plugins', [$this, 'checkForUpdates']);
|
||||
|
||||
// Provide plugin information for the update modal
|
||||
add_filter('plugins_api', [$this, 'getPluginInfo'], 10, 3);
|
||||
|
||||
// Add authentication headers to download requests
|
||||
add_filter('http_request_args', [$this, 'addAuthHeaders'], 10, 2);
|
||||
|
||||
// Handle auto-install setting
|
||||
add_filter('auto_update_plugin', [$this, 'handleAutoInstall'], 10, 2);
|
||||
|
||||
// Clear cache on settings save
|
||||
add_action('update_option_wc_licensed_product_plugin_license_key', [$this, 'clearCache']);
|
||||
add_action('update_option_wc_licensed_product_plugin_license_server_url', [$this, 'clearCache']);
|
||||
add_action('update_option_wc_licensed_product_update_notification_enabled', [$this, 'clearCache']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if update notifications are disabled
|
||||
*/
|
||||
private function isUpdateNotificationDisabled(): bool
|
||||
{
|
||||
// Check constant
|
||||
if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check setting
|
||||
return !SettingsController::isUpdateNotificationEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-install setting for WordPress automatic updates
|
||||
*
|
||||
* @param bool|null $update The update decision
|
||||
* @param object $item The plugin update object
|
||||
* @return bool|null Whether to auto-update this plugin
|
||||
*/
|
||||
public function handleAutoInstall($update, $item): ?bool
|
||||
{
|
||||
// Only handle our plugin
|
||||
if (!isset($item->plugin) || $item->plugin !== $this->pluginBasename) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
// Return true to enable auto-install, false to disable, or null to use default
|
||||
return SettingsController::isAutoInstallEnabled() ? true : $update;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for plugin updates
|
||||
*
|
||||
* @param object $transient The update_plugins transient
|
||||
* @return object Modified transient
|
||||
*/
|
||||
public function checkForUpdates($transient)
|
||||
{
|
||||
if (empty($transient->checked)) {
|
||||
return $transient;
|
||||
}
|
||||
|
||||
// Get cached update info or fetch fresh
|
||||
$updateInfo = $this->getUpdateInfo();
|
||||
|
||||
if (!$updateInfo || !isset($updateInfo['update_available']) || !$updateInfo['update_available']) {
|
||||
return $transient;
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
$currentVersion = $transient->checked[$this->pluginBasename] ?? WC_LICENSED_PRODUCT_VERSION;
|
||||
|
||||
if (version_compare($updateInfo['version'], $currentVersion, '>')) {
|
||||
$transient->response[$this->pluginBasename] = $this->buildUpdateObject($updateInfo);
|
||||
}
|
||||
|
||||
return $transient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin information for the update modal
|
||||
*
|
||||
* @param false|object|array $result The result object or array
|
||||
* @param string $action The API action
|
||||
* @param object $args Request arguments
|
||||
* @return false|object
|
||||
*/
|
||||
public function getPluginInfo($result, string $action, object $args)
|
||||
{
|
||||
if ($action !== 'plugin_information') {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (!isset($args->slug) || $args->slug !== $this->pluginSlug) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Get update info
|
||||
$updateInfo = $this->getUpdateInfo(true);
|
||||
|
||||
if (!$updateInfo) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $this->buildPluginInfoObject($updateInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add authentication headers to download requests
|
||||
*
|
||||
* @param array $args HTTP request arguments
|
||||
* @param string $url Request URL
|
||||
* @return array Modified arguments
|
||||
*/
|
||||
public function addAuthHeaders(array $args, string $url): array
|
||||
{
|
||||
// Only modify requests to our license server
|
||||
$serverUrl = $this->getLicenseServerUrl();
|
||||
if (empty($serverUrl) || strpos($url, parse_url($serverUrl, PHP_URL_HOST)) === false) {
|
||||
return $args;
|
||||
}
|
||||
|
||||
// Only modify download requests
|
||||
if (strpos($url, 'license-download') === false) {
|
||||
return $args;
|
||||
}
|
||||
|
||||
// Add license key to headers for potential server-side verification
|
||||
$licenseKey = $this->getLicenseKey();
|
||||
if (!empty($licenseKey)) {
|
||||
$args['headers']['X-License-Key'] = $licenseKey;
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update info from cache or server
|
||||
*
|
||||
* @param bool $forceRefresh Force refresh from server
|
||||
* @return array|null Update info or null if unavailable
|
||||
*/
|
||||
public function getUpdateInfo(bool $forceRefresh = false): ?array
|
||||
{
|
||||
// Check cache unless force refresh
|
||||
if (!$forceRefresh) {
|
||||
$cached = get_transient(self::CACHE_KEY);
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from server
|
||||
$updateInfo = $this->fetchUpdateInfo();
|
||||
|
||||
if ($updateInfo) {
|
||||
// Cache the result
|
||||
$cacheTtl = $this->getCacheTtl();
|
||||
set_transient(self::CACHE_KEY, $updateInfo, $cacheTtl);
|
||||
}
|
||||
|
||||
return $updateInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch update info from the license server
|
||||
*/
|
||||
private function fetchUpdateInfo(): ?array
|
||||
{
|
||||
$serverUrl = $this->getLicenseServerUrl();
|
||||
$licenseKey = $this->getLicenseKey();
|
||||
|
||||
if (empty($serverUrl) || empty($licenseKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$httpClient = HttpClient::create([
|
||||
'timeout' => 15,
|
||||
'verify_peer' => true,
|
||||
]);
|
||||
|
||||
$updateCheckUrl = rtrim($serverUrl, '/') . '/wp-json/wc-licensed-product/v1/update-check';
|
||||
|
||||
$response = $httpClient->request('POST', $updateCheckUrl, [
|
||||
'json' => [
|
||||
'license_key' => $licenseKey,
|
||||
'domain' => $this->getCurrentDomain(),
|
||||
'plugin_slug' => $this->pluginSlug,
|
||||
'current_version' => WC_LICENSED_PRODUCT_VERSION,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
// Verify response structure
|
||||
if (!isset($data['success']) || !$data['success']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't break the site
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('WC Licensed Product: Update check failed - ' . $e->getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WordPress update object for transient
|
||||
*/
|
||||
private function buildUpdateObject(array $updateInfo): object
|
||||
{
|
||||
$update = new \stdClass();
|
||||
$update->id = $this->pluginSlug;
|
||||
$update->slug = $updateInfo['slug'] ?? $this->pluginSlug;
|
||||
$update->plugin = $this->pluginBasename;
|
||||
$update->new_version = $updateInfo['version'];
|
||||
$update->url = $updateInfo['homepage'] ?? '';
|
||||
$update->package = $updateInfo['download_url'] ?? $updateInfo['package'] ?? '';
|
||||
|
||||
if (isset($updateInfo['tested'])) {
|
||||
$update->tested = $updateInfo['tested'];
|
||||
}
|
||||
|
||||
if (isset($updateInfo['requires'])) {
|
||||
$update->requires = $updateInfo['requires'];
|
||||
}
|
||||
|
||||
if (isset($updateInfo['requires_php'])) {
|
||||
$update->requires_php = $updateInfo['requires_php'];
|
||||
}
|
||||
|
||||
if (isset($updateInfo['icons'])) {
|
||||
$update->icons = $updateInfo['icons'];
|
||||
}
|
||||
|
||||
return $update;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build plugin info object for plugins_api
|
||||
*/
|
||||
private function buildPluginInfoObject(array $updateInfo): object
|
||||
{
|
||||
$info = new \stdClass();
|
||||
$info->name = $updateInfo['name'] ?? 'WC Licensed Product';
|
||||
$info->slug = $updateInfo['slug'] ?? $this->pluginSlug;
|
||||
$info->version = $updateInfo['version'];
|
||||
$info->author = '<a href="https://src.bundespruefstelle.ch/magdev">Marco Graetsch</a>';
|
||||
$info->homepage = $updateInfo['homepage'] ?? '';
|
||||
$info->requires = $updateInfo['requires'] ?? '6.0';
|
||||
$info->tested = $updateInfo['tested'] ?? '';
|
||||
$info->requires_php = $updateInfo['requires_php'] ?? '8.3';
|
||||
$info->downloaded = 0;
|
||||
$info->last_updated = $updateInfo['last_updated'] ?? '';
|
||||
$info->download_link = $updateInfo['download_url'] ?? $updateInfo['package'] ?? '';
|
||||
|
||||
// Sections for the modal
|
||||
$info->sections = [];
|
||||
|
||||
if (isset($updateInfo['sections']['description'])) {
|
||||
$info->sections['description'] = $updateInfo['sections']['description'];
|
||||
} else {
|
||||
$info->sections['description'] = __(
|
||||
'WooCommerce plugin for selling licensed software products with domain-bound license keys.',
|
||||
'wc-licensed-product'
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($updateInfo['sections']['changelog']) || isset($updateInfo['changelog'])) {
|
||||
$info->sections['changelog'] = $updateInfo['sections']['changelog'] ?? $updateInfo['changelog'];
|
||||
}
|
||||
|
||||
// Banners and icons
|
||||
if (isset($updateInfo['banners'])) {
|
||||
$info->banners = $updateInfo['banners'];
|
||||
}
|
||||
|
||||
if (isset($updateInfo['icons'])) {
|
||||
$info->icons = $updateInfo['icons'];
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the update cache
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
delete_transient(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache TTL from settings or default
|
||||
*/
|
||||
private function getCacheTtl(): int
|
||||
{
|
||||
$hours = (int) get_option('wc_licensed_product_update_check_frequency', 12);
|
||||
return max(1, $hours) * HOUR_IN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the license server URL from settings
|
||||
*/
|
||||
private function getLicenseServerUrl(): string
|
||||
{
|
||||
// Check constant override first
|
||||
if (defined('WC_LICENSE_UPDATE_CHECK_URL') && WC_LICENSE_UPDATE_CHECK_URL) {
|
||||
return WC_LICENSE_UPDATE_CHECK_URL;
|
||||
}
|
||||
|
||||
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 current domain from the site URL
|
||||
*/
|
||||
private function getCurrentDomain(): string
|
||||
{
|
||||
$siteUrl = get_site_url();
|
||||
$parsed = parse_url($siteUrl);
|
||||
$host = $parsed['host'] ?? 'localhost';
|
||||
|
||||
if (isset($parsed['port'])) {
|
||||
$host .= ':' . $parsed['port'];
|
||||
}
|
||||
|
||||
return strtolower($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate update check
|
||||
*
|
||||
* Useful for admin interfaces where user clicks "Check for updates"
|
||||
*/
|
||||
public function forceUpdateCheck(): ?array
|
||||
{
|
||||
$this->clearCache();
|
||||
return $this->getUpdateInfo(true);
|
||||
}
|
||||
}
|
||||
@@ -424,12 +424,11 @@
|
||||
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>';
|
||||
html += '<tr><th>{{ __('Product') }}</th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></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 += '<tr><th>{{ __('Expires') }}</th><td><span class="license-lifetime">{{ __('Lifetime') }}</span></td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
} else {
|
||||
|
||||
@@ -1,81 +1,198 @@
|
||||
{% if not has_licenses %}
|
||||
<p>{{ __('You have no licenses yet.') }}</p>
|
||||
{# License Filter Form #}
|
||||
{% if filter_products is defined and filter_products|length > 0 or filter_domains is defined and filter_domains|length > 0 %}
|
||||
<div class="wclp-filter-form">
|
||||
<form method="get" action="{{ esc_url(licenses_url) }}">
|
||||
<div class="wclp-filter-row">
|
||||
{% if filter_products is defined and filter_products|length > 0 %}
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_product">{{ __('Product') }}</label>
|
||||
<select name="filter_product" id="filter_product">
|
||||
<option value="">{{ __('All Products') }}</option>
|
||||
{% for id, name in filter_products %}
|
||||
<option value="{{ id }}" {{ current_filter_product == id ? 'selected' : '' }}>
|
||||
{{ esc_html(name) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if filter_domains is defined and filter_domains|length > 0 %}
|
||||
<div class="wclp-filter-field">
|
||||
<label for="filter_domain">{{ __('Domain') }}</label>
|
||||
<select name="filter_domain" id="filter_domain">
|
||||
<option value="">{{ __('All Domains') }}</option>
|
||||
{% for domain in filter_domains %}
|
||||
<option value="{{ esc_attr(domain) }}" {{ current_filter_domain == domain ? 'selected' : '' }}>
|
||||
{{ esc_html(domain) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="wclp-filter-actions">
|
||||
<button type="submit" class="button">{{ __('Filter') }}</button>
|
||||
{% if is_filtered %}
|
||||
<a href="{{ esc_url(licenses_url) }}" class="button">{{ __('Clear') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not has_packages %}
|
||||
{% if is_filtered %}
|
||||
<p>{{ __('No licenses found matching your filters.') }}</p>
|
||||
{% else %}
|
||||
<p>{{ __('You have no licenses yet.') }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="woocommerce-licenses">
|
||||
{% for item in licenses %}
|
||||
<div class="license-card">
|
||||
<div class="license-header">
|
||||
<h3>
|
||||
{% if item.product_url %}
|
||||
<a href="{{ esc_url(item.product_url) }}">{{ esc_html(item.product_name) }}</a>
|
||||
{% else %}
|
||||
{{ esc_html(item.product_name) }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
|
||||
{{ esc_html(item.license.status)|capitalize }}
|
||||
{% for package in packages %}
|
||||
<div class="license-package">
|
||||
<div class="package-header">
|
||||
<div class="package-title">
|
||||
<h3>
|
||||
{% if package.product_url %}
|
||||
<a href="{{ esc_url(package.product_url) }}">{{ esc_html(package.product_name) }}</a>
|
||||
{% else %}
|
||||
{{ esc_html(package.product_name) }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
<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') }}">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span><strong>{{ __('Expires:') }}</strong>
|
||||
{% if item.license.expiresAt %}
|
||||
{{ item.license.expiresAt|date('Y-m-d') }}
|
||||
{% else %}
|
||||
{{ __('Never') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</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 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>
|
||||
</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 %}
|
||||
{# 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>
|
||||
</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>
|
||||
|
||||
@@ -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.3.8
|
||||
* Version: 0.7.3
|
||||
* 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.3.8');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.7.3');
|
||||
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