You've already forked wc-licensed-product
Compare commits
32 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 |
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
|
wp-core
|
||||||
vendor/
|
vendor/
|
||||||
releases/*
|
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
|
||||||
132
CHANGELOG.md
132
CHANGELOG.md
@@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.6.1] - 2026-01-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
289
CLAUDE.md
289
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.
|
**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.7.0
|
### Known Bugs
|
||||||
|
|
||||||
No changes planned at the moment
|
None currently tracked.
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
|
|
||||||
@@ -55,6 +55,13 @@ No changes planned at the moment
|
|||||||
- Nonce verification on form submissions
|
- Nonce verification on form submissions
|
||||||
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
|
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
|
||||||
- Direct file access prevention via `ABSPATH` check
|
- 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
|
### Translation Ready
|
||||||
|
|
||||||
@@ -1782,3 +1789,281 @@ Bug fix and improvement release addressing admin license testing, auto-update se
|
|||||||
**Dependency Updates:**
|
**Dependency Updates:**
|
||||||
|
|
||||||
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1
|
- 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
|
- **Version Binding**: Optional binding to major software versions
|
||||||
- **Expiration Support**: Set license validity periods or lifetime licenses
|
- **Expiration Support**: Set license validity periods or lifetime licenses
|
||||||
- **Rate Limiting**: API endpoints protected with configurable rate limiting (default: 30 requests/minute)
|
- **Rate Limiting**: API endpoints protected with configurable rate limiting (default: 30 requests/minute)
|
||||||
|
- **Frontend Rate Limiting**: Transfer requests (5/hour) and downloads (30/hour) protected against abuse
|
||||||
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
|
- **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+)
|
- **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)
|
- **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
|
### 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)
|
3. Upload a CSV file (supports exported format or simplified format)
|
||||||
4. Choose options: skip header row, update existing licenses
|
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
|
## Security
|
||||||
|
|
||||||
The plugin implements several security best practices:
|
The plugin implements several security best practices:
|
||||||
|
|
||||||
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
|
||||||
- **Output Escaping**: All output is escaped to prevent XSS attacks
|
- **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
|
- **CSRF Protection**: Nonce verification on all forms and AJAX requests
|
||||||
- **SQL Injection Prevention**: All database queries use prepared statements
|
- **SQL Injection Prevention**: All database queries use prepared statements
|
||||||
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
|
||||||
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
|
||||||
- **Response Signing**: Optional HMAC-SHA256 signatures for API tamper protection
|
- **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
|
### Trusted Proxy Configuration
|
||||||
|
|
||||||
@@ -330,6 +342,41 @@ Content-Type: application/json
|
|||||||
| `max_activations_reached` | Maximum activations reached |
|
| `max_activations_reached` | Maximum activations reached |
|
||||||
| `rate_limit_exceeded` | Too many requests (wait and retry) |
|
| `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
|
## License Statuses
|
||||||
|
|
||||||
- **Active**: License is valid and usable
|
- **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
|
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
|
||||||
- **License Expired**: Notification when a license auto-expires
|
- **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
|
## Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
|
See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
|
||||||
@@ -359,6 +438,60 @@ For issues and feature requests, please visit:
|
|||||||
|
|
||||||
Marco Graetsch
|
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
|
## License
|
||||||
|
|
||||||
GPL-2.0-or-later
|
GPL-2.0-or-later
|
||||||
|
|||||||
@@ -367,64 +367,90 @@
|
|||||||
container.className = 'wc-block-components-licensed-product-wrapper';
|
container.className = 'wc-block-components-licensed-product-wrapper';
|
||||||
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
|
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
|
||||||
|
|
||||||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
// Helper function to create elements with text content (XSS-safe)
|
||||||
container.innerHTML = `
|
function createEl(tag, textContent, styles) {
|
||||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
|
var el = document.createElement(tag);
|
||||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
if (textContent) el.textContent = textContent;
|
||||||
${settings.fieldDescription || 'Enter a unique domain for each license.'}
|
if (styles) el.style.cssText = styles;
|
||||||
</p>
|
return el;
|
||||||
${settings.licensedProducts.map(product => {
|
}
|
||||||
const productKey = product.variation_id && product.variation_id > 0
|
|
||||||
? `${product.product_id}_${product.variation_id}`
|
|
||||||
: product.product_id;
|
|
||||||
const durationLabel = product.duration_label || '';
|
|
||||||
const displayName = durationLabel
|
|
||||||
? `${product.name} (${durationLabel})`
|
|
||||||
: product.name;
|
|
||||||
|
|
||||||
return `
|
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
// Build header safely using DOM methods
|
||||||
<strong style="display: block; margin-bottom: 8px;">
|
var header = createEl('h4', settings.sectionTitle || 'License Domains', 'margin: 0 0 8px 0;');
|
||||||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
container.appendChild(header);
|
||||||
</strong>
|
|
||||||
${Array.from({ length: product.quantity }, (_, i) => `
|
var desc = createEl('p', settings.fieldDescription || 'Enter a unique domain for each license.',
|
||||||
<div style="margin-bottom: 8px;">
|
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||||
<label style="display: block; margin-bottom: 4px;">
|
container.appendChild(desc);
|
||||||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
|
||||||
</label>
|
// Build product sections
|
||||||
<input type="text"
|
settings.licensedProducts.forEach(function(product) {
|
||||||
name="licensed_domains[${productKey}][${i}]"
|
var productKey = product.variation_id && product.variation_id > 0
|
||||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
? product.product_id + '_' + product.variation_id
|
||||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
: String(product.product_id);
|
||||||
/>
|
var durationLabel = product.duration_label || '';
|
||||||
${product.variation_id && product.variation_id > 0 ? `
|
var displayName = durationLabel
|
||||||
<input type="hidden"
|
? product.name + ' (' + durationLabel + ')'
|
||||||
name="licensed_variation_ids[${productKey}]"
|
: product.name;
|
||||||
value="${product.variation_id}"
|
|
||||||
/>
|
var productDiv = createEl('div', null, 'margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;');
|
||||||
` : ''}
|
|
||||||
</div>
|
var nameEl = createEl('strong', displayName + (product.quantity > 1 ? ' ×' + product.quantity : ''),
|
||||||
`).join('')}
|
'display: block; margin-bottom: 8px;');
|
||||||
</div>
|
productDiv.appendChild(nameEl);
|
||||||
`}).join('')}
|
|
||||||
`;
|
// 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 {
|
} else {
|
||||||
container.innerHTML = `
|
// Single domain mode - build safely using DOM methods
|
||||||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4>
|
var header = createEl('h4', settings.sectionTitle || 'License Domain', 'margin: 0 0 8px 0;');
|
||||||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
container.appendChild(header);
|
||||||
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
|
|
||||||
</p>
|
var desc = createEl('p', settings.fieldDescription || 'Enter the domain where you will use the license.',
|
||||||
<div style="margin-bottom: 8px;">
|
'margin-bottom: 12px; color: #666; font-size: 0.9em;');
|
||||||
<label style="display: block; margin-bottom: 4px;">
|
container.appendChild(desc);
|
||||||
${settings.singleDomainLabel || 'Domain'}
|
|
||||||
</label>
|
var fieldDiv = createEl('div', null, 'margin-bottom: 8px;');
|
||||||
<input type="text"
|
|
||||||
name="licensed_product_domain"
|
var label = createEl('label', settings.singleDomainLabel || 'Domain', 'display: block; margin-bottom: 4px;');
|
||||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
fieldDiv.appendChild(label);
|
||||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
|
||||||
/>
|
var input = document.createElement('input');
|
||||||
</div>
|
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) {
|
if (contactInfo) {
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
$modal: null,
|
$modal: null,
|
||||||
$form: 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() {
|
init: function() {
|
||||||
this.$modal = $('#wclp-transfer-modal');
|
this.$modal = $('#wclp-transfer-modal');
|
||||||
this.$form = $('#wclp-transfer-form');
|
this.$form = $('#wclp-transfer-form');
|
||||||
@@ -171,6 +179,11 @@
|
|||||||
var licenseId = $btn.data('license-id');
|
var licenseId = $btn.data('license-id');
|
||||||
var currentDomain = $btn.data('current-domain');
|
var currentDomain = $btn.data('current-domain');
|
||||||
|
|
||||||
|
// Validate license ID is numeric
|
||||||
|
if (!licenseId || !/^\d+$/.test(String(licenseId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$('#transfer-license-id').val(licenseId);
|
$('#transfer-license-id').val(licenseId);
|
||||||
$('#transfer-current-domain').text(currentDomain);
|
$('#transfer-current-domain').text(currentDomain);
|
||||||
$('#transfer-new-domain').val('');
|
$('#transfer-new-domain').val('');
|
||||||
@@ -235,9 +248,12 @@
|
|||||||
.removeClass('error').addClass('success').show();
|
.removeClass('error').addClass('success').show();
|
||||||
|
|
||||||
// Update the domain display in the license card
|
// Update the domain display in the license card
|
||||||
var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]');
|
var safeLicenseId = self.sanitizeForSelector(licenseId);
|
||||||
$domainDisplay.find('.domain-value').text(response.data.new_domain);
|
if (safeLicenseId) {
|
||||||
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain);
|
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
|
// Close modal after a short delay
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
|
|||||||
@@ -12,14 +12,17 @@
|
|||||||
],
|
],
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "path",
|
||||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git"
|
"url": "lib/wc-licensed-product-client",
|
||||||
|
"options": {
|
||||||
|
"symlink": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3.0",
|
"php": ">=8.3.0",
|
||||||
"twig/twig": "^3.0",
|
"twig/twig": "^3.0",
|
||||||
"magdev/wc-licensed-product-client": "dev-main"
|
"magdev/wc-licensed-product-client": "*"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "05af8ab515abe7e689c610724b54e27a",
|
"content-hash": "f13b7ed9531068d0180f28adc8a80397",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "magdev/wc-licensed-product-client",
|
"name": "magdev/wc-licensed-product-client",
|
||||||
"version": "dev-main",
|
"version": "dev-main",
|
||||||
"source": {
|
"dist": {
|
||||||
"type": "git",
|
"type": "path",
|
||||||
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
|
"url": "lib/wc-licensed-product-client",
|
||||||
"reference": "760e1e752a0c088fa634cf7ff678e0735ed525a4"
|
"reference": "f9281ec5fb23bf1993ab0240e0347c835009a10f"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"phpunit/phpunit": "^11.0"
|
||||||
},
|
},
|
||||||
"default-branch": true,
|
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -52,7 +51,9 @@
|
|||||||
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
|
||||||
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
|
||||||
},
|
},
|
||||||
"time": "2026-01-27T19:52:12+00:00"
|
"transport-options": {
|
||||||
|
"relative": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/cache",
|
"name": "psr/cache",
|
||||||
@@ -380,16 +381,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client",
|
"name": "symfony/http-client",
|
||||||
"version": "v7.4.4",
|
"version": "v7.4.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-client.git",
|
"url": "https://github.com/symfony/http-client.git",
|
||||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c"
|
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
|
"url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||||
"reference": "d63c23357d74715a589454c141c843f0172bec6c",
|
"reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -457,7 +458,7 @@
|
|||||||
"http"
|
"http"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-client/tree/v7.4.4"
|
"source": "https://github.com/symfony/http-client/tree/v7.4.5"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -477,7 +478,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-01-23T16:34:22+00:00"
|
"time": "2026-01-27T16:16:02+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-client-contracts",
|
"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
|
## Requirements
|
||||||
|
|
||||||
- PHP 7.4+ (8.0+ recommended)
|
- PHP 8.3+
|
||||||
- A server secret stored securely (not in version control)
|
- A server secret stored securely (not in version control)
|
||||||
|
|
||||||
## Server Configuration
|
## Server Configuration
|
||||||
@@ -51,25 +51,33 @@ php -r "echo bin2hex(random_bytes(32));"
|
|||||||
|
|
||||||
### Key Derivation
|
### 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
|
```php
|
||||||
/**
|
/**
|
||||||
* Derive a unique signing key for a license.
|
* Derive a unique signing key for a license.
|
||||||
*
|
*
|
||||||
* @param string $licenseKey The license key
|
* Uses PHP's native hash_hkdf() function per RFC 5869.
|
||||||
* @param string $serverSecret The server's master secret
|
*
|
||||||
* @return string The derived key (hex encoded)
|
* @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
|
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||||
{
|
{
|
||||||
// HKDF-like key derivation
|
// HKDF key derivation per RFC 5869
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
// IKM: server_secret, Length: 32 bytes, Info: license_key
|
||||||
|
return bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey));
|
||||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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
|
### Response Signing
|
||||||
|
|
||||||
Sign every API response before sending:
|
Sign every API response before sending:
|
||||||
@@ -88,8 +96,8 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||||
|
|
||||||
// Sort keys for consistent ordering
|
// Recursively sort keys for consistent ordering (important for nested arrays!)
|
||||||
ksort($responseData);
|
$responseData = recursive_key_sort($responseData);
|
||||||
|
|
||||||
// Build signature payload
|
// Build signature payload
|
||||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$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,
|
'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
|
### WordPress REST API Integration
|
||||||
@@ -214,7 +236,7 @@ class ResponseSigner
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = $this->deriveKey($licenseKey);
|
$signingKey = $this->deriveKey($licenseKey);
|
||||||
|
|
||||||
ksort($data);
|
$data = $this->recursiveKeySort($data);
|
||||||
$payload = $timestamp . ':' . json_encode(
|
$payload = $timestamp . ':' . json_encode(
|
||||||
$data,
|
$data,
|
||||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
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
|
private function deriveKey(string $licenseKey): string
|
||||||
{
|
{
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
// HKDF key derivation per RFC 5869
|
||||||
|
return bin2hex(hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey));
|
||||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +294,8 @@ signature = HMAC-SHA256(
|
|||||||
|
|
||||||
Where:
|
Where:
|
||||||
|
|
||||||
- `derive_signing_key` uses HKDF-like derivation (see above)
|
- `derive_signing_key` uses RFC 5869 HKDF: `hash_hkdf('sha256', server_secret, 32, license_key)`
|
||||||
- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode
|
- `canonical_json` recursively sorts keys alphabetically, no escaping of slashes/unicode
|
||||||
- Result is hex-encoded (64 characters)
|
- Result is hex-encoded (64 characters)
|
||||||
|
|
||||||
## Testing
|
## 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
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
|
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 Environment $twig;
|
||||||
private LicenseManager $licenseManager;
|
private LicenseManager $licenseManager;
|
||||||
|
|
||||||
@@ -653,6 +668,23 @@ final class AdminController
|
|||||||
exit;
|
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
|
// Read the CSV file
|
||||||
$handle = fopen($file['tmp_name'], 'r');
|
$handle = fopen($file['tmp_name'], 'r');
|
||||||
if (!$handle) {
|
if (!$handle) {
|
||||||
@@ -679,6 +711,7 @@ final class AdminController
|
|||||||
$updated = 0;
|
$updated = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
$rowCount = 0;
|
||||||
|
|
||||||
while (($row = fgetcsv($handle)) !== false) {
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
// Skip empty rows
|
// Skip empty rows
|
||||||
@@ -686,6 +719,24 @@ final class AdminController
|
|||||||
continue;
|
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):
|
// 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
|
// 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
|
// For import we need: License Key (or generate), Product ID, Customer ID, Domain, Status, Max Activations, Expires At
|
||||||
@@ -1700,6 +1751,21 @@ final class AdminController
|
|||||||
case 'read':
|
case 'read':
|
||||||
esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product');
|
esc_html_e('Error reading file. Please check the file format.', 'wc-licensed-product');
|
||||||
break;
|
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:
|
default:
|
||||||
esc_html_e('An error occurred during import.', 'wc-licensed-product');
|
esc_html_e('An error occurred during import.', 'wc-licensed-product');
|
||||||
}
|
}
|
||||||
@@ -1708,6 +1774,20 @@ final class AdminController
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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;">
|
<div class="card" style="max-width: 800px; padding: 20px;">
|
||||||
<h2><?php esc_html_e('Import Licenses from CSV', 'wc-licensed-product'); ?></h2>
|
<h2><?php esc_html_e('Import Licenses from CSV', 'wc-licensed-product'); ?></h2>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ final class SettingsController
|
|||||||
'auto-updates' => __('Auto-Updates', 'wc-licensed-product'),
|
'auto-updates' => __('Auto-Updates', 'wc-licensed-product'),
|
||||||
'defaults' => __('Default Settings', 'wc-licensed-product'),
|
'defaults' => __('Default Settings', 'wc-licensed-product'),
|
||||||
'notifications' => __('Notifications', 'wc-licensed-product'),
|
'notifications' => __('Notifications', 'wc-licensed-product'),
|
||||||
|
'metrics' => __('Metrics', 'wc-licensed-product'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ final class SettingsController
|
|||||||
'auto-updates' => $this->getAutoUpdatesSettings(),
|
'auto-updates' => $this->getAutoUpdatesSettings(),
|
||||||
'defaults' => $this->getDefaultsSettings(),
|
'defaults' => $this->getDefaultsSettings(),
|
||||||
'notifications' => $this->getNotificationsSettings(),
|
'notifications' => $this->getNotificationsSettings(),
|
||||||
|
'metrics' => $this->getMetricsSettings(),
|
||||||
default => $this->getPluginLicenseSettings(),
|
default => $this->getPluginLicenseSettings(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -314,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
|
* Render settings tab content
|
||||||
*/
|
*/
|
||||||
@@ -575,4 +603,12 @@ final class SettingsController
|
|||||||
wp_send_json_error(['message' => $error]);
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
$this->serverSecret = self::getServerSecret();
|
||||||
? WC_LICENSE_SERVER_SECRET
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +77,8 @@ final class ResponseSigner
|
|||||||
|
|
||||||
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
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/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
|
public static function getCustomerSecretForLicense(string $licenseKey): ?string
|
||||||
{
|
{
|
||||||
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
|
$serverSecret = self::getServerSecret();
|
||||||
|
|
||||||
if (empty($serverSecret)) {
|
if (empty($serverSecret)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -200,6 +199,40 @@ final class ResponseSigner
|
|||||||
*/
|
*/
|
||||||
public static function isSigningEnabled(): bool
|
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;
|
namespace Jeremias\WcLicensedProduct\Api;
|
||||||
|
|
||||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||||
|
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_REST_Server;
|
use WP_REST_Server;
|
||||||
@@ -19,6 +20,7 @@ use WP_REST_Server;
|
|||||||
*/
|
*/
|
||||||
final class RestApiController
|
final class RestApiController
|
||||||
{
|
{
|
||||||
|
use IpDetectionTrait;
|
||||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +109,10 @@ final class RestApiController
|
|||||||
'retry_after' => $retryAfter,
|
'retry_after' => $retryAfter,
|
||||||
], 429);
|
], 429);
|
||||||
$response->header('Retry-After', (string) $retryAfter);
|
$response->header('Retry-After', (string) $retryAfter);
|
||||||
|
|
||||||
|
// Track rate limit event for metrics
|
||||||
|
PrometheusController::incrementRateLimitExceeded('api');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,154 +121,6 @@ final class RestApiController
|
|||||||
return null;
|
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
|
* Register REST API routes
|
||||||
*/
|
*/
|
||||||
@@ -356,6 +214,16 @@ final class RestApiController
|
|||||||
|
|
||||||
$statusCode = $this->getStatusCodeForResult($result);
|
$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);
|
return new WP_REST_Response($result, $statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +262,9 @@ final class RestApiController
|
|||||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||||
|
|
||||||
if (!$license) {
|
if (!$license) {
|
||||||
|
PrometheusController::incrementApiRequest('status', 'error');
|
||||||
|
PrometheusController::incrementValidationError('license_not_found');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'valid' => false,
|
'valid' => false,
|
||||||
'error' => 'license_not_found',
|
'error' => 'license_not_found',
|
||||||
@@ -401,6 +272,8 @@ final class RestApiController
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrometheusController::incrementApiRequest('status', 'success');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'valid' => $license->isValid(),
|
'valid' => $license->isValid(),
|
||||||
'status' => $license->getStatus(),
|
'status' => $license->getStatus(),
|
||||||
@@ -427,6 +300,9 @@ final class RestApiController
|
|||||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||||
|
|
||||||
if (!$license) {
|
if (!$license) {
|
||||||
|
PrometheusController::incrementApiRequest('activate', 'error');
|
||||||
|
PrometheusController::incrementValidationError('license_not_found');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'license_not_found',
|
'error' => 'license_not_found',
|
||||||
@@ -435,6 +311,9 @@ final class RestApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$license->isValid()) {
|
if (!$license->isValid()) {
|
||||||
|
PrometheusController::incrementApiRequest('activate', 'error');
|
||||||
|
PrometheusController::incrementValidationError('license_invalid');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'license_invalid',
|
'error' => 'license_invalid',
|
||||||
@@ -446,6 +325,8 @@ final class RestApiController
|
|||||||
|
|
||||||
// Check if already activated on this domain
|
// Check if already activated on this domain
|
||||||
if ($license->getDomain() === $normalizedDomain) {
|
if ($license->getDomain() === $normalizedDomain) {
|
||||||
|
PrometheusController::incrementApiRequest('activate', 'success');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => __('License is already activated for this domain.', 'wc-licensed-product'),
|
'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
|
// Check if can activate on another domain
|
||||||
if (!$license->canActivate()) {
|
if (!$license->canActivate()) {
|
||||||
|
PrometheusController::incrementApiRequest('activate', 'error');
|
||||||
|
PrometheusController::incrementValidationError('max_activations_reached');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'max_activations_reached',
|
'error' => 'max_activations_reached',
|
||||||
@@ -465,6 +349,9 @@ final class RestApiController
|
|||||||
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
|
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
|
||||||
|
|
||||||
if (!$success) {
|
if (!$success) {
|
||||||
|
PrometheusController::incrementApiRequest('activate', 'error');
|
||||||
|
PrometheusController::incrementValidationError('activation_failed');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'activation_failed',
|
'error' => 'activation_failed',
|
||||||
@@ -472,6 +359,8 @@ final class RestApiController
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrometheusController::incrementApiRequest('activate', 'success');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => __('License activated successfully.', 'wc-licensed-product'),
|
'message' => __('License activated successfully.', 'wc-licensed-product'),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
|||||||
namespace Jeremias\WcLicensedProduct\Api;
|
namespace Jeremias\WcLicensedProduct\Api;
|
||||||
|
|
||||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||||
|
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
|
||||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||||
use Jeremias\WcLicensedProduct\Product\ProductVersion;
|
use Jeremias\WcLicensedProduct\Product\ProductVersion;
|
||||||
use WP_REST_Request;
|
use WP_REST_Request;
|
||||||
@@ -26,6 +27,7 @@ use WP_REST_Server;
|
|||||||
*/
|
*/
|
||||||
final class UpdateController
|
final class UpdateController
|
||||||
{
|
{
|
||||||
|
use IpDetectionTrait;
|
||||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +85,7 @@ final class UpdateController
|
|||||||
*/
|
*/
|
||||||
private function checkRateLimit(): ?WP_REST_Response
|
private function checkRateLimit(): ?WP_REST_Response
|
||||||
{
|
{
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
$ip = $this->getClientIp();
|
||||||
$transientKey = 'wclp_update_rate_' . md5($ip);
|
$transientKey = 'wclp_update_rate_' . md5($ip);
|
||||||
$rateLimit = $this->getRateLimit();
|
$rateLimit = $this->getRateLimit();
|
||||||
$rateWindow = $this->getRateWindow();
|
$rateWindow = $this->getRateWindow();
|
||||||
@@ -112,6 +114,10 @@ final class UpdateController
|
|||||||
'retry_after' => $retryAfter,
|
'retry_after' => $retryAfter,
|
||||||
], 429);
|
], 429);
|
||||||
$response->header('Retry-After', (string) $retryAfter);
|
$response->header('Retry-After', (string) $retryAfter);
|
||||||
|
|
||||||
|
// Track rate limit event for metrics
|
||||||
|
PrometheusController::incrementRateLimitExceeded('update-check');
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +184,14 @@ final class UpdateController
|
|||||||
$validationResult = $this->licenseManager->validateLicense($licenseKey, $domain);
|
$validationResult = $this->licenseManager->validateLicense($licenseKey, $domain);
|
||||||
|
|
||||||
if (!$validationResult['valid']) {
|
if (!$validationResult['valid']) {
|
||||||
|
$errorType = $validationResult['error'] ?? 'license_invalid';
|
||||||
|
PrometheusController::incrementApiRequest('update-check', 'error');
|
||||||
|
PrometheusController::incrementValidationError($errorType);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'update_available' => false,
|
'update_available' => false,
|
||||||
'error' => $validationResult['error'] ?? 'license_invalid',
|
'error' => $errorType,
|
||||||
'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'),
|
'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'),
|
||||||
], $validationResult['error'] === 'license_not_found' ? 404 : 403);
|
], $validationResult['error'] === 'license_not_found' ? 404 : 403);
|
||||||
}
|
}
|
||||||
@@ -189,6 +199,9 @@ final class UpdateController
|
|||||||
// Get license to access product ID
|
// Get license to access product ID
|
||||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||||
if (!$license) {
|
if (!$license) {
|
||||||
|
PrometheusController::incrementApiRequest('update-check', 'error');
|
||||||
|
PrometheusController::incrementValidationError('license_not_found');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'update_available' => false,
|
'update_available' => false,
|
||||||
@@ -201,6 +214,9 @@ final class UpdateController
|
|||||||
$product = wc_get_product($productId);
|
$product = wc_get_product($productId);
|
||||||
|
|
||||||
if (!$product) {
|
if (!$product) {
|
||||||
|
PrometheusController::incrementApiRequest('update-check', 'error');
|
||||||
|
PrometheusController::incrementValidationError('product_not_found');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'update_available' => false,
|
'update_available' => false,
|
||||||
@@ -213,6 +229,8 @@ final class UpdateController
|
|||||||
$latestVersion = $this->getLatestVersionForLicense($license);
|
$latestVersion = $this->getLatestVersionForLicense($license);
|
||||||
|
|
||||||
if (!$latestVersion) {
|
if (!$latestVersion) {
|
||||||
|
PrometheusController::incrementApiRequest('update-check', 'success');
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'update_available' => false,
|
'update_available' => false,
|
||||||
@@ -229,6 +247,8 @@ final class UpdateController
|
|||||||
// Build response
|
// Build response
|
||||||
$response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable);
|
$response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable);
|
||||||
|
|
||||||
|
PrometheusController::incrementApiRequest('update-check', 'success');
|
||||||
|
|
||||||
return new WP_REST_Response($response);
|
return new WP_REST_Response($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ final class StoreApiExtension
|
|||||||
{
|
{
|
||||||
$requestData = json_decode(file_get_contents('php://input'), true);
|
$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()) {
|
if (SettingsController::isMultiDomainEnabled()) {
|
||||||
$this->processMultiDomainOrder($order, $requestData);
|
$this->processMultiDomainOrder($order, $requestData);
|
||||||
} else {
|
} else {
|
||||||
@@ -270,7 +275,7 @@ final class StoreApiExtension
|
|||||||
// Check for wclp_license_domains (from our hidden input - JSON string)
|
// Check for wclp_license_domains (from our hidden input - JSON string)
|
||||||
if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
|
if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
|
||||||
$parsed = json_decode($requestData['wclp_license_domains'], true);
|
$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);
|
$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;
|
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||||
|
|
||||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||||
|
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
|
||||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
@@ -19,6 +20,8 @@ use Twig\Environment;
|
|||||||
*/
|
*/
|
||||||
final class AccountController
|
final class AccountController
|
||||||
{
|
{
|
||||||
|
use RateLimitTrait;
|
||||||
|
|
||||||
private Environment $twig;
|
private Environment $twig;
|
||||||
private LicenseManager $licenseManager;
|
private LicenseManager $licenseManager;
|
||||||
private VersionManager $versionManager;
|
private VersionManager $versionManager;
|
||||||
@@ -425,6 +428,26 @@ final class AccountController
|
|||||||
?>
|
?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,6 +598,15 @@ final class AccountController
|
|||||||
*/
|
*/
|
||||||
public function handleTransferRequest(): void
|
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
|
// Verify nonce
|
||||||
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
|
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
|
||||||
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
|
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Jeremias\WcLicensedProduct\Frontend;
|
namespace Jeremias\WcLicensedProduct\Frontend;
|
||||||
|
|
||||||
|
use Jeremias\WcLicensedProduct\Common\RateLimitTrait;
|
||||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ use Jeremias\WcLicensedProduct\Product\VersionManager;
|
|||||||
*/
|
*/
|
||||||
final class DownloadController
|
final class DownloadController
|
||||||
{
|
{
|
||||||
|
use RateLimitTrait;
|
||||||
|
|
||||||
private LicenseManager $licenseManager;
|
private LicenseManager $licenseManager;
|
||||||
private VersionManager $versionManager;
|
private VersionManager $versionManager;
|
||||||
|
|
||||||
@@ -110,6 +113,15 @@ final class DownloadController
|
|||||||
exit;
|
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
|
// Get license
|
||||||
$license = $this->licenseManager->getLicenseById($licenseId);
|
$license = $this->licenseManager->getLicenseById($licenseId);
|
||||||
if (!$license) {
|
if (!$license) {
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ use Jeremias\WcLicensedProduct\Frontend\AccountController;
|
|||||||
use Jeremias\WcLicensedProduct\Frontend\DownloadController;
|
use Jeremias\WcLicensedProduct\Frontend\DownloadController;
|
||||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||||
|
use Jeremias\WcLicensedProduct\Metrics\PrometheusController;
|
||||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||||
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
|
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
|
||||||
@@ -147,7 +148,7 @@ final class Plugin
|
|||||||
new LicenseEmailController($this->licenseManager);
|
new LicenseEmailController($this->licenseManager);
|
||||||
|
|
||||||
// Initialize response signing if server secret is configured
|
// Initialize response signing if server secret is configured
|
||||||
if (defined('WC_LICENSE_SERVER_SECRET') && WC_LICENSE_SERVER_SECRET !== '') {
|
if (ResponseSigner::isSigningEnabled()) {
|
||||||
(new ResponseSigner())->register();
|
(new ResponseSigner())->register();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +172,9 @@ final class Plugin
|
|||||||
if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) {
|
if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) {
|
||||||
PluginUpdateChecker::getInstance()->register();
|
PluginUpdateChecker::getInstance()->register();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Prometheus metrics if enabled
|
||||||
|
(new PrometheusController($this->licenseManager, $this->versionManager))->register();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: WooCommerce Licensed Product
|
* Plugin Name: WooCommerce Licensed Product
|
||||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-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.
|
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
|
||||||
* Version: 0.6.1
|
* Version: 0.7.5
|
||||||
* Author: Marco Graetsch
|
* Author: Marco Graetsch
|
||||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plugin constants
|
// Plugin constants
|
||||||
define('WC_LICENSED_PRODUCT_VERSION', '0.6.1');
|
define('WC_LICENSED_PRODUCT_VERSION', '0.7.5');
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user