You've already forked wc-licensed-product
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57e1b838cc | |||
| cfd34c9329 | |||
| fb4be7124b | |||
| 73ba7fb929 | |||
| 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 |
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
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ wp-plugins
|
||||
wp-core
|
||||
vendor/
|
||||
releases/*
|
||||
|
||||
# Marketing texts (not part of plugin distribution)
|
||||
MARKETING.md
|
||||
|
||||
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
|
||||
238
CHANGELOG.md
238
CHANGELOG.md
@@ -7,6 +7,244 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.5] - 2026-02-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Grafana Dashboard**: Example dashboard for license metrics monitoring
|
||||
- 24 panels organized into 4 sections: License Overview, Downloads & Versions, API Metrics, Errors & Rate Limiting
|
||||
- Template variables for data source and instance filtering
|
||||
- Includes example Prometheus alerting rules
|
||||
- **WP Prometheus Dashboard Integration**: Dashboard automatically registered with wp-prometheus
|
||||
- Appears in Settings > WP Prometheus > Dashboards when metrics are enabled
|
||||
- Uses `wp_prometheus_register_dashboards` hook for seamless integration
|
||||
- Documentation for Grafana dashboard installation and PromQL query examples
|
||||
|
||||
### New Files
|
||||
|
||||
- `docs/grafana-dashboard.json` - Complete Grafana dashboard with 24 panels
|
||||
- `docs/grafana-dashboard.md` - Installation and usage documentation
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated README with "Monitoring with Prometheus & Grafana" section
|
||||
|
||||
## [0.7.4] - 2026-02-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Prometheus Metrics Integration**: Expose license and API metrics for monitoring
|
||||
- New "Metrics" settings tab with enable/disable toggle
|
||||
- License gauges: total by status, lifetime, expiring, expiring soon
|
||||
- Download gauges: total downloads, active versions count
|
||||
- API counters: requests by endpoint/result, rate limit exceeded events, validation errors by type
|
||||
- Requires [WP Prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) plugin
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/Metrics/PrometheusController.php` - Prometheus metrics collection and registration
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Hooks into `wp_prometheus_collect_metrics` action for metric collection
|
||||
- API counters stored persistently in WordPress options (`wclp_prometheus_counters`)
|
||||
- Static methods for incrementing counters from API controllers
|
||||
- Metrics only collected when enabled in settings
|
||||
|
||||
## [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
|
||||
|
||||
582
CLAUDE.md
582
CLAUDE.md
@@ -32,9 +32,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
|
||||
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
|
||||
|
||||
### Version 0.6.0
|
||||
### Known Bugs
|
||||
|
||||
*No planned features yet.*
|
||||
None currently tracked.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
@@ -55,6 +55,13 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
||||
- 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
|
||||
|
||||
@@ -197,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
|
||||
@@ -220,10 +229,11 @@ 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 |
|
||||
| `/update-check` | POST | Check for plugin updates (v0.6.0+) |
|
||||
|
||||
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
|
||||
|
||||
@@ -1495,3 +1505,565 @@ Removed redundant "Default" prefix from setting labels on the Default Settings p
|
||||
- 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
|
||||
|
||||
### 2026-02-03 - Version 0.7.4 - Prometheus Metrics Integration
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added Prometheus metrics integration to expose license and API metrics for monitoring. Requires the WP Prometheus plugin.
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/Metrics/PrometheusController.php` - Prometheus metrics collection controller
|
||||
|
||||
**Implemented:**
|
||||
|
||||
- New "Metrics" settings section with enable/disable toggle
|
||||
- Hooks into `wp_prometheus_collect_metrics` action for metric collection
|
||||
- License gauges using existing `LicenseManager::getStatistics()`:
|
||||
- `wclp_licenses_total{status}` - License counts by status
|
||||
- `wclp_licenses_lifetime_total` - Lifetime licenses count
|
||||
- `wclp_licenses_expiring_total` - Expiring licenses count
|
||||
- `wclp_licenses_expiring_soon` - Licenses expiring within 30 days
|
||||
- Download gauges using existing `VersionManager::getDownloadStatistics()`:
|
||||
- `wclp_downloads_total` - Total downloads
|
||||
- `wclp_versions_active_total` - Active product versions
|
||||
- API counters (stored in WordPress options for persistence):
|
||||
- `wclp_api_requests_total{endpoint,result}` - API requests by endpoint and result
|
||||
- `wclp_rate_limit_exceeded_total{endpoint}` - Rate limit exceeded events
|
||||
- `wclp_validation_errors_total{error_type}` - Validation errors by type
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Admin/SettingsController.php` - Added 'metrics' section with settings
|
||||
- `src/Api/RestApiController.php` - Added metric tracking for API requests
|
||||
- `src/Api/UpdateController.php` - Added metric tracking for update-check requests
|
||||
- `src/Plugin.php` - Initialize PrometheusController
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Metrics are only collected when enabled via settings toggle
|
||||
- Static methods allow increment from API controllers without dependency injection
|
||||
- Counter values persist across requests via `wclp_prometheus_counters` option
|
||||
- Gauges query database on each metric collection (uses existing statistics methods)
|
||||
|
||||
### 2026-02-03 - Grafana Dashboard & WP Prometheus Integration
|
||||
|
||||
**Overview:**
|
||||
|
||||
Added example Grafana dashboard JSON and integrated with wp-prometheus dashboard registration system.
|
||||
|
||||
**New files:**
|
||||
|
||||
- `docs/grafana-dashboard.json` - Complete Grafana dashboard with 24 panels
|
||||
- `docs/grafana-dashboard.md` - Installation and usage documentation
|
||||
|
||||
**Dashboard panels:**
|
||||
|
||||
- License Overview: Total, Active, Lifetime, Expiring Soon, Expired, Revoked stats + pie chart + time series
|
||||
- Downloads & Versions: Total downloads, active versions, download trends
|
||||
- API Metrics: Request rates by endpoint/result, pie chart breakdown, top requests table
|
||||
- Errors & Rate Limiting: Rate limit events, validation errors by type over time
|
||||
|
||||
**WP Prometheus integration:**
|
||||
|
||||
- Dashboard automatically registered via `wp_prometheus_register_dashboards` hook
|
||||
- Appears in Settings > WP Prometheus > Dashboards tab when metrics are enabled
|
||||
- Uses file-based registration with metadata (title, description, icon, plugin attribution)
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/Metrics/PrometheusController.php` - Added `registerDashboard()` method and hook registration
|
||||
- `docs/grafana-dashboard.md` - Added wp-prometheus installation option as recommended method
|
||||
- `README.md` - Added "Monitoring with Prometheus & Grafana" section linking to dashboard docs
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Dashboard registration only occurs when metrics are enabled (same condition as metric collection)
|
||||
- Uses `dashicons-admin-network` icon for dashboard list
|
||||
- File path uses `WC_LICENSED_PRODUCT_PLUGIN_DIR` constant for reliable path resolution
|
||||
|
||||
133
README.md
133
README.md
@@ -21,9 +21,12 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
|
||||
- **Version Binding**: Optional binding to major software versions
|
||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||
- **Rate Limiting**: API endpoints protected with 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
|
||||
|
||||
@@ -132,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
|
||||
|
||||
@@ -330,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
|
||||
@@ -346,6 +393,38 @@ The plugin sends automatic email notifications (configurable via WooCommerce > S
|
||||
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
|
||||
- **License Expired**: Notification when a license auto-expires
|
||||
|
||||
## Monitoring with Prometheus & Grafana
|
||||
|
||||
The plugin integrates with [wp-prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) to expose metrics for monitoring and alerting.
|
||||
|
||||
### Enable Metrics
|
||||
|
||||
1. Install and configure the wp-prometheus plugin
|
||||
2. Go to WooCommerce > Settings > Licensed Products > Metrics
|
||||
3. Enable "Prometheus Metrics"
|
||||
|
||||
### Available Metrics
|
||||
|
||||
**Gauges:**
|
||||
|
||||
- `wclp_licenses_total{status}` - License counts by status
|
||||
- `wclp_licenses_lifetime_total` - Lifetime licenses
|
||||
- `wclp_licenses_expiring_soon` - Licenses expiring within 30 days
|
||||
- `wclp_downloads_total` - Total file downloads
|
||||
- `wclp_versions_active_total` - Active product versions
|
||||
|
||||
**Counters:**
|
||||
|
||||
- `wclp_api_requests_total{endpoint,result}` - API requests by endpoint and result
|
||||
- `wclp_rate_limit_exceeded_total{endpoint}` - Rate limit events
|
||||
- `wclp_validation_errors_total{error_type}` - Validation errors by type
|
||||
|
||||
### Grafana Dashboard
|
||||
|
||||
An example Grafana dashboard is included at [docs/grafana-dashboard.json](docs/grafana-dashboard.json).
|
||||
|
||||
See [docs/grafana-dashboard.md](docs/grafana-dashboard.md) for installation instructions, panel descriptions, and alerting examples.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
|
||||
@@ -359,6 +438,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,16 +50,21 @@ code.file-hash {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* License Product Tab - Hidden by default, shown via JS based on product type */
|
||||
#woocommerce-product-data .show_if_licensed,
|
||||
#woocommerce-product-data .show_if_licensed-variable {
|
||||
/* 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;
|
||||
|
||||
@@ -37,6 +37,80 @@
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@@ -18,12 +18,25 @@
|
||||
}
|
||||
|
||||
const { getSetting } = wc.wcSettings;
|
||||
const { createElement, useState } = wp.element;
|
||||
const { createElement, useState, useEffect, useCallback } = wp.element;
|
||||
const { TextControl } = wp.components;
|
||||
const { __ } = wp.i18n;
|
||||
|
||||
// Get available exports from blocksCheckout
|
||||
const { ExperimentalOrderMeta } = wc.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', {});
|
||||
@@ -59,6 +72,23 @@
|
||||
const [domain, setDomain] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 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);
|
||||
@@ -67,9 +97,11 @@
|
||||
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
|
||||
} else {
|
||||
setError('');
|
||||
// Update Store API when valid
|
||||
updateStoreApi(normalized);
|
||||
}
|
||||
|
||||
// Store in hidden input for form submission
|
||||
// Store in hidden input for form submission (fallback)
|
||||
const hiddenInput = document.getElementById('wclp-domain-hidden');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = normalized;
|
||||
@@ -135,6 +167,23 @@
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -174,7 +223,7 @@
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
// Update hidden field with variation support
|
||||
// Build domain data for Store API
|
||||
const data = products.map(p => {
|
||||
const pKey = getProductKey(p);
|
||||
const doms = newDomains[pKey] || [];
|
||||
@@ -188,6 +237,10 @@
|
||||
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);
|
||||
@@ -273,11 +326,13 @@
|
||||
|
||||
if (registerPlugin) {
|
||||
registerPlugin('wc-licensed-product-domain-fields', {
|
||||
render: () => createElement(
|
||||
render: () => {
|
||||
return createElement(
|
||||
ExperimentalOrderMeta,
|
||||
{},
|
||||
createElement(LicenseDomainsBlock)
|
||||
),
|
||||
);
|
||||
},
|
||||
scope: 'woocommerce-checkout',
|
||||
});
|
||||
}
|
||||
@@ -312,64 +367,90 @@
|
||||
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) {
|
||||
container.innerHTML = `
|
||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
|
||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||||
${settings.fieldDescription || 'Enter a unique domain for each license.'}
|
||||
</p>
|
||||
${settings.licensedProducts.map(product => {
|
||||
const productKey = product.variation_id && product.variation_id > 0
|
||||
? `${product.product_id}_${product.variation_id}`
|
||||
: product.product_id;
|
||||
const durationLabel = product.duration_label || '';
|
||||
const displayName = durationLabel
|
||||
? `${product.name} (${durationLabel})`
|
||||
// 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;
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
||||
<strong style="display: block; margin-bottom: 8px;">
|
||||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
||||
</strong>
|
||||
${Array.from({ length: product.quantity }, (_, i) => `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: block; margin-bottom: 4px;">
|
||||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_domains[${productKey}][${i}]"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
${product.variation_id && product.variation_id > 0 ? `
|
||||
<input type="hidden"
|
||||
name="licensed_variation_ids[${productKey}]"
|
||||
value="${product.variation_id}"
|
||||
/>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`}).join('')}
|
||||
`;
|
||||
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 {
|
||||
container.innerHTML = `
|
||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4>
|
||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||||
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
|
||||
</p>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="display: block; margin-bottom: 4px;">
|
||||
${settings.singleDomainLabel || 'Domain'}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="licensed_product_domain"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
// 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) {
|
||||
@@ -379,6 +460,68 @@
|
||||
} 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');
|
||||
@@ -171,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('');
|
||||
@@ -235,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 + '"]');
|
||||
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() {
|
||||
|
||||
@@ -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": "5e4b5a970f75d0163c5496581d963a24ade4f276"
|
||||
"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-26T15:54:37+00:00"
|
||||
"transport-options": {
|
||||
"relative": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
@@ -380,16 +381,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v7.4.4",
|
||||
"version": "v7.4.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c"
|
||||
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
|
||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c",
|
||||
"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.4"
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -477,7 +478,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-23T16:34:22+00:00"
|
||||
"time": "2026-01-27T16:16:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
|
||||
1748
docs/grafana-dashboard.json
Normal file
1748
docs/grafana-dashboard.json
Normal file
File diff suppressed because it is too large
Load Diff
219
docs/grafana-dashboard.md
Normal file
219
docs/grafana-dashboard.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Grafana Dashboard for WC Licensed Product
|
||||
|
||||
This dashboard provides comprehensive monitoring for the WC Licensed Product plugin using Prometheus metrics exposed via the [wp-prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) plugin.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **WP Prometheus Plugin** - Install and configure [wp-prometheus](https://src.bundespruefstelle.ch/magdev/wp-prometheus) on your WordPress site
|
||||
2. **Prometheus** - Configure Prometheus to scrape your WordPress metrics endpoint
|
||||
3. **Grafana** - Grafana 9.0+ with Prometheus data source configured
|
||||
4. **Enable Metrics** - In WordPress admin: WooCommerce > Settings > Licensed Products > Metrics > Enable Prometheus Metrics
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Via WP Prometheus Settings (Recommended)
|
||||
|
||||
When metrics are enabled, the dashboard is automatically registered with wp-prometheus:
|
||||
|
||||
1. Go to **Settings > WP Prometheus** in WordPress admin
|
||||
2. Navigate to the **Dashboards** tab
|
||||
3. Find "WC Licensed Product - License Metrics" in the list
|
||||
4. Click **Download JSON** to get the dashboard file
|
||||
5. Import the downloaded file into Grafana
|
||||
|
||||
### Option 2: Manual Import
|
||||
|
||||
1. Open Grafana and navigate to **Dashboards > Import**
|
||||
2. Upload the `grafana-dashboard.json` file or paste its contents
|
||||
3. Select your Prometheus data source
|
||||
4. Click **Import**
|
||||
|
||||
### Configure Variables
|
||||
|
||||
The dashboard includes two template variables:
|
||||
|
||||
- **datasource** - Select your Prometheus data source
|
||||
- **instance** - Filter by WordPress instance (useful for multi-site monitoring)
|
||||
|
||||
## Dashboard Panels
|
||||
|
||||
### License Overview
|
||||
|
||||
| Panel | Description |
|
||||
| --- | --- |
|
||||
| Total Licenses | Total count of all licenses |
|
||||
| Active Licenses | Licenses with `status=active` |
|
||||
| Lifetime Licenses | Licenses without expiration date |
|
||||
| Expiring Soon (30d) | Licenses expiring within 30 days |
|
||||
| Expired Licenses | Licenses with `status=expired` |
|
||||
| Revoked Licenses | Licenses with `status=revoked` |
|
||||
| Licenses by Status | Pie chart showing distribution |
|
||||
| License Status Over Time | Time series of license counts |
|
||||
|
||||
### Downloads & Versions
|
||||
|
||||
| Panel | Description |
|
||||
| --- | --- |
|
||||
| Total Downloads | Cumulative download count |
|
||||
| Active Product Versions | Number of active versions |
|
||||
| Downloads Over Time | Download trend graph |
|
||||
| Downloads (Selected Range) | Downloads in selected time range |
|
||||
|
||||
### API Metrics
|
||||
|
||||
| Panel | Description |
|
||||
| --- | --- |
|
||||
| API Requests (5m intervals) | Stacked bar chart by endpoint/result |
|
||||
| Requests by Endpoint | Donut chart of endpoint distribution |
|
||||
| Top API Requests | Table of most frequent requests |
|
||||
|
||||
### Errors & Rate Limiting
|
||||
|
||||
| Panel | Description |
|
||||
| --- | --- |
|
||||
| Rate Limit Events (Total) | Total HTTP 429 responses |
|
||||
| Validation Errors (Total) | Total validation failures |
|
||||
| Validation Errors Over Time | Error trend by type |
|
||||
| Validation Errors by Type | Pie chart breakdown |
|
||||
| Rate Limit Events by Endpoint | Rate limits per endpoint |
|
||||
|
||||
## Metrics Reference
|
||||
|
||||
### Gauges (current values)
|
||||
|
||||
```promql
|
||||
# Licenses by status
|
||||
wclp_licenses_total{status="active|expired|revoked|inactive"}
|
||||
|
||||
# Lifetime licenses (no expiration)
|
||||
wclp_licenses_lifetime_total
|
||||
|
||||
# Licenses with expiration date
|
||||
wclp_licenses_expiring_total
|
||||
|
||||
# Licenses expiring within 30 days
|
||||
wclp_licenses_expiring_soon
|
||||
|
||||
# Total downloads
|
||||
wclp_downloads_total
|
||||
|
||||
# Active product versions
|
||||
wclp_versions_active_total
|
||||
```
|
||||
|
||||
### Counters (cumulative)
|
||||
|
||||
```promql
|
||||
# API requests by endpoint and result
|
||||
wclp_api_requests_total{endpoint="validate|status|activate|update-check", result="success|error"}
|
||||
|
||||
# Rate limit exceeded events
|
||||
wclp_rate_limit_exceeded_total{endpoint="validate|status|activate|update-check"}
|
||||
|
||||
# Validation errors by type
|
||||
wclp_validation_errors_total{error_type="license_not_found|domain_mismatch|license_expired|license_revoked|..."}
|
||||
```
|
||||
|
||||
## Example Prometheus Queries
|
||||
|
||||
### Success Rate
|
||||
|
||||
```promql
|
||||
sum(rate(wclp_api_requests_total{result="success"}[5m])) /
|
||||
sum(rate(wclp_api_requests_total[5m])) * 100
|
||||
```
|
||||
|
||||
### Error Rate by Endpoint
|
||||
|
||||
```promql
|
||||
sum by (endpoint) (rate(wclp_api_requests_total{result="error"}[5m]))
|
||||
```
|
||||
|
||||
### License Churn (new activations)
|
||||
|
||||
```promql
|
||||
increase(wclp_licenses_total{status="active"}[1d])
|
||||
```
|
||||
|
||||
### Top Validation Errors
|
||||
|
||||
```promql
|
||||
topk(5, sum by (error_type) (wclp_validation_errors_total))
|
||||
```
|
||||
|
||||
## Alerting Examples
|
||||
|
||||
Add these alerts to your Prometheus alerting rules:
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: wc-licensed-product
|
||||
rules:
|
||||
# High rate limit events
|
||||
- alert: HighRateLimitEvents
|
||||
expr: increase(wclp_rate_limit_exceeded_total[5m]) > 10
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High rate limiting on {{ $labels.endpoint }}"
|
||||
|
||||
# Many expiring licenses
|
||||
- alert: LicensesExpiringSoon
|
||||
expr: wclp_licenses_expiring_soon > 20
|
||||
for: 1h
|
||||
labels:
|
||||
severity: info
|
||||
annotations:
|
||||
summary: "{{ $value }} licenses expiring within 30 days"
|
||||
|
||||
# API error rate
|
||||
- alert: HighAPIErrorRate
|
||||
expr: |
|
||||
sum(rate(wclp_api_requests_total{result="error"}[5m])) /
|
||||
sum(rate(wclp_api_requests_total[5m])) > 0.1
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "API error rate above 10%"
|
||||
```
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
Add to your `prometheus.yml`:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'wordpress'
|
||||
metrics_path: '/metrics'
|
||||
scheme: https
|
||||
bearer_token: 'YOUR_WP_PROMETHEUS_TOKEN'
|
||||
static_configs:
|
||||
- targets: ['your-wordpress-site.com']
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No data showing
|
||||
|
||||
1. Verify wp-prometheus is installed and configured
|
||||
2. Check that metrics are enabled in WC Licensed Product settings
|
||||
3. Confirm Prometheus can reach your WordPress metrics endpoint
|
||||
4. Check the data source selection in Grafana
|
||||
|
||||
### Missing metrics
|
||||
|
||||
Some metrics only appear after relevant actions occur:
|
||||
|
||||
- `wclp_api_requests_total` - After API requests
|
||||
- `wclp_rate_limit_exceeded_total` - After rate limit events
|
||||
- `wclp_validation_errors_total` - After validation errors
|
||||
|
||||
### Counter resets
|
||||
|
||||
Counters persist in WordPress options and survive restarts. To reset:
|
||||
|
||||
```php
|
||||
\Jeremias\WcLicensedProduct\Metrics\PrometheusController::resetCounters();
|
||||
```
|
||||
@@ -21,7 +21,7 @@ This prevents attackers from:
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 7.4+ (8.0+ recommended)
|
||||
- PHP 8.3+
|
||||
- A server secret stored securely (not in version control)
|
||||
|
||||
## Server Configuration
|
||||
@@ -51,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:
|
||||
@@ -88,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);
|
||||
@@ -103,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
|
||||
@@ -214,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
|
||||
@@ -226,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,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
|
||||
|
||||
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.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.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>
|
||||
|
||||
|
||||
@@ -83,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;
|
||||
}
|
||||
@@ -162,7 +162,7 @@ final class OrderLicenseController
|
||||
// Legacy: one license per licensed product
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if ($product && $product->is_type('licensed')) {
|
||||
if ($product && $this->licenseManager->isLicensedProduct($product)) {
|
||||
$expectedLicenses++;
|
||||
}
|
||||
}
|
||||
@@ -567,7 +567,7 @@ final class OrderLicenseController
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -615,7 +615,7 @@ final class OrderLicenseController
|
||||
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if (!$product || !$product->is_type('licensed')) {
|
||||
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,10 @@ 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'),
|
||||
'metrics' => __('Metrics', 'wc-licensed-product'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -112,8 +114,10 @@ final class SettingsController
|
||||
$currentSection = $this->getCurrentSection();
|
||||
|
||||
return match ($currentSection) {
|
||||
'auto-updates' => $this->getAutoUpdatesSettings(),
|
||||
'defaults' => $this->getDefaultsSettings(),
|
||||
'notifications' => $this->getNotificationsSettings(),
|
||||
'metrics' => $this->getMetricsSettings(),
|
||||
default => $this->getPluginLicenseSettings(),
|
||||
};
|
||||
}
|
||||
@@ -160,6 +164,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
|
||||
*/
|
||||
@@ -262,6 +316,32 @@ final class SettingsController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics settings
|
||||
*/
|
||||
private function getMetricsSettings(): array
|
||||
{
|
||||
return [
|
||||
'metrics_section_title' => [
|
||||
'name' => __('Prometheus Metrics', 'wc-licensed-product'),
|
||||
'type' => 'title',
|
||||
'desc' => __('Expose license and API metrics for Prometheus monitoring. Requires the WP Prometheus plugin to be installed and active.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_section_metrics',
|
||||
],
|
||||
'metrics_enabled' => [
|
||||
'name' => __('Enable Prometheus Metrics', 'wc-licensed-product'),
|
||||
'type' => 'checkbox',
|
||||
'desc' => __('Expose license statistics, API usage, and download metrics via Prometheus.', 'wc-licensed-product'),
|
||||
'id' => 'wc_licensed_product_metrics_enabled',
|
||||
'default' => 'no',
|
||||
],
|
||||
'metrics_section_end' => [
|
||||
'type' => 'sectionend',
|
||||
'id' => 'wc_licensed_product_section_metrics_end',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render settings tab content
|
||||
*/
|
||||
@@ -460,6 +540,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
|
||||
*/
|
||||
@@ -485,4 +603,12 @@ final class SettingsController
|
||||
wp_send_json_error(['message' => $error]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Prometheus metrics are enabled
|
||||
*/
|
||||
public static function isMetricsEnabled(): bool
|
||||
{
|
||||
return get_option('wc_licensed_product_metrics_enabled', 'no') === 'yes';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,16 +43,13 @@ 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'),
|
||||
@@ -63,7 +60,6 @@ final class VersionAdminController
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render versions meta box
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +183,7 @@ final class ResponseSigner
|
||||
*/
|
||||
public static function getCustomerSecretForLicense(string $licenseKey): ?string
|
||||
{
|
||||
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
|
||||
$serverSecret = self::getServerSecret();
|
||||
|
||||
if (empty($serverSecret)) {
|
||||
return null;
|
||||
@@ -200,6 +199,40 @@ final class ResponseSigner
|
||||
*/
|
||||
public static function isSigningEnabled(): bool
|
||||
{
|
||||
return defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET);
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ declare(strict_types=1);
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
@@ -19,6 +20,7 @@ use WP_REST_Server;
|
||||
*/
|
||||
final class RestApiController
|
||||
{
|
||||
use IpDetectionTrait;
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
/**
|
||||
@@ -107,6 +109,10 @@ final class RestApiController
|
||||
'retry_after' => $retryAfter,
|
||||
], 429);
|
||||
$response->header('Retry-After', (string) $retryAfter);
|
||||
|
||||
// Track rate limit event for metrics
|
||||
PrometheusController::incrementRateLimitExceeded('api');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -115,154 +121,6 @@ final class RestApiController
|
||||
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
|
||||
*/
|
||||
@@ -356,6 +214,16 @@ final class RestApiController
|
||||
|
||||
$statusCode = $this->getStatusCodeForResult($result);
|
||||
|
||||
// Track metrics
|
||||
if ($result['valid']) {
|
||||
PrometheusController::incrementApiRequest('validate', 'success');
|
||||
} else {
|
||||
PrometheusController::incrementApiRequest('validate', 'error');
|
||||
if (!empty($result['error'])) {
|
||||
PrometheusController::incrementValidationError($result['error']);
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response($result, $statusCode);
|
||||
}
|
||||
|
||||
@@ -394,6 +262,9 @@ final class RestApiController
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
|
||||
if (!$license) {
|
||||
PrometheusController::incrementApiRequest('status', 'error');
|
||||
PrometheusController::incrementValidationError('license_not_found');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'valid' => false,
|
||||
'error' => 'license_not_found',
|
||||
@@ -401,6 +272,8 @@ final class RestApiController
|
||||
], 404);
|
||||
}
|
||||
|
||||
PrometheusController::incrementApiRequest('status', 'success');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'valid' => $license->isValid(),
|
||||
'status' => $license->getStatus(),
|
||||
@@ -427,6 +300,9 @@ final class RestApiController
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
|
||||
if (!$license) {
|
||||
PrometheusController::incrementApiRequest('activate', 'error');
|
||||
PrometheusController::incrementValidationError('license_not_found');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'license_not_found',
|
||||
@@ -435,6 +311,9 @@ final class RestApiController
|
||||
}
|
||||
|
||||
if (!$license->isValid()) {
|
||||
PrometheusController::incrementApiRequest('activate', 'error');
|
||||
PrometheusController::incrementValidationError('license_invalid');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'license_invalid',
|
||||
@@ -446,6 +325,8 @@ final class RestApiController
|
||||
|
||||
// Check if already activated on this domain
|
||||
if ($license->getDomain() === $normalizedDomain) {
|
||||
PrometheusController::incrementApiRequest('activate', 'success');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => __('License is already activated for this domain.', 'wc-licensed-product'),
|
||||
@@ -454,6 +335,9 @@ final class RestApiController
|
||||
|
||||
// Check if can activate on another domain
|
||||
if (!$license->canActivate()) {
|
||||
PrometheusController::incrementApiRequest('activate', 'error');
|
||||
PrometheusController::incrementValidationError('max_activations_reached');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'max_activations_reached',
|
||||
@@ -465,6 +349,9 @@ final class RestApiController
|
||||
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
|
||||
|
||||
if (!$success) {
|
||||
PrometheusController::incrementApiRequest('activate', 'error');
|
||||
PrometheusController::incrementValidationError('activation_failed');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'error' => 'activation_failed',
|
||||
@@ -472,6 +359,8 @@ final class RestApiController
|
||||
], 500);
|
||||
}
|
||||
|
||||
PrometheusController::incrementApiRequest('activate', 'success');
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => __('License activated successfully.', 'wc-licensed-product'),
|
||||
|
||||
372
src/Api/UpdateController.php
Normal file
372
src/Api/UpdateController.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?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\Metrics\PrometheusController;
|
||||
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);
|
||||
|
||||
// Track rate limit event for metrics
|
||||
PrometheusController::incrementRateLimitExceeded('update-check');
|
||||
|
||||
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']) {
|
||||
$errorType = $validationResult['error'] ?? 'license_invalid';
|
||||
PrometheusController::incrementApiRequest('update-check', 'error');
|
||||
PrometheusController::incrementValidationError($errorType);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => false,
|
||||
'update_available' => false,
|
||||
'error' => $errorType,
|
||||
'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) {
|
||||
PrometheusController::incrementApiRequest('update-check', 'error');
|
||||
PrometheusController::incrementValidationError('license_not_found');
|
||||
|
||||
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) {
|
||||
PrometheusController::incrementApiRequest('update-check', 'error');
|
||||
PrometheusController::incrementValidationError('product_not_found');
|
||||
|
||||
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) {
|
||||
PrometheusController::incrementApiRequest('update-check', 'success');
|
||||
|
||||
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);
|
||||
|
||||
PrometheusController::incrementApiRequest('update-check', 'success');
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -112,10 +112,12 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
public function get_script_data(): array
|
||||
{
|
||||
$isMultiDomain = SettingsController::isMultiDomainEnabled();
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
$hasLicensedProducts = !empty($licensedProducts);
|
||||
|
||||
return [
|
||||
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
|
||||
'licensedProducts' => $this->getLicensedProductsFromCart(),
|
||||
'hasLicensedProducts' => $hasLicensedProducts,
|
||||
'licensedProducts' => $licensedProducts,
|
||||
'isMultiDomainEnabled' => $isMultiDomain,
|
||||
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
|
||||
'fieldDescription' => $isMultiDomain
|
||||
@@ -151,8 +153,11 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
}
|
||||
|
||||
$licensedProducts = [];
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
$cartContents = WC()->cart->get_cart();
|
||||
|
||||
foreach ($cartContents as $cartKey => $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
@@ -171,11 +176,12 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
|
||||
}
|
||||
|
||||
// Check for variations of licensed-variable products
|
||||
if ($product->is_type('variation')) {
|
||||
// Use WC_Product_Factory::get_product_type() for reliable parent type check
|
||||
$parentId = $product->get_parent_id();
|
||||
$parent = wc_get_product($parentId);
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
if ($parentType === 'licensed-variable') {
|
||||
$variationId = $product->get_id();
|
||||
|
||||
// Get duration label if it's a LicensedProductVariation
|
||||
|
||||
@@ -67,8 +67,9 @@ final class CheckoutController
|
||||
}
|
||||
|
||||
$licensedProducts = [];
|
||||
foreach (WC()->cart->get_cart() as $cartItem) {
|
||||
foreach (WC()->cart->get_cart() as $cartItemKey => $cartItem) {
|
||||
$product = $cartItem['data'];
|
||||
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
@@ -87,11 +88,12 @@ final class CheckoutController
|
||||
}
|
||||
|
||||
// Check for variations of licensed-variable products
|
||||
if ($product->is_type('variation')) {
|
||||
// Use WC_Product_Factory::get_product_type() for reliable parent type check
|
||||
$parentId = $product->get_parent_id();
|
||||
$parent = wc_get_product($parentId);
|
||||
if ($parentId) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($parentId);
|
||||
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
if ($parentType === 'licensed-variable') {
|
||||
$variationId = $product->get_id();
|
||||
// Use combination key to allow same product with different variations
|
||||
$key = "{$parentId}_{$variationId}";
|
||||
@@ -127,6 +129,7 @@ final class CheckoutController
|
||||
public function addDomainField(): void
|
||||
{
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
|
||||
if (empty($licensedProducts)) {
|
||||
return;
|
||||
}
|
||||
@@ -401,6 +404,7 @@ final class CheckoutController
|
||||
public function saveDomainField(int $orderId): void
|
||||
{
|
||||
$licensedProducts = $this->getLicensedProductsFromCart();
|
||||
|
||||
if (empty($licensedProducts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,11 @@ final class StoreApiExtension
|
||||
{
|
||||
$requestData = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Handle JSON decode errors gracefully
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$requestData = null;
|
||||
}
|
||||
|
||||
if (SettingsController::isMultiDomainEnabled()) {
|
||||
$this->processMultiDomainOrder($order, $requestData);
|
||||
} else {
|
||||
@@ -270,7 +275,7 @@ final class StoreApiExtension
|
||||
// Check for wclp_license_domains (from our hidden input - JSON string)
|
||||
if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
|
||||
$parsed = json_decode($requestData['wclp_license_domains'], true);
|
||||
if (is_array($parsed)) {
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($parsed)) {
|
||||
$domainData = $this->normalizeDomainsData($parsed);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -19,6 +20,8 @@ use Twig\Environment;
|
||||
*/
|
||||
final class AccountController
|
||||
{
|
||||
use RateLimitTrait;
|
||||
|
||||
private Environment $twig;
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
@@ -106,23 +109,104 @@ 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);
|
||||
|
||||
// Apply filters
|
||||
$filteredLicenses = $this->applyLicenseFilters($licenses, $filterProductId, $filterDomain);
|
||||
|
||||
// Group licenses by product+order into "packages"
|
||||
$packages = $this->groupLicensesIntoPackages($licenses);
|
||||
$packages = $this->groupLicensesIntoPackages($filteredLicenses);
|
||||
|
||||
// Get unique products and domains for filter dropdowns
|
||||
$filterOptions = $this->getFilterOptions($licenses);
|
||||
|
||||
try {
|
||||
echo $this->twig->render('frontend/licenses.html.twig', [
|
||||
'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($packages);
|
||||
$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
|
||||
*
|
||||
@@ -217,10 +301,67 @@ final class AccountController
|
||||
/**
|
||||
* Fallback display method if Twig is unavailable
|
||||
*/
|
||||
private function displayLicensesFallback(array $packages): void
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -287,6 +428,26 @@ final class AccountController
|
||||
?>
|
||||
</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; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -437,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
|
||||
*/
|
||||
|
||||
@@ -37,10 +37,18 @@ class LicenseManager
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for our custom variation class
|
||||
if ($product instanceof LicensedProductVariation) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Variation of a licensed-variable product
|
||||
if ($product->is_type('variation') && $product->get_parent_id()) {
|
||||
$parent = wc_get_product($product->get_parent_id());
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -101,10 +109,10 @@ class LicenseManager
|
||||
// For variations, load the variation; otherwise load the parent product
|
||||
if ($variationId) {
|
||||
$settingsProduct = wc_get_product($variationId);
|
||||
$parentProduct = wc_get_product($productId);
|
||||
|
||||
// Verify parent is licensed-variable
|
||||
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
|
||||
// Verify parent is licensed-variable using DB-level type check
|
||||
$parentType = \WC_Product_Factory::get_product_type($productId);
|
||||
if ($parentType !== 'licensed-variable') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
282
src/Metrics/PrometheusController.php
Normal file
282
src/Metrics/PrometheusController.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
/**
|
||||
* Prometheus Metrics Controller
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Metrics
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Metrics;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
|
||||
/**
|
||||
* Exposes license and API metrics for Prometheus monitoring
|
||||
*/
|
||||
final class PrometheusController
|
||||
{
|
||||
/**
|
||||
* Option name for storing API counters
|
||||
*/
|
||||
private const COUNTERS_OPTION = 'wclp_prometheus_counters';
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager, VersionManager $versionManager)
|
||||
{
|
||||
$this->licenseManager = $licenseManager;
|
||||
$this->versionManager = $versionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for Prometheus metrics collection
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Only register if metrics are enabled
|
||||
if (!SettingsController::isMetricsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action('wp_prometheus_collect_metrics', [$this, 'collectMetrics']);
|
||||
add_action('wp_prometheus_register_dashboards', [$this, 'registerDashboard']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Grafana dashboard with wp-prometheus
|
||||
*
|
||||
* @param object $provider The dashboard provider object
|
||||
*/
|
||||
public function registerDashboard(object $provider): void
|
||||
{
|
||||
$dashboardFile = WC_LICENSED_PRODUCT_PLUGIN_DIR . 'docs/grafana-dashboard.json';
|
||||
|
||||
if (!file_exists($dashboardFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider->register_dashboard('wc-licensed-product', [
|
||||
'title' => __('WC Licensed Product - License Metrics', 'wc-licensed-product'),
|
||||
'description' => __('Monitor license status, downloads, API usage, and validation errors.', 'wc-licensed-product'),
|
||||
'icon' => 'dashicons-admin-network',
|
||||
'file' => $dashboardFile,
|
||||
'plugin' => 'WC Licensed Product',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and register all metrics
|
||||
*
|
||||
* @param object $collector The Prometheus collector object
|
||||
*/
|
||||
public function collectMetrics(object $collector): void
|
||||
{
|
||||
$this->collectLicenseMetrics($collector);
|
||||
$this->collectDownloadMetrics($collector);
|
||||
$this->collectApiMetrics($collector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect license-related metrics
|
||||
*/
|
||||
private function collectLicenseMetrics(object $collector): void
|
||||
{
|
||||
$stats = $this->licenseManager->getStatistics();
|
||||
|
||||
// License count by status (gauge)
|
||||
$licensesByStatus = $collector->register_gauge(
|
||||
'wclp_licenses_total',
|
||||
'Total number of licenses by status',
|
||||
['status']
|
||||
);
|
||||
|
||||
foreach ($stats['by_status'] as $status => $count) {
|
||||
$licensesByStatus->set($count, [$status]);
|
||||
}
|
||||
|
||||
// Lifetime licenses (gauge)
|
||||
$lifetimeLicenses = $collector->register_gauge(
|
||||
'wclp_licenses_lifetime_total',
|
||||
'Total number of lifetime licenses'
|
||||
);
|
||||
$lifetimeLicenses->set($stats['lifetime']);
|
||||
|
||||
// Expiring licenses (gauge)
|
||||
$expiringLicenses = $collector->register_gauge(
|
||||
'wclp_licenses_expiring_total',
|
||||
'Total number of licenses with expiration date'
|
||||
);
|
||||
$expiringLicenses->set($stats['expiring']);
|
||||
|
||||
// Licenses expiring soon - next 30 days (gauge)
|
||||
$expiringSoon = $collector->register_gauge(
|
||||
'wclp_licenses_expiring_soon',
|
||||
'Licenses expiring within 30 days'
|
||||
);
|
||||
$expiringSoon->set($stats['expiring_soon']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect download-related metrics
|
||||
*/
|
||||
private function collectDownloadMetrics(object $collector): void
|
||||
{
|
||||
$stats = $this->versionManager->getDownloadStatistics();
|
||||
|
||||
// Total downloads (gauge)
|
||||
$totalDownloads = $collector->register_gauge(
|
||||
'wclp_downloads_total',
|
||||
'Total number of file downloads'
|
||||
);
|
||||
$totalDownloads->set($stats['total']);
|
||||
|
||||
// Active versions count (gauge)
|
||||
$activeVersions = $collector->register_gauge(
|
||||
'wclp_versions_active_total',
|
||||
'Total number of active product versions'
|
||||
);
|
||||
$activeVersions->set($this->countActiveVersions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect API-related metrics (counters)
|
||||
*/
|
||||
private function collectApiMetrics(object $collector): void
|
||||
{
|
||||
$counters = $this->getCounters();
|
||||
|
||||
// API requests by endpoint and result (counter)
|
||||
$apiRequests = $collector->register_counter(
|
||||
'wclp_api_requests_total',
|
||||
'Total API requests by endpoint and result',
|
||||
['endpoint', 'result']
|
||||
);
|
||||
|
||||
foreach ($counters['api_requests'] ?? [] as $key => $count) {
|
||||
[$endpoint, $result] = explode(':', $key);
|
||||
$apiRequests->incBy($count, [$endpoint, $result]);
|
||||
}
|
||||
|
||||
// Rate limit exceeded events (counter)
|
||||
$rateLimitExceeded = $collector->register_counter(
|
||||
'wclp_rate_limit_exceeded_total',
|
||||
'Total rate limit exceeded events by endpoint',
|
||||
['endpoint']
|
||||
);
|
||||
|
||||
foreach ($counters['rate_limit'] ?? [] as $endpoint => $count) {
|
||||
$rateLimitExceeded->incBy($count, [$endpoint]);
|
||||
}
|
||||
|
||||
// Validation errors by type (counter)
|
||||
$validationErrors = $collector->register_counter(
|
||||
'wclp_validation_errors_total',
|
||||
'Total validation errors by error type',
|
||||
['error_type']
|
||||
);
|
||||
|
||||
foreach ($counters['validation_errors'] ?? [] as $errorType => $count) {
|
||||
$validationErrors->incBy($count, [$errorType]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count active product versions
|
||||
*/
|
||||
private function countActiveVersions(): int
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$tableName = \Jeremias\WcLicensedProduct\Installer::getVersionsTable();
|
||||
|
||||
return (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$tableName} WHERE is_active = 1"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored counters
|
||||
*/
|
||||
private function getCounters(): array
|
||||
{
|
||||
$counters = get_option(self::COUNTERS_OPTION, []);
|
||||
return is_array($counters) ? $counters : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment an API request counter
|
||||
*
|
||||
* @param string $endpoint The API endpoint (validate, status, activate, update-check)
|
||||
* @param string $result The result (success or error)
|
||||
*/
|
||||
public static function incrementApiRequest(string $endpoint, string $result): void
|
||||
{
|
||||
if (!SettingsController::isMetricsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counters = get_option(self::COUNTERS_OPTION, []);
|
||||
if (!is_array($counters)) {
|
||||
$counters = [];
|
||||
}
|
||||
|
||||
$key = "{$endpoint}:{$result}";
|
||||
$counters['api_requests'][$key] = ($counters['api_requests'][$key] ?? 0) + 1;
|
||||
|
||||
update_option(self::COUNTERS_OPTION, $counters, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment rate limit exceeded counter
|
||||
*
|
||||
* @param string $endpoint The API endpoint
|
||||
*/
|
||||
public static function incrementRateLimitExceeded(string $endpoint): void
|
||||
{
|
||||
if (!SettingsController::isMetricsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counters = get_option(self::COUNTERS_OPTION, []);
|
||||
if (!is_array($counters)) {
|
||||
$counters = [];
|
||||
}
|
||||
|
||||
$counters['rate_limit'][$endpoint] = ($counters['rate_limit'][$endpoint] ?? 0) + 1;
|
||||
|
||||
update_option(self::COUNTERS_OPTION, $counters, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment validation error counter
|
||||
*
|
||||
* @param string $errorType The error type (license_not_found, domain_mismatch, etc.)
|
||||
*/
|
||||
public static function incrementValidationError(string $errorType): void
|
||||
{
|
||||
if (!SettingsController::isMetricsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counters = get_option(self::COUNTERS_OPTION, []);
|
||||
if (!is_array($counters)) {
|
||||
$counters = [];
|
||||
}
|
||||
|
||||
$counters['validation_errors'][$errorType] = ($counters['validation_errors'][$errorType] ?? 0) + 1;
|
||||
|
||||
update_option(self::COUNTERS_OPTION, $counters, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all counters (useful for testing or maintenance)
|
||||
*/
|
||||
public static function resetCounters(): void
|
||||
{
|
||||
delete_option(self::COUNTERS_OPTION);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -25,8 +26,10 @@ use Jeremias\WcLicensedProduct\Frontend\AccountController;
|
||||
use Jeremias\WcLicensedProduct\Frontend\DownloadController;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
@@ -139,12 +142,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 +166,15 @@ 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();
|
||||
}
|
||||
|
||||
// Initialize Prometheus metrics if enabled
|
||||
(new PrometheusController($this->licenseManager, $this->versionManager))->register();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +223,7 @@ final class Plugin
|
||||
|
||||
// Try new multi-domain format first
|
||||
$domainData = $order->get_meta('_licensed_product_domains');
|
||||
|
||||
if (!empty($domainData) && is_array($domainData)) {
|
||||
$this->generateLicensesMultiDomain($order, $domainData);
|
||||
return;
|
||||
@@ -244,7 +258,12 @@ final class Plugin
|
||||
// Generate licenses for each licensed product
|
||||
foreach ($order->get_items() as $item) {
|
||||
$product = $item->get_product();
|
||||
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
|
||||
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->licenseManager->isLicensedProduct($product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -278,12 +297,14 @@ final class Plugin
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,9 +30,12 @@ final class LicensedProductType
|
||||
*/
|
||||
private function registerHooks(): void
|
||||
{
|
||||
// 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']);
|
||||
@@ -46,9 +49,19 @@ final class LicensedProductType
|
||||
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);
|
||||
|
||||
@@ -64,6 +77,15 @@ final class LicensedProductType
|
||||
add_action('admin_footer', [$this, 'addVariableProductScripts']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -76,8 +98,13 @@ final class LicensedProductType
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -86,11 +113,24 @@ final class LicensedProductType
|
||||
return LicensedVariableProduct::class;
|
||||
}
|
||||
// Handle variations of licensed-variable products
|
||||
if ($productType === 'variation') {
|
||||
// Check if parent is licensed-variable
|
||||
// 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) {
|
||||
$parentType = \WC_Product_Factory::get_product_type($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;
|
||||
}
|
||||
@@ -101,15 +141,23 @@ final class LicensedProductType
|
||||
|
||||
/**
|
||||
* 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', '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;
|
||||
}
|
||||
|
||||
@@ -199,35 +247,6 @@ final class LicensedProductType
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function($) {
|
||||
// Show/hide panels based on product type for license settings tab
|
||||
function toggleLicensedProductOptions() {
|
||||
var productType = $('#product-type').val();
|
||||
var isLicensed = productType === 'licensed';
|
||||
var isLicensedVariable = productType === 'licensed-variable';
|
||||
|
||||
if (isLicensed || isLicensedVariable) {
|
||||
// Show license settings tab
|
||||
$('.show_if_licensed').show();
|
||||
$('.show_if_licensed-variable').show();
|
||||
$('.general_options').show();
|
||||
$('.pricing').show();
|
||||
$('.general_tab').show();
|
||||
} else {
|
||||
// Hide license settings tab for other product types
|
||||
$('.show_if_licensed').hide();
|
||||
$('.show_if_licensed-variable').hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Initial state on page load
|
||||
toggleLicensedProductOptions();
|
||||
|
||||
// On product type change
|
||||
$('#product-type').on('change', toggleLicensedProductOptions);
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
@@ -265,26 +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') || $product->is_type('licensed-variable')) {
|
||||
if ($this->isLicensedProductOrVariation($product)) {
|
||||
return true;
|
||||
}
|
||||
// Also handle variations of licensed-variable products
|
||||
if ($product->is_type('variation') && $product->get_parent_id()) {
|
||||
$parent = wc_get_product($product->get_parent_id());
|
||||
if ($parent && $parent->is_type('licensed-variable')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return $isVirtual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue frontend styles for licensed products on single product pages
|
||||
* Enqueue frontend styles and scripts for licensed products on single product pages
|
||||
*/
|
||||
public function enqueueFrontendStyles(): void
|
||||
{
|
||||
@@ -304,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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,40 +442,46 @@ final class LicensedProductType
|
||||
|
||||
/**
|
||||
* Add to cart template for variable licensed products
|
||||
* This mirrors WooCommerce's woocommerce_variable_add_to_cart() function
|
||||
*/
|
||||
public function variableAddToCartTemplate(): void
|
||||
{
|
||||
global $product;
|
||||
|
||||
// Check if product is a variable type (includes LicensedVariableProduct which extends WC_Product_Variable)
|
||||
if (!$product || !$product->is_type('licensed-variable')) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get attributes - ensure we have an array even if no attributes are set
|
||||
$attributes = $product->get_variation_attributes();
|
||||
// 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 no attributes defined, show a message instead of broken form
|
||||
if (empty($attributes)) {
|
||||
echo '<p class="woocommerce-info">' . esc_html__('This product has no variations available.', 'wc-licensed-product') . '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get variations count to determine if we should load them via AJAX
|
||||
$children = $product->get_children();
|
||||
$getVariations = count($children) <= apply_filters('woocommerce_ajax_variation_threshold', 30, $product);
|
||||
|
||||
// Get available variations - ensure we have an array
|
||||
$availableVariations = $getVariations ? $product->get_available_variations() : false;
|
||||
if ($getVariations && !is_array($availableVariations)) {
|
||||
$availableVariations = [];
|
||||
}
|
||||
|
||||
// Get default/selected attributes - ensure we have an array
|
||||
$selectedAttributes = $product->get_default_attributes();
|
||||
if (!is_array($selectedAttributes)) {
|
||||
$selectedAttributes = [];
|
||||
}
|
||||
@@ -518,7 +633,8 @@ final class LicensedProductType
|
||||
}
|
||||
|
||||
/**
|
||||
* Add JavaScript for licensed-variable product type in admin
|
||||
* Add JavaScript for licensed product types in admin
|
||||
* Handles visibility of License Settings tab and Product Versions meta box
|
||||
*/
|
||||
public function addVariableProductScripts(): void
|
||||
{
|
||||
@@ -535,60 +651,63 @@ final class LicensedProductType
|
||||
?>
|
||||
<script type="text/javascript">
|
||||
jQuery(document).ready(function($) {
|
||||
// Show/hide panels based on product type
|
||||
function toggleLicensedVariableOptions() {
|
||||
// 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';
|
||||
|
||||
if (productType === 'licensed-variable') {
|
||||
// Show variable product options
|
||||
// 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();
|
||||
|
||||
// Show licensed product options
|
||||
$('.show_if_licensed-variable').show();
|
||||
$('.show_if_licensed').show();
|
||||
|
||||
// Show general and variations tabs
|
||||
$('.general_tab').show();
|
||||
$('.variations_tab').show();
|
||||
$('.variations_options').show();
|
||||
|
||||
// Hide shipping tab (virtual products)
|
||||
$('.shipping_tab').hide();
|
||||
|
||||
// Ensure the variations panel can be displayed
|
||||
$('#variable_product_options').show();
|
||||
} 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 check
|
||||
toggleLicensedVariableOptions();
|
||||
// Initial setup - run after WooCommerce has initialized
|
||||
setTimeout(toggleOurElements, 10);
|
||||
|
||||
// On product type change
|
||||
// On product type change - run after WooCommerce has processed
|
||||
$('#product-type').on('change', function() {
|
||||
// Use setTimeout to let WooCommerce finish its own processing first
|
||||
setTimeout(toggleLicensedVariableOptions, 100);
|
||||
setTimeout(toggleOurElements, 100);
|
||||
});
|
||||
|
||||
// Re-apply after WooCommerce AJAX operations that may reset visibility
|
||||
$(document).on('woocommerce_variations_loaded', toggleLicensedVariableOptions);
|
||||
$(document).on('woocommerce_variations_added', toggleLicensedVariableOptions);
|
||||
$(document).on('woocommerce_variations_saved', toggleLicensedVariableOptions);
|
||||
|
||||
// Handle AJAX complete events for attribute saving
|
||||
$(document).ajaxComplete(function(event, xhr, settings) {
|
||||
// Check if this was a product data save or attribute action
|
||||
if (settings.data && (
|
||||
settings.data.indexOf('action=woocommerce_save_attributes') !== -1 ||
|
||||
settings.data.indexOf('action=woocommerce_load_variations') !== -1 ||
|
||||
settings.data.indexOf('action=woocommerce_add_variation') !== -1
|
||||
)) {
|
||||
setTimeout(toggleLicensedVariableOptions, 100);
|
||||
}
|
||||
// Re-apply after WooCommerce AJAX operations
|
||||
$(document).on('woocommerce_variations_loaded woocommerce_variations_added woocommerce_variations_saved', function() {
|
||||
setTimeout(toggleOurElements, 10);
|
||||
});
|
||||
|
||||
// Also listen for the WooCommerce product type show/hide trigger
|
||||
$('body').on('woocommerce-product-type-change', toggleLicensedVariableOptions);
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
|
||||
@@ -35,6 +35,61 @@ class LicensedProductVariation extends WC_Product_Variation
|
||||
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
|
||||
|
||||
@@ -41,6 +41,19 @@ class LicensedVariableProduct extends WC_Product_Variable
|
||||
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
|
||||
*/
|
||||
@@ -50,11 +63,197 @@ class LicensedVariableProduct extends WC_Product_Variable
|
||||
}
|
||||
|
||||
/**
|
||||
* Licensed products are purchasable
|
||||
* 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
|
||||
{
|
||||
return $this->exists() && $this->get_price() !== '';
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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,5 +1,53 @@
|
||||
{# 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 package in packages %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: WooCommerce Licensed Product
|
||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
|
||||
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
|
||||
* Version: 0.5.9
|
||||
* Version: 0.7.5
|
||||
* Author: Marco Graetsch
|
||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.5.9');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.7.5');
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user