29 Commits

Author SHA1 Message Date
5826c744dc Fix CI/CD workflow to handle existing releases
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
Delete existing release before creating a new one when tag is updated.
This prevents "Release has no Tag" error when recreating tags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:42:22 +01:00
3a81544f30 Update README with auto-updates and development sections
Some checks failed
Create Release Package / build-release (push) Failing after 57s
- Add auto-updates documentation explaining WordPress native update integration
- Add development section with setup instructions and git submodule usage
- Document CI/CD release process for contributors
- Add core features: WordPress Auto-Updates and Automated Releases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:40:04 +01:00
89493aa5b6 Update CLAUDE.md with accurate v0.7.2 CI/CD details
Document the successful automated release workflow including:
- Correct version constraint (*) and symlink handling
- Direct Gitea API calls instead of gitea-release-action
- Correct secret name (SRC_GITEA_TOKEN)
- Workflow completion time (57 seconds)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:37:51 +01:00
46e5b5a1c5 Rewrite workflow to match working reference implementations
All checks were successful
Create Release Package / build-release (push) Successful in 57s
Simplified workflow based on wp-fedistream and wc-tier-and-package-prices
which have working CI/CD pipelines with same project structure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:29:05 +01:00
b89225c6d7 Fix zip packaging directory structure
Some checks failed
Create Release Package / build-release (push) Failing after 1m2s
Copy workspace to temp directory with proper subdirectory name
before creating zip to ensure correct WordPress plugin structure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:25:25 +01:00
0ebd2d0103 Add vendor directory verification and symlink fix
Some checks failed
Create Release Package / build-release (push) Failing after 59s
Explicitly check vendor after composer install and replace
symlink with actual files if needed for proper packaging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:21:12 +01:00
6a10eada8c Disable symlink for path repository in composer
Some checks failed
Create Release Package / build-release (push) Failing after 1m0s
Force Composer to copy files instead of symlink so vendor/
is properly included in release package (lib/ is excluded).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:19:08 +01:00
f4da9e116a Fix SIGPIPE error in package verification
Some checks failed
Create Release Package / build-release (push) Failing after 1m1s
Add || true to suppress exit code 141 from unzip piped to head.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:16:57 +01:00
601a4f6da2 Skip lock file check in composer validation
Some checks failed
Create Release Package / build-release (push) Failing after 1m7s
--no-check-lock: Skip lock file validation (regenerated during install)
--no-check-all: Only validate schema, not warnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:14:52 +01:00
0758caefc7 Fix composer validation for path repository
Some checks failed
Create Release Package / build-release (push) Failing after 1m0s
- Change version constraint from @dev to * for path repository
- Remove --strict from composer validate (path repos can't have proper versions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:10:41 +01:00
bcd3481ea3 Use relative path for submodule URL
Some checks failed
Create Release Package / build-release (push) Failing after 1m3s
Fixes CI/CD failing to clone submodule via HTTPS.
Relative path uses same protocol/auth as parent repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:00:43 +01:00
60fb5cc13c Fix Gitea release workflow to use API directly
Some checks failed
Create Release Package / build-release (push) Has been cancelled
Replace non-existent actions/gitea-release-action with direct
Gitea API calls using curl for release creation and asset upload.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:56:41 +01:00
1dc128a1e5 Bump version to 0.7.2
Some checks failed
Create Release Package / build-release (push) Failing after 4s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:53:30 +01:00
f32758ab28 Add git submodule and Gitea CI/CD pipeline for v0.7.2
- Convert wc-licensed-product-client from Composer VCS to git submodule
- Add Gitea Actions workflow for automated releases on version tags
- Update composer.json to use path repository for submodule
- Workflow includes: submodule checkout, PHP setup, translation compilation,
  version verification, package creation, checksum generation, release upload

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:53:06 +01:00
ac1814cbb0 Add release package for v0.7.1
- Created wc-licensed-product-0.7.1.zip (886 KB)
- SHA256: 6ffd0bdf47395436bbc28a029eff4c6d065f2b5b64c687b96ae36a74c3ee34ef
- Updated CLAUDE.md with release info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:09:25 +01:00
2d6bfa219a Release v0.7.1 - Bug Fixes & Client Compatibility
## Fixed
- CRITICAL: Fixed API Verification Secret not displayed in PHP fallback template
- Response signing now includes /update-check endpoint

## Changed
- Updated magdev/wc-licensed-product-client to v0.2.2
- Updated symfony/http-client to v7.4.5

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:07:23 +01:00
302f2e76ca Update translations for v0.7.1
- Regenerated .pot template with 388 strings
- All German (de_CH) translations up to date
- Compiled .mo file for production

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:06:45 +01:00
5938aaed1b Update documentation for v0.7.0 security features
README.md:
- Added frontend rate limiting info (transfers: 5/hour, downloads: 30/hour)
- Added CSV import limits section (2MB, 1000 rows, 5-min cooldown)
- Added XSS-safe DOM construction to security section
- Added rate limiting and import limits to security best practices

docs/server-implementation.md:
- Updated PHP requirement to 8.3+
- Fixed key derivation to use RFC 5869 hash_hkdf() (v0.5.5 fix)
- Added recursive key sorting for signature generation
- Updated signature algorithm documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:38:59 +01:00
630a5859d3 Update CLAUDE.md with v0.7.0 security documentation
- Updated Security Best Practices section with v0.7.0 security measures
- Cleared Temporary Roadmap (v0.7.0 completed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:35:56 +01:00
36e1fdc20a Add release package for v0.7.0
- Release package: wc-licensed-product-0.7.0.zip (883 KB)
- SHA256: 12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:31:56 +01:00
cbece2f279 Update CLAUDE.md with v0.7.0 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:28:02 +01:00
b50969f701 Release v0.7.0 - Security Hardening
Security Fixes:
- Fixed XSS vulnerability in checkout blocks DOM injection (replaced innerHTML with safe DOM methods)
- Unified IP detection for rate limiting across all API endpoints (new IpDetectionTrait)
- Added rate limiting to license transfers (5/hour) and downloads (30/hour) (new RateLimitTrait)
- Added file size limit (2MB), row limit (1000), and rate limiting to CSV import
- Added JSON decode error handling in StoreApiExtension
- Added license ID validation in frontend.js to prevent selector injection

New Files:
- src/Api/IpDetectionTrait.php - Shared IP detection with proxy support
- src/Common/RateLimitTrait.php - Reusable rate limiting for frontend operations

Breaking Changes:
- None

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:27:08 +01:00
d0af939f5e Update translations for v0.7.0
Added new translatable strings for security features:
- Rate limiting messages for transfers and downloads
- CSV import security limits (file size, row count, rate limit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:25:49 +01:00
c1a337aabe Update CLAUDE.md with v0.6.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:25:34 +01:00
ff0229061d Add checksum file for v0.6.1 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:24:32 +01:00
7bbffa50b4 Release v0.6.1 - UI improvements and bug fixes
- Fix admin license test popup showing empty product field
- Display product name in bold in test license modal
- Split auto-update settings into notification and auto-install options
- Add filter functionality to customer account licenses page
- Update translations (402 strings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:22:45 +01:00
e168b1a44b Update translations for v0.6.1
- Regenerated .pot template with current strings
- All 402 strings translated in German (de_CH)
- Compiled .mo binary file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:21:29 +01:00
eb8818aa81 Update CLAUDE.md with v0.6.0 session history
- Document WordPress auto-update system implementation
- Add /update-check endpoint to REST API table
- Add Update/ directory to project structure
- Add Email/ directory to project structure
- Update temporary roadmap to v0.7.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:17:20 +01:00
fddeda4a80 Add checksum file for v0.6.0 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:15:29 +01:00
35 changed files with 5094 additions and 3581 deletions

View File

@@ -0,0 +1,228 @@
name: Create Release Package
on:
push:
tags:
- 'v*'
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, zip, intl, gettext
tools: composer:v2
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Validate composer.json
run: composer validate --no-check-lock --no-check-all
- name: Install Composer dependencies (production)
run: |
composer config platform.php 8.3.0
composer install --no-dev --optimize-autoloader --no-interaction
- name: Fix vendor symlink
run: |
# If client is a symlink, replace with actual files
if [ -L "vendor/magdev/wc-licensed-product-client" ]; then
echo "Found symlink, replacing with actual files..."
TARGET=$(readlink -f vendor/magdev/wc-licensed-product-client)
rm vendor/magdev/wc-licensed-product-client
cp -r "$TARGET" vendor/magdev/wc-licensed-product-client
fi
ls -la vendor/magdev/
- name: Install gettext
run: apt-get update && apt-get install -y gettext
- name: Compile translations
run: |
for po in languages/*.po; do
if [ -f "$po" ]; then
mo="${po%.po}.mo"
echo "Compiling $po to $mo"
msgfmt -o "$mo" "$po"
fi
done
- name: Verify plugin version matches tag
run: |
PLUGIN_VERSION=$(grep -oP "Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+" wc-licensed-product.php | head -1)
TAG_VERSION=${{ steps.version.outputs.version }}
if [ "$PLUGIN_VERSION" != "$TAG_VERSION" ]; then
echo "Error: Plugin version ($PLUGIN_VERSION) does not match tag version ($TAG_VERSION)"
exit 1
fi
echo "Version verified: $PLUGIN_VERSION"
- name: Create release directory
run: mkdir -p releases
- name: Build release package
run: |
VERSION=${{ steps.version.outputs.version }}
PLUGIN_NAME="wc-licensed-product"
RELEASE_FILE="releases/${PLUGIN_NAME}-${VERSION}.zip"
cd ..
zip -r "${PLUGIN_NAME}/${RELEASE_FILE}" "${PLUGIN_NAME}" \
-x "${PLUGIN_NAME}/.git/*" \
-x "${PLUGIN_NAME}/.gitea/*" \
-x "${PLUGIN_NAME}/.github/*" \
-x "${PLUGIN_NAME}/.vscode/*" \
-x "${PLUGIN_NAME}/.claude/*" \
-x "${PLUGIN_NAME}/CLAUDE.md" \
-x "${PLUGIN_NAME}/wp-core" \
-x "${PLUGIN_NAME}/wp-core/*" \
-x "${PLUGIN_NAME}/wp-plugins" \
-x "${PLUGIN_NAME}/wp-plugins/*" \
-x "${PLUGIN_NAME}/releases/*" \
-x "${PLUGIN_NAME}/composer.lock" \
-x "${PLUGIN_NAME}/*.log" \
-x "${PLUGIN_NAME}/.gitignore" \
-x "${PLUGIN_NAME}/.gitmodules" \
-x "${PLUGIN_NAME}/.editorconfig" \
-x "${PLUGIN_NAME}/phpcs.xml*" \
-x "${PLUGIN_NAME}/phpunit.xml*" \
-x "${PLUGIN_NAME}/tests/*" \
-x "${PLUGIN_NAME}/*.po~" \
-x "${PLUGIN_NAME}/*.bak" \
-x "${PLUGIN_NAME}/lib/*" \
-x "${PLUGIN_NAME}/lib/*/.git/*" \
-x "${PLUGIN_NAME}/vendor/magdev/*/.git/*" \
-x "${PLUGIN_NAME}/vendor/magdev/*/CLAUDE.md" \
-x "*.DS_Store"
cd "${PLUGIN_NAME}"
echo "Created: ${RELEASE_FILE}"
ls -lh "${RELEASE_FILE}"
- name: Generate checksums
run: |
VERSION=${{ steps.version.outputs.version }}
PLUGIN_NAME="wc-licensed-product"
cd releases
sha256sum "${PLUGIN_NAME}-${VERSION}.zip" > "${PLUGIN_NAME}-${VERSION}.zip.sha256"
echo "SHA256:"
cat "${PLUGIN_NAME}-${VERSION}.zip.sha256"
- name: Verify package structure
run: |
set +o pipefail
VERSION=${{ steps.version.outputs.version }}
PLUGIN_NAME="wc-licensed-product"
echo "Package contents (first 50 entries):"
unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | head -50 || true
# Verify main plugin file exists
if unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | grep -q "${PLUGIN_NAME}/${PLUGIN_NAME}.php"; then
echo "Main plugin file: OK"
else
echo "ERROR: Main plugin file not found!"
exit 1
fi
# Verify vendor directory included
if unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | grep -q "${PLUGIN_NAME}/vendor/"; then
echo "Vendor directory: OK"
else
echo "ERROR: Vendor directory not found!"
exit 1
fi
# Verify lib directory excluded
if unzip -l "releases/${PLUGIN_NAME}-${VERSION}.zip" | grep -q "${PLUGIN_NAME}/lib/"; then
echo "WARNING: lib/ directory should be excluded"
else
echo "lib/ excluded: OK"
fi
- name: Extract changelog for release notes
id: changelog
run: |
VERSION=${{ steps.version.outputs.version }}
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | sed '$ d' | tail -n +2)
if [ -z "$NOTES" ]; then
NOTES="Release version ${VERSION}"
fi
echo "$NOTES" > release_notes.txt
echo "Release notes extracted"
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.SRC_GITEA_TOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
TAG_NAME=${{ github.ref_name }}
PLUGIN_NAME="wc-licensed-product"
PRERELEASE="false"
if [[ "$TAG_NAME" == *-* ]]; then
PRERELEASE="true"
fi
BODY=$(cat release_notes.txt)
# Check if release already exists and delete it
EXISTING_RELEASE=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}")
EXISTING_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty')
if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
echo "Deleting existing release ID: $EXISTING_ID"
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${EXISTING_ID}"
echo "Existing release deleted"
fi
# Create release via Gitea API
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${VERSION}\", \"body\": $(echo "$BODY" | jq -Rs .), \"draft\": false, \"prerelease\": ${PRERELEASE}}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" == "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
exit 1
fi
echo "Created release ID: $RELEASE_ID"
# Upload release assets
for file in "releases/${PLUGIN_NAME}-${VERSION}.zip" "releases/${PLUGIN_NAME}-${VERSION}.zip.sha256"; do
if [ -f "$file" ]; then
FILENAME=$(basename "$file")
echo "Uploading $FILENAME..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo "Uploaded $FILENAME"
fi
done
echo "Release created: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG_NAME}"

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "lib/wc-licensed-product-client"]
path = lib/wc-licensed-product-client
url = ../wc-licensed-product-client.git

View File

@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.7.2] - 2026-01-29
### Added
- **Gitea CI/CD Pipeline**: Automated release workflow triggered on version tags
- Automatic package creation with proper WordPress subdirectory structure
- SHA256 checksum generation for package integrity
- Changelog extraction for release notes
- Pre-release detection for hyphenated tags (e.g., `v0.7.2-rc1`)
### Changed
- **Git Submodule Migration**: `magdev/wc-licensed-product-client` is now a git submodule
- Located at `lib/wc-licensed-product-client` instead of being fetched via Composer VCS
- Composer now uses `path` type repository pointing to local submodule
- Improves version control clarity and development workflow
- Symlinked to `vendor/` during `composer install`
### Developer Notes
- New file: `.gitea/workflows/release.yml` for CI/CD automation
- Updated `composer.json`: Repository type changed from `vcs` to `path`
- Created `.gitmodules` for submodule tracking
- Release packages now exclude `lib/` directory (vendor has installed copy)
- Submodule checkout required: `git submodule update --init --recursive`
## [0.7.1] - 2026-01-28
### Fixed
- **CRITICAL:** Fixed API Verification Secret not displayed in PHP fallback template on customer account licenses page
- Response signing now includes `/update-check` endpoint (was missing from signed routes)
### Changed
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.2
- Updated `symfony/http-client` dependency to v7.4.5
### Technical Details
- Added customer secret display to `displayLicensesFallback()` method in `AccountController`
- Added `/update-check` route to `ResponseSigner::shouldSign()` method for consistent signature headers
- Verified server implementation aligns with updated client library documentation
## [0.7.0] - 2026-01-28
### Security
- Fixed XSS vulnerability in checkout blocks DOM fallback injection
- Unified IP detection for rate limiting across all REST API endpoints
- Added rate limiting to license transfers (5 per hour) and downloads (30 per hour)
- Added file size (2MB), row count (1000), and rate limiting to CSV import
- Added JSON decode error handling in Store API extension
- Added jQuery selector sanitization for license ID validation
### Added
- New `IpDetectionTrait` for shared IP detection logic with proxy support
- New `RateLimitTrait` for reusable frontend rate limiting
- New `src/Common/` directory for shared traits
### Changed
- RestApiController now uses IpDetectionTrait instead of inline methods
- UpdateController now uses IpDetectionTrait for consistent rate limiting behind proxies
- AccountController now uses RateLimitTrait for transfer rate limiting
- DownloadController now uses RateLimitTrait for download rate limiting
- Checkout blocks fallback uses safe DOM construction instead of innerHTML
## [0.6.1] - 2026-01-27
### Added
- Filter functionality on customer account licenses page (filter by product or domain)
- Split auto-update settings into two options: "Enable Update Notifications" and "Automatically Install Updates"
- New `isUpdateNotificationEnabled()`, `isAutoInstallEnabled()` static methods in SettingsController
- WordPress auto-update filter integration (`auto_update_plugin`) for automatic installation
### Fixed
- Fixed admin license test popup showing empty product field
- `handleAjaxTestLicense()` now enriches response with product name
- Removed version field from test popup (version_id is only set for version-bound licenses)
### Changed
- Updated `magdev/wc-licensed-product-client` dependency to v0.2.1
- "Automatically Install Updates" is only selectable when "Enable Update Notifications" is enabled
## [0.6.0] - 2026-01-27
### Added
- WordPress-style automatic update system for licensed plugins
- Server-side `/update-check` REST API endpoint for WordPress-compatible update information
- Client-side `PluginUpdateChecker` singleton for WordPress update integration
- New "Auto-Updates" settings subtab with enable/disable and check frequency options
- Secure download authentication via `X-License-Key` header
- Response signing support for tamper-proof update responses
- Configurable cache TTL for update checks (1-168 hours)
### Changed
- Updated OpenAPI specification to version 0.6.0 with `/update-check` endpoint documentation
## [0.5.15] - 2026-01-27 ## [0.5.15] - 2026-01-27
### Fixed ### Fixed

309
CLAUDE.md
View File

@@ -32,9 +32,7 @@ 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.6.0 No pending roadmap items.
*No planned features yet.*
## Technical Stack ## Technical Stack
@@ -55,6 +53,13 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
- 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
@@ -197,11 +202,13 @@ wc-licensed-product/
├── releases/ # Release packages (version 0.1.0+) ├── releases/ # Release packages (version 0.1.0+)
├── src/ ├── src/
│ ├── Admin/ # AdminController - license management UI │ ├── Admin/ # AdminController - license management UI
│ ├── Api/ # RestApiController - license validation endpoints │ ├── Api/ # RestApiController, UpdateController - REST API endpoints
│ ├── Checkout/ # CheckoutController - domain field at checkout │ ├── Checkout/ # CheckoutController - domain field at checkout
│ ├── Email/ # WooCommerce email classes
│ ├── Frontend/ # AccountController - customer licenses page │ ├── Frontend/ # AccountController - customer licenses page
│ ├── License/ # License model and LicenseManager │ ├── License/ # License model and LicenseManager
── Product/ # LicensedProduct type and LicensedProductType ── Product/ # LicensedProduct type and LicensedProductType
│ └── Update/ # PluginUpdateChecker - WordPress auto-update integration
├── templates/ ├── templates/
│ ├── admin/ # Twig templates for admin views │ ├── admin/ # Twig templates for admin views
│ └── frontend/ # Twig templates for customer views │ └── frontend/ # Twig templates for customer views
@@ -219,11 +226,12 @@ Created on plugin activation via `Installer::createTables()`:
Base: `/wp-json/wc-licensed-product/v1/` Base: `/wp-json/wc-licensed-product/v1/`
| Endpoint | Method | Description | | Endpoint | Method | Description |
| ----------- | ------ | ------------------------------- | | --------------- | ------ | ---------------------------------- |
| `/validate` | POST | Validate license key for domain | | `/validate` | POST | Validate license key for domain |
| `/status` | POST | Get license status | | `/status` | POST | Get license status |
| `/activate` | POST | Activate license on domain | | `/activate` | POST | Activate license on domain |
| `/update-check` | POST | Check for plugin updates (v0.6.0+) |
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
@@ -1656,3 +1664,284 @@ Fixed tab rendering bug in WooCommerce product edit page when switching to licen
- Created release package: `releases/wc-licensed-product-0.5.15.zip` (862 KB) - Created release package: `releases/wc-licensed-product-0.5.15.zip` (862 KB)
- SHA256: `47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278` - SHA256: `47407de49bae4c649644af64e87b44b32fb30eeb2d50890ff8c4bbb741059278`
- Committed to `dev` branch - Committed to `dev` branch
### 2026-01-27 - Version 0.6.0 - WordPress Auto-Update System
**Overview:**
Major feature release implementing WordPress-style automatic updates. Licensed plugins can now receive updates through WordPress's native plugin update mechanism by checking against the license server.
**New files:**
- `src/Api/UpdateController.php` - Server-side REST API endpoint for update checks
- `src/Update/PluginUpdateChecker.php` - Client-side singleton for WordPress update integration
**Implemented:**
- Server-side `/update-check` REST API endpoint serving WordPress-compatible update information
- Client-side `PluginUpdateChecker` singleton hooking into WordPress's native update system
- Hooks: `pre_set_site_transient_update_plugins`, `plugins_api`, `http_request_args`
- New "Auto-Updates" settings subtab with enable/disable toggle and check frequency
- Configurable cache TTL for update checks (1-168 hours, default: 12)
- Secure download authentication via `X-License-Key` header
- Response signing support for tamper-proof update responses
**Modified files:**
- `src/Plugin.php` - Added UpdateController and PluginUpdateChecker initialization
- `src/Admin/SettingsController.php` - Added 'auto-updates' section with settings
- `openapi.json` - Documented `/update-check` endpoint with request/response schemas
- `languages/*` - Updated translations for new strings
**Settings Controller Changes:**
- Added `'auto-updates'` to `getSections()` for sub-tab navigation
- New `getAutoUpdatesSettings()` method returning enable/frequency settings
- New static methods: `isAutoUpdateEnabled()`, `getUpdateCheckFrequency()`
**UpdateController API:**
- Endpoint: `POST /wp-json/wc-licensed-product/v1/update-check`
- Request: `license_key`, `domain`, `plugin_slug` (optional), `current_version` (optional)
- Response: `update_available`, `version`, `download_url`, `package`, `changelog`, `tested`, `requires`, `requires_php`, etc.
- License validation before serving update info
- Secure download URL generation using existing DownloadController patterns
**PluginUpdateChecker Features:**
- Singleton pattern with `getInstance()`
- Caching via WordPress transients (`wclp_update_info`)
- Automatic cache clearing on settings save
- Only activates when license server URL is configured and not self-licensing
- `forceUpdateCheck()` method for manual refresh
**Configuration:**
To disable auto-updates programmatically:
```php
define('WC_LICENSE_DISABLE_AUTO_UPDATE', true);
```
**Technical notes:**
- Update checker only registers when `SettingsController::getPluginLicenseServerUrl()` returns a value
- Self-licensing detection prevents circular update checks (via `PluginLicenseChecker::isSelfLicensing()`)
- Download URLs include license key in `X-License-Key` header for server-side verification
- Uses Symfony HttpClient for server requests with 15s timeout
- Cache TTL configurable from 1-168 hours in settings
- OpenAPI spec updated to version 0.6.0 with full `/update-check` documentation
**Release v0.6.0:**
- Created release package: `releases/wc-licensed-product-0.6.0.zip` (1.1 MB)
- SHA256: `171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027`
- Tagged as `v0.6.0` and pushed to `main` branch
### 2026-01-27 - Version 0.6.1 - UI Improvements & Bug Fixes
**Overview:**
Bug fix and improvement release addressing admin license testing, auto-update settings, and customer license filtering.
**Implemented:**
- Filter functionality on customer account licenses page (filter by product or domain)
- Split auto-update settings into "Enable Update Notifications" and "Automatically Install Updates"
- WordPress `auto_update_plugin` filter integration for automatic installation
**Bug Fixes:**
- Fixed admin license test popup showing empty product field
- Removed version field from test popup (version_id is only set for version-bound licenses)
- `handleAjaxTestLicense()` now enriches response with product name
**Modified files:**
- `src/Admin/AdminController.php` - Enriched test license response with product name
- `src/Admin/SettingsController.php` - Split auto-update settings, added static helper methods
- `src/Update/PluginUpdateChecker.php` - Added `auto_update_plugin` filter, use new settings methods
- `src/Frontend/AccountController.php` - Added filter functionality with `applyLicenseFilters()` method
- `templates/frontend/licenses.html.twig` - Added filter form with product and domain dropdowns
- `templates/admin/licenses.html.twig` - Removed version row from test license modal
- `assets/css/frontend.css` - Added responsive styles for filter form
- `languages/*` - Updated all translation files
**New methods in SettingsController:**
- `isUpdateNotificationEnabled()` - Check if update notifications are enabled
- `isAutoInstallEnabled()` - Check if auto-install is enabled (requires notifications enabled)
**New methods in AccountController:**
- `applyLicenseFilters()` - Filter licenses by product ID and/or domain
- `getFilterOptions()` - Get unique products and domains for filter dropdowns
**Technical notes:**
- Filter form uses GET parameters: `filter_product` and `filter_domain`
- Auto-install setting is disabled (greyed out) when update notifications are disabled
- License test popup now only shows Product and Expires fields (version removed)
- Domain filter uses case-insensitive partial matching via `stripos()`
**Dependency Updates:**
- Updated `magdev/wc-licensed-product-client` from v0.2.0 to v0.2.1
### 2026-01-28 - Version 0.7.0 - Security Hardening
**Overview:**
Security-focused release with comprehensive audit and hardening. Performed OWASP Top 10 testing against live shop (shop.magdev.cc) and fixed identified vulnerabilities.
**Security Audit Results:**
- SQL injection: Protected (prepared statements throughout)
- CSRF: Protected (nonce verification on all forms/AJAX)
- Rate limiting: Working (429 responses after ~28 requests)
- Access control: Working (403 for unauthorized access)
- HTTPS: Enforced with proper redirect
- Missing security headers noted: X-Frame-Options, HSTS, CSP (server-level configuration)
**Critical Fixes:**
- **XSS in checkout-blocks.js**: Replaced `innerHTML` template literals with safe DOM construction using `document.createElement()` and `textContent`
- **IP Detection**: UpdateController was using raw `$_SERVER['REMOTE_ADDR']` without proxy support - now uses shared `IpDetectionTrait`
**New Files:**
- `src/Api/IpDetectionTrait.php` - Shared IP detection with proxy header support (Cloudflare, X-Forwarded-For, X-Real-IP)
- `src/Common/RateLimitTrait.php` - Reusable rate limiting for frontend operations
**Security Enhancements:**
- Added rate limiting to license transfers (5/hour per user)
- Added rate limiting to file downloads (30/hour per user)
- Added CSV import limits: 2MB max file size, 1000 max rows, 5-minute cooldown
- Added JSON error handling in StoreApiExtension
- Added license ID validation in frontend.js to prevent selector injection
**Modified Files:**
- `assets/js/checkout-blocks.js` - XSS-safe DOM construction
- `assets/js/frontend.js` - Added `sanitizeForSelector()` helper
- `src/Api/RestApiController.php` - Use IpDetectionTrait, remove duplicate methods
- `src/Api/UpdateController.php` - Use IpDetectionTrait for rate limiting
- `src/Admin/AdminController.php` - CSV import security limits
- `src/Frontend/AccountController.php` - Transfer rate limiting
- `src/Frontend/DownloadController.php` - Download rate limiting
- `src/Checkout/StoreApiExtension.php` - JSON error handling
**Technical Notes:**
- IpDetectionTrait supports `WC_LICENSE_TRUSTED_PROXIES` constant for proxy configuration
- RateLimitTrait uses WordPress transients with user ID-based keys
- CSV import constants: `MAX_IMPORT_FILE_SIZE = 2097152`, `MAX_IMPORT_ROWS = 1000`, `IMPORT_RATE_LIMIT_WINDOW = 300`
**Release v0.7.0:**
- Created release package: `releases/wc-licensed-product-0.7.0.zip` (883 KB)
- SHA256: `12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18`
- Tagged as `v0.7.0` and pushed to `main` branch
### 2026-01-28 - Version 0.7.1 - Bug Fixes & Client Compatibility
**Overview:**
Bug fix release ensuring compatibility with updated `magdev/wc-licensed-product-client` v0.2.2 and fixing API Verification Secret display.
**Bug Fixes:**
- **CRITICAL:** Fixed API Verification Secret not displaying on customer account licenses page when using PHP fallback (Twig unavailable)
- Fixed `/update-check` endpoint responses not being signed (missing from `ResponseSigner::shouldSign()`)
**Dependency Updates:**
- Updated `magdev/wc-licensed-product-client` from `760e1e7` to `56abe8a` (v0.2.2)
- Updated `symfony/http-client` from v7.4.4 to v7.4.5
**Modified files:**
- `src/Frontend/AccountController.php` - Added customer secret display to PHP fallback method `displayLicensesFallback()`
- `src/Api/ResponseSigner.php` - Added `/update-check` to `shouldSign()` method
**Technical notes:**
- PHP fallback template now includes the collapsible API Verification Secret section matching the Twig template
- All four API endpoints (`/validate`, `/status`, `/activate`, `/update-check`) now include signature headers when `WC_LICENSE_SERVER_SECRET` is configured
- Client library v0.2.2 verified compatible with server implementation
**Release v0.7.1:**
- Created release package: `releases/wc-licensed-product-0.7.1.zip` (886 KB)
- SHA256: `6ffd0bdf47395436bbc28a029eff4c6d065f2b5b64c687b96ae36a74c3ee34ef`
- Tagged as `v0.7.1` and pushed to `main` branch
### 2026-01-29 - Version 0.7.2 - Git Submodule & CI/CD Pipeline
**Overview:**
Infrastructure release converting the client library dependency to a git submodule and implementing automated CI/CD releases via Gitea Actions.
**Git Submodule Migration:**
- Converted `magdev/wc-licensed-product-client` from Composer VCS dependency to git submodule
- Submodule located at `lib/wc-licensed-product-client`
- Composer uses `path` type repository pointing to local submodule
- Symlinked to `vendor/magdev/wc-licensed-product-client` during `composer install`
**Gitea CI/CD Pipeline:**
- New workflow at `.gitea/workflows/release.yml`
- Triggers on version tags (`v*`)
- Automated steps:
- Checkout with recursive submodules
- PHP 8.3 setup with required extensions
- Composer dependency installation (production only)
- Translation compilation (`.po` to `.mo`)
- Version verification against plugin header
- Release package creation with proper exclusions
- SHA256 checksum generation
- Package structure verification
- Changelog extraction for release notes
- Gitea release creation with asset upload
- Pre-release detection for hyphenated tags
**New files:**
- `.gitea/workflows/release.yml` - Gitea Actions workflow for automated releases
- `.gitmodules` - Git submodule configuration (created by git)
**Modified files:**
- `composer.json` - Changed repository type from `vcs` to `path`, URL to `lib/wc-licensed-product-client`
- `CHANGELOG.md` - Added v0.7.2 release notes
- `CLAUDE.md` - Removed v0.7.2 from roadmap, added session history
**Package Exclusions:**
Release packages exclude: `.git/`, `.gitea/`, `.gitmodules`, `lib/` (submodule source), `vendor/**/.git`, `tests/`, `CLAUDE.md`, `*.po~`, `wp-core`, `wp-plugins`, `composer.lock`
**Developer Workflow Changes:**
After cloning the repository, developers must now run:
```bash
git submodule update --init --recursive
composer install
```
**Technical notes:**
- Path repository uses `*` version constraint with `symlink: false` option
- CI replaces symlink with actual files via `cp -r` before packaging
- CI uses `actions/checkout@v4` with `submodules: recursive` for proper submodule initialization
- Release creation uses direct Gitea API calls (`/api/v1/repos/.../releases`)
- Requires `SRC_GITEA_TOKEN` secret configured in Gitea repository settings
- Workflow completed successfully: 57 seconds, all checks passed
**Release v0.7.2:**
- Automatically created by Gitea Actions CI/CD pipeline
- Release package: 881 KiB with SHA256 checksum
- First automated release - all future releases will use this workflow

101
README.md
View File

@@ -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
@@ -359,6 +406,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

View File

@@ -37,6 +37,80 @@
color: #383d41; color: #383d41;
} }
/* Filter Form */
.wclp-filter-form {
margin-bottom: 1.5em;
padding: 1em;
background-color: #f8f9fa;
border: 1px solid #e5e5e5;
border-radius: 8px;
}
.wclp-filter-row {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: flex-end;
}
.wclp-filter-field {
display: flex;
flex-direction: column;
gap: 0.3em;
flex: 1;
min-width: 150px;
}
.wclp-filter-field label {
font-size: 0.85em;
font-weight: 600;
color: #666;
}
.wclp-filter-field select {
width: 100%;
padding: 0.5em 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #fff;
font-size: 0.95em;
}
.wclp-filter-field select:focus {
border-color: #0073aa;
outline: none;
box-shadow: 0 0 0 1px #0073aa;
}
.wclp-filter-actions {
display: flex;
gap: 0.5em;
}
.wclp-filter-actions .button {
padding: 0.5em 1em;
font-size: 0.95em;
white-space: nowrap;
}
@media (max-width: 600px) {
.wclp-filter-row {
flex-direction: column;
}
.wclp-filter-field {
min-width: 100%;
}
.wclp-filter-actions {
width: 100%;
}
.wclp-filter-actions .button {
flex: 1;
}
}
/* License Packages */ /* License Packages */
.woocommerce-licenses { .woocommerce-licenses {
display: flex; display: flex;

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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
View File

@@ -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": "5e4b5a970f75d0163c5496581d963a24ade4f276" "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-26T15:54:37+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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
171c8195c586b3b20bac4a806e2d698cdaaf15966e2fd6e1670ec39dac8ab027 releases/wc-licensed-product-0.6.0.zip

View File

@@ -0,0 +1 @@
f1f1cbdfdd6cda7b20cbd2b88ab4697cde38d987e04cda1f52e885d7818d32f5 wc-licensed-product-0.6.1.zip

Binary file not shown.

View File

@@ -0,0 +1 @@
12f8452316e350273003f36bf6d7b7121a7bedc9a6964c3d0732d26318d94c18 wc-licensed-product-0.7.0.zip

Binary file not shown.

View File

@@ -0,0 +1 @@
6ffd0bdf47395436bbc28a029eff4c6d065f2b5b64c687b96ae36a74c3ee34ef wc-licensed-product-0.7.1.zip

View File

@@ -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;
@@ -379,6 +394,19 @@ final class AdminController
// Validate the license using LicenseManager // Validate the license using LicenseManager
$result = $this->licenseManager->validateLicense($licenseKey, $domain); $result = $this->licenseManager->validateLicense($licenseKey, $domain);
// Enrich result with product name for display in the popup
if (!empty($result['valid']) && isset($result['license'])) {
// Get product name
$productId = $result['license']['product_id'] ?? null;
if ($productId) {
$product = wc_get_product($productId);
$result['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
// Flatten expires_at for easier access in JavaScript
$result['expires_at'] = $result['license']['expires_at'] ?? null;
}
wp_send_json_success($result); wp_send_json_success($result);
} }
@@ -640,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) {
@@ -666,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
@@ -673,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
@@ -1605,12 +1669,11 @@ final class AdminController
if (result.valid) { if (result.valid) {
html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>'; html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>';
html += '<table class="widefat striped"><tbody>'; html += '<table class="widefat striped"><tbody>';
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>'; html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></td></tr>';
html += '<tr><th><?php echo esc_js(__('Version', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
if (result.expires_at) { if (result.expires_at) {
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>'; html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
} else { } else {
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></td></tr>'; html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><span class="license-lifetime"><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></span></td></tr>';
} }
html += '</tbody></table>'; html += '</tbody></table>';
} else { } else {
@@ -1688,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');
} }
@@ -1696,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>

View File

@@ -167,6 +167,8 @@ final class SettingsController
*/ */
private function getAutoUpdatesSettings(): array private function getAutoUpdatesSettings(): array
{ {
$autoInstallDisabled = !self::isUpdateNotificationEnabled();
return [ return [
'auto_update_section_title' => [ 'auto_update_section_title' => [
'name' => __('Auto-Updates', 'wc-licensed-product'), 'name' => __('Auto-Updates', 'wc-licensed-product'),
@@ -174,13 +176,23 @@ final class SettingsController
'desc' => __('Configure automatic plugin updates from the license server.', 'wc-licensed-product'), 'desc' => __('Configure automatic plugin updates from the license server.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_section_auto_update', 'id' => 'wc_licensed_product_section_auto_update',
], ],
'plugin_auto_update_enabled' => [ 'update_notification_enabled' => [
'name' => __('Enable Auto-Updates', 'wc-licensed-product'), 'name' => __('Enable Update Notifications', 'wc-licensed-product'),
'type' => 'checkbox', 'type' => 'checkbox',
'desc' => __('Automatically check for and receive plugin updates from the license server.', 'wc-licensed-product'), 'desc' => __('Check for and display available updates from the license server in WordPress admin.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_plugin_auto_update_enabled', 'id' => 'wc_licensed_product_update_notification_enabled',
'default' => 'yes', 'default' => 'yes',
], ],
'plugin_auto_install_enabled' => [
'name' => __('Automatically Install Updates', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => $autoInstallDisabled
? __('Enable "Update Notifications" above to use this option.', 'wc-licensed-product')
: __('Automatically install updates when they become available (requires update notifications enabled).', 'wc-licensed-product'),
'id' => 'wc_licensed_product_plugin_auto_install_enabled',
'default' => 'no',
'custom_attributes' => $autoInstallDisabled ? ['disabled' => 'disabled'] : [],
],
'update_check_frequency' => [ 'update_check_frequency' => [
'name' => __('Check Frequency (Hours)', 'wc-licensed-product'), 'name' => __('Check Frequency (Hours)', 'wc-licensed-product'),
'type' => 'number', 'type' => 'number',
@@ -501,11 +513,32 @@ final class SettingsController
} }
/** /**
* Check if auto-updates are enabled * Check if update notifications are enabled
*/
public static function isUpdateNotificationEnabled(): bool
{
return get_option('wc_licensed_product_update_notification_enabled', 'yes') === 'yes';
}
/**
* Check if auto-updates are enabled (legacy alias for isUpdateNotificationEnabled)
*/ */
public static function isAutoUpdateEnabled(): bool public static function isAutoUpdateEnabled(): bool
{ {
return get_option('wc_licensed_product_plugin_auto_update_enabled', 'yes') === 'yes'; return self::isUpdateNotificationEnabled();
}
/**
* Check if automatic installation of updates is enabled
*/
public static function isAutoInstallEnabled(): bool
{
// Auto-install requires notifications to be enabled first
if (!self::isUpdateNotificationEnabled()) {
return false;
}
return get_option('wc_licensed_product_plugin_auto_install_enabled', 'no') === 'yes';
} }
/** /**

View 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);
}
}

View File

@@ -79,7 +79,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');
} }
/** /**

View File

@@ -19,6 +19,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';
/** /**
@@ -115,154 +116,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
*/ */

View File

@@ -26,6 +26,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 +84,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();

View File

@@ -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);
} }
} }

View 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));
}
}

View File

@@ -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;
@@ -106,23 +109,104 @@ final class AccountController
return; return;
} }
// Get filter parameters from URL
$filterProductId = isset($_GET['filter_product']) ? absint($_GET['filter_product']) : 0;
$filterDomain = isset($_GET['filter_domain']) ? sanitize_text_field(wp_unslash($_GET['filter_domain'])) : '';
$licenses = $this->licenseManager->getLicensesByCustomer($customerId); $licenses = $this->licenseManager->getLicensesByCustomer($customerId);
// Apply filters
$filteredLicenses = $this->applyLicenseFilters($licenses, $filterProductId, $filterDomain);
// Group licenses by product+order into "packages" // Group licenses by product+order into "packages"
$packages = $this->groupLicensesIntoPackages($licenses); $packages = $this->groupLicensesIntoPackages($filteredLicenses);
// Get unique products and domains for filter dropdowns
$filterOptions = $this->getFilterOptions($licenses);
try { try {
echo $this->twig->render('frontend/licenses.html.twig', [ echo $this->twig->render('frontend/licenses.html.twig', [
'packages' => $packages, 'packages' => $packages,
'has_packages' => !empty($packages), 'has_packages' => !empty($packages),
'signing_enabled' => ResponseSigner::isSigningEnabled(), 'signing_enabled' => ResponseSigner::isSigningEnabled(),
'filter_products' => $filterOptions['products'],
'filter_domains' => $filterOptions['domains'],
'current_filter_product' => $filterProductId,
'current_filter_domain' => $filterDomain,
'is_filtered' => $filterProductId > 0 || !empty($filterDomain),
'licenses_url' => wc_get_account_endpoint_url('licenses'),
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
// Fallback to PHP template if Twig fails // Fallback to PHP template if Twig fails
$this->displayLicensesFallback($packages); $this->displayLicensesFallback($packages, $filterOptions, $filterProductId, $filterDomain);
} }
} }
/**
* Apply filters to licenses
*
* @param array $licenses Array of License objects
* @param int $productId Filter by product ID (0 for all)
* @param string $domain Filter by domain (empty for all)
* @return array Filtered array of License objects
*/
private function applyLicenseFilters(array $licenses, int $productId, string $domain): array
{
if ($productId === 0 && empty($domain)) {
return $licenses;
}
return array_filter($licenses, function ($license) use ($productId, $domain) {
// Filter by product
if ($productId > 0 && $license->getProductId() !== $productId) {
return false;
}
// Filter by domain (partial match)
if (!empty($domain) && stripos($license->getDomain(), $domain) === false) {
return false;
}
return true;
});
}
/**
* Get unique filter options from licenses
*
* @param array $licenses Array of License objects
* @return array Array with 'products' and 'domains' keys
*/
private function getFilterOptions(array $licenses): array
{
$products = [];
$domains = [];
foreach ($licenses as $license) {
// Collect unique products
$productId = $license->getProductId();
if (!isset($products[$productId])) {
$product = wc_get_product($productId);
$products[$productId] = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
}
// Collect unique domains
$domain = $license->getDomain();
if (!in_array($domain, $domains, true)) {
$domains[] = $domain;
}
}
// Sort products by name, domains alphabetically
asort($products);
sort($domains);
return [
'products' => $products,
'domains' => $domains,
];
}
/** /**
* Group licenses into packages by product+order * Group licenses into packages by product+order
* *
@@ -217,10 +301,67 @@ final class AccountController
/** /**
* Fallback display method if Twig is unavailable * Fallback display method if Twig is unavailable
*/ */
private function displayLicensesFallback(array $packages): void private function displayLicensesFallback(
{ array $packages,
array $filterOptions = [],
int $currentFilterProduct = 0,
string $currentFilterDomain = ''
): void {
$isFiltered = $currentFilterProduct > 0 || !empty($currentFilterDomain);
$licensesUrl = wc_get_account_endpoint_url('licenses');
// Display filter form if we have filter options
if (!empty($filterOptions['products']) || !empty($filterOptions['domains'])) {
?>
<div class="wclp-filter-form">
<form method="get" action="<?php echo esc_url($licensesUrl); ?>">
<div class="wclp-filter-row">
<?php if (!empty($filterOptions['products'])): ?>
<div class="wclp-filter-field">
<label for="filter_product"><?php esc_html_e('Product', 'wc-licensed-product'); ?></label>
<select name="filter_product" id="filter_product">
<option value=""><?php esc_html_e('All Products', 'wc-licensed-product'); ?></option>
<?php foreach ($filterOptions['products'] as $id => $name): ?>
<option value="<?php echo esc_attr($id); ?>" <?php selected($currentFilterProduct, $id); ?>>
<?php echo esc_html($name); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if (!empty($filterOptions['domains'])): ?>
<div class="wclp-filter-field">
<label for="filter_domain"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></label>
<select name="filter_domain" id="filter_domain">
<option value=""><?php esc_html_e('All Domains', 'wc-licensed-product'); ?></option>
<?php foreach ($filterOptions['domains'] as $domain): ?>
<option value="<?php echo esc_attr($domain); ?>" <?php selected($currentFilterDomain, $domain); ?>>
<?php echo esc_html($domain); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="wclp-filter-actions">
<button type="submit" class="button"><?php esc_html_e('Filter', 'wc-licensed-product'); ?></button>
<?php if ($isFiltered): ?>
<a href="<?php echo esc_url($licensesUrl); ?>" class="button"><?php esc_html_e('Clear', 'wc-licensed-product'); ?></a>
<?php endif; ?>
</div>
</div>
</form>
</div>
<?php
}
if (empty($packages)) { if (empty($packages)) {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>'; if ($isFiltered) {
echo '<p>' . esc_html__('No licenses found matching your filters.', 'wc-licensed-product') . '</p>';
} else {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
}
return; return;
} }
@@ -287,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>
@@ -437,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);

View File

@@ -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) {

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Update; namespace Jeremias\WcLicensedProduct\Update;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker; use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
@@ -74,8 +75,8 @@ final class PluginUpdateChecker
*/ */
public function register(): void public function register(): void
{ {
// Skip if auto-updates are disabled // Skip if update notifications are disabled
if ($this->isAutoUpdateDisabled()) { if ($this->isUpdateNotificationDisabled()) {
return; return;
} }
@@ -88,15 +89,19 @@ final class PluginUpdateChecker
// Add authentication headers to download requests // Add authentication headers to download requests
add_filter('http_request_args', [$this, 'addAuthHeaders'], 10, 2); add_filter('http_request_args', [$this, 'addAuthHeaders'], 10, 2);
// Handle auto-install setting
add_filter('auto_update_plugin', [$this, 'handleAutoInstall'], 10, 2);
// Clear cache on settings save // Clear cache on settings save
add_action('update_option_wc_licensed_product_plugin_license_key', [$this, 'clearCache']); add_action('update_option_wc_licensed_product_plugin_license_key', [$this, 'clearCache']);
add_action('update_option_wc_licensed_product_plugin_license_server_url', [$this, 'clearCache']); add_action('update_option_wc_licensed_product_plugin_license_server_url', [$this, 'clearCache']);
add_action('update_option_wc_licensed_product_update_notification_enabled', [$this, 'clearCache']);
} }
/** /**
* Check if auto-updates are disabled * Check if update notifications are disabled
*/ */
private function isAutoUpdateDisabled(): bool private function isUpdateNotificationDisabled(): bool
{ {
// Check constant // Check constant
if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) { if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) {
@@ -104,8 +109,25 @@ final class PluginUpdateChecker
} }
// Check setting // Check setting
$enabled = get_option('wc_licensed_product_plugin_auto_update_enabled', 'yes'); return !SettingsController::isUpdateNotificationEnabled();
return $enabled !== 'yes'; }
/**
* Handle auto-install setting for WordPress automatic updates
*
* @param bool|null $update The update decision
* @param object $item The plugin update object
* @return bool|null Whether to auto-update this plugin
*/
public function handleAutoInstall($update, $item): ?bool
{
// Only handle our plugin
if (!isset($item->plugin) || $item->plugin !== $this->pluginBasename) {
return $update;
}
// Return true to enable auto-install, false to disable, or null to use default
return SettingsController::isAutoInstallEnabled() ? true : $update;
} }
/** /**

View File

@@ -424,12 +424,11 @@
if (result.valid) { if (result.valid) {
html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>'; html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>';
html += '<table class="widefat striped"><tbody>'; html += '<table class="widefat striped"><tbody>';
html += '<tr><th>{{ __('Product') }}</th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>'; html += '<tr><th>{{ __('Product') }}</th><td><strong>' + escapeHtml(result.product_name || '-') + '</strong></td></tr>';
html += '<tr><th>{{ __('Version') }}</th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
if (result.expires_at) { if (result.expires_at) {
html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>'; html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
} else { } else {
html += '<tr><th>{{ __('Expires') }}</th><td>{{ __('Lifetime') }}</td></tr>'; html += '<tr><th>{{ __('Expires') }}</th><td><span class="license-lifetime">{{ __('Lifetime') }}</span></td></tr>';
} }
html += '</tbody></table>'; html += '</tbody></table>';
} else { } else {

View File

@@ -1,5 +1,53 @@
{# License Filter Form #}
{% if filter_products is defined and filter_products|length > 0 or filter_domains is defined and filter_domains|length > 0 %}
<div class="wclp-filter-form">
<form method="get" action="{{ esc_url(licenses_url) }}">
<div class="wclp-filter-row">
{% if filter_products is defined and filter_products|length > 0 %}
<div class="wclp-filter-field">
<label for="filter_product">{{ __('Product') }}</label>
<select name="filter_product" id="filter_product">
<option value="">{{ __('All Products') }}</option>
{% for id, name in filter_products %}
<option value="{{ id }}" {{ current_filter_product == id ? 'selected' : '' }}>
{{ esc_html(name) }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if filter_domains is defined and filter_domains|length > 0 %}
<div class="wclp-filter-field">
<label for="filter_domain">{{ __('Domain') }}</label>
<select name="filter_domain" id="filter_domain">
<option value="">{{ __('All Domains') }}</option>
{% for domain in filter_domains %}
<option value="{{ esc_attr(domain) }}" {{ current_filter_domain == domain ? 'selected' : '' }}>
{{ esc_html(domain) }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="wclp-filter-actions">
<button type="submit" class="button">{{ __('Filter') }}</button>
{% if is_filtered %}
<a href="{{ esc_url(licenses_url) }}" class="button">{{ __('Clear') }}</a>
{% endif %}
</div>
</div>
</form>
</div>
{% endif %}
{% if not has_packages %} {% if not has_packages %}
<p>{{ __('You have no licenses yet.') }}</p> {% if is_filtered %}
<p>{{ __('No licenses found matching your filters.') }}</p>
{% else %}
<p>{{ __('You have no licenses yet.') }}</p>
{% endif %}
{% else %} {% else %}
<div class="woocommerce-licenses"> <div class="woocommerce-licenses">
{% for package in packages %} {% for package in packages %}

View File

@@ -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.0 * Version: 0.7.2
* 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.0'); define('WC_LICENSED_PRODUCT_VERSION', '0.7.2');
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__));