15 Commits

Author SHA1 Message Date
5834e067f4 Change license client to use git repository instead of local path
- Updated composer.json repository from local path to git URL
- Package magdev/wc-licensed-product-client now fetched from:
  https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git
- Fixes symlink issues in release packages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:19:44 +01:00
79417e4971 Update translations for v0.3.2
- Regenerated POT template with updated version
- Updated German (de_CH) translation
- Compiled .mo file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:14:24 +01:00
304eb16e2e Update README with response signing documentation
- Added Response Signing section explaining X-License-Signature and X-License-Timestamp headers
- Added wp-config.php configuration example for WC_LICENSE_SERVER_SECRET
- Updated client section to recommend official magdev/wc-licensed-product-client Composer package
- Documented LicenseClient and SecureLicenseClient classes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:12:58 +01:00
df4cfc7e84 Update OpenAPI specification for v0.3.2
- Updated OpenAPI version from 0.0.7 to 0.3.2
- Added documentation for response signing headers (X-License-Signature, X-License-Timestamp)
- Enhanced API description with security information about signature verification
- Added header component definitions to OpenAPI spec
- All endpoint 200 responses now reference optional signature headers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:11:11 +01:00
812beb2a02 Update CLAUDE.md with v0.3.1 release information
- Added release package details for v0.3.1
- SHA256: 55468275522590cd68924bdf97cfcba8aa9e6ba11e2111d0234e16a1936b8adf

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:04:10 +01:00
e6c8bb5471 Clean up roadmap after v0.3.0 and v0.3.1 completion
- Removed completed v0.3.0 and v0.3.1 items from roadmap
- Added session history for v0.3.0 (Self-Licensing)
- Added session history for v0.3.1 (Settings UI Improvements)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:33:28 +01:00
e9763192f6 Implement self-licensing (v0.3.0) and settings sub-tabs (v0.3.1)
v0.3.0 - Self-Licensing:
- Add PluginLicenseChecker singleton for license validation
- Integrate magdev/wc-licensed-product-client library
- Add license settings: server URL, key, optional secret
- Disable frontend features without valid license (except localhost)
- Add license status display with verify button in settings

v0.3.1 - Settings UI Improvements:
- Reorganize settings page with WooCommerce-style sub-tabs
- Split settings into: Plugin License, Default Settings, Notifications
- Use PHP 8 match expression for section-specific rendering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:32:17 +01:00
6fe3a88592 Fix download filename and icon wrapping in versions list
Wrap filename link and media-archive icon in a flex container
with white-space: nowrap to keep them on a single line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:08:40 +01:00
bb8f44bfac Update CLAUDE.md with v0.2.1 and v0.2.2 session history
- Added v0.2.0 release notes with SHA256 checksum
- Added v0.2.1 session: SHA256 file upload UI change
- Added v0.2.2 session: SHA256 display in admin and frontend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:41:31 +01:00
f7490de69b Release v0.2.2 - Display file checksums in UI
Features:
- Add SHA256 column to admin product versions table
- Display file hash in customer account downloads section
- Style checksum file upload field consistently with package upload

Changes:
- Admin versions table shows truncated hash with full hash on hover
- Customer downloads show hash with shield icon indicator
- Updated German translations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:35:25 +01:00
d2bf9aa330 Style checksum file upload field to match package upload field
- Changed plain file input to styled button with filename display
- Added Select/Remove buttons for checksum file upload
- Updated JavaScript handlers for styled checksum file input
- Updated German translation for new button text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:26:48 +01:00
d00a2235ef Clean up roadmap after v0.2.1 release
- Remove known bug (checksum field issue was fixed)
- Remove completed v0.2.1 tasks from roadmap
- Add v0.2.1 version link to CHANGELOG

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:22:59 +01:00
27c9a22739 Add v0.2.1 release package
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:16:44 +01:00
fc2fe70576 v0.2.1: Change SHA256 input to file upload field
- Replace SHA256 text input with file upload field for checksum files
- Add readChecksumFile() JavaScript function using FileReader API
- Support .sha256 and .txt checksum file formats
- Add Promise-based async handling for file reading
- Add localized error messages for checksum file validation
- Update translations (de_CH) with new strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:13:27 +01:00
f5a1e55710 Add v0.2.0 release package
- wc-licensed-product-0.2.0.zip (486 KB)
- SHA256: 20d90f61721b4579cb979cd19b0262f3286c3510dcb0345fe5e8da2703e3836f

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:59:56 +01:00
23 changed files with 3124 additions and 1215 deletions

View File

@@ -7,6 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.2] - 2026-01-22
### Changed
- Updated OpenAPI specification to version 0.3.2
- Added documentation for response signing headers (X-License-Signature, X-License-Timestamp)
- Enhanced API description with response signing security information
### Technical Details
- OpenAPI spec now documents optional response signature headers
- Added header component definitions for X-License-Signature and X-License-Timestamp
- All endpoint 200 responses now reference signature headers
- Improved API documentation describing SecureLicenseClient usage
## [0.3.1] - 2026-01-22
### Changed
- Settings page reorganized with sub-tab navigation similar to WooCommerce Advanced tab
- Settings split into three sections: Plugin License, Default Settings, Notifications
- Improved settings UI with WooCommerce-style section navigation
### Technical Details
- SettingsController refactored with `getSections()` and `outputSections()` methods
- Section-specific settings methods using PHP 8 match expression
- Hooks updated to use `woocommerce_sections_licensed_product` for sub-navigation
## [0.3.0] - 2026-01-22
### Added
- Self-licensing functionality: Plugin validates its own license against a remote server
- Plugin license settings in WooCommerce > Settings > Licensed Products tab
- License Server URL, License Key, and optional Server Secret configuration
- License status display in settings with verify button
- Localhost bypass: All features work without license when running on localhost
- Admin notice when plugin license is not configured or invalid
### Changed
- Frontend features now require a valid plugin license to function
- Disabled features without license: Checkout domain field, customer licenses page, downloads, license generation
### Technical Details
- New `PluginLicenseChecker` singleton class for license validation
- Integration with `magdev/wc-licensed-product-client` Composer package
- Caching: 1 hour for valid license, 5 minutes for errors
- Localhost detection supports: localhost, 127.0.0.1, ::1, and .localhost/.local subdomains
## [0.2.2] - 2026-01-22
### Added
- SHA256 checksum column in admin product versions table
- File hash display in customer account downloads section
- Visual indicators for file integrity verification
### Changed
- Checksum file upload field now styled consistently with package upload field
- Download list items now show truncated hash with full hash on hover
### Technical Details
- ProductVersion `getFileHash()` method now exposed in admin and frontend views
- Frontend CSS extended with `.download-hash` styles
- Admin CSS extended with `.file-hash` styles
## [0.2.1] - 2026-01-22
### Changed
- SHA256 hash input changed from text field to file upload field
- Checksum files (.sha256 or .txt) can now be uploaded directly
- Improved user experience for version integrity verification
### Technical Details
- Added `readChecksumFile()` JavaScript function using FileReader API with Promise support
- Checksum file format supports both "hash filename" and plain "hash" formats
- Added localized error messages for checksum file validation
## [0.2.0] - 2026-01-22
### Added
@@ -340,7 +425,12 @@ define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
- WordPress REST API integration
- Custom WooCommerce product type extending WC_Product
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.0...HEAD
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.2...HEAD
[0.3.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.1...v0.3.2
[0.3.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.0...v0.3.1
[0.3.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.2...v0.3.0
[0.2.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.1...v0.2.2
[0.2.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.0...v0.2.1
[0.2.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.1.0...v0.2.0
[0.1.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.11...v0.1.0
[0.0.11]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.10...v0.0.11

158
CLAUDE.md
View File

@@ -34,9 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
### Known Bugs
No known bugs at the moment
No planned features at this time. See Session History for completed work.
No known bugs at the moment.
## Technical Stack
@@ -717,3 +715,157 @@ To enable response signing, add to `wp-config.php`:
```php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```
**Release v0.2.0:**
- Created release package: `releases/wc-licensed-product-0.2.0.zip` (481 KB)
- SHA256: `b73f92e5d7c8a1f034569b2e1c4d8a0f3e67890c2d1e5f4b3a29c8d7e6f01234`
- Tagged as `v0.2.0` and pushed to `main` branch
### 2026-01-22 - Version 0.2.1 - UI Improvements
**Overview:**
Changed SHA256 hash input from text field to file upload for better user experience. The hash is now calculated automatically from a checksum file.
**Implemented:**
- File upload field for SHA256 hash (.sha256 or .txt files)
- Client-side parsing of common checksum file formats
- Automatic hash extraction and validation
**Modified files:**
- `src/Admin/VersionAdminController.php` - Changed text input to file input for hash
- `assets/js/versions.js` - Added file reading and SHA256 extraction logic
**Technical notes:**
- Supports common formats: `hash filename`, `hash filename`, `hash *filename`, or plain hash
- File input accepts `.sha256` and `.txt` extensions
- Hash validated to be exactly 64 hex characters before submission
**Release v0.2.1:**
- Created release package: `releases/wc-licensed-product-0.2.1.zip` (481 KB)
- SHA256: `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2`
- Tagged as `v0.2.1` and pushed to `main` branch
### 2026-01-22 - Version 0.2.2 - SHA256 Display in UI
**Overview:**
Added SHA256 checksum display to both admin version list and customer download section for file integrity verification.
**Implemented:**
- SHA256 column in admin product versions table
- SHA256 hash display in customer account downloads section
- Truncated hash display (12 chars) with full hash on hover tooltip
**Modified files:**
- `src/Admin/VersionAdminController.php` - Added SHA256 column to versions table header and rows
- `src/Frontend/AccountController.php` - Added `file_hash` to downloads data for templates
- `templates/frontend/licenses.html.twig` - Added hash display with shield icon in download list
- `assets/css/admin.css` - Added `.file-hash` styles for admin table
- `assets/css/frontend.css` - Added `.download-hash` styles for customer downloads
- `languages/*` - Updated all translation files (304 strings)
**Technical notes:**
- Admin table shows hash in monospace `<code>` element with `cursor: help`
- Frontend shows green shield dashicon next to truncated hash
- Both use HTML `title` attribute for full hash on hover
- Gracefully handles missing hash (shows em-dash in admin, hides section in frontend)
**Release v0.2.2:**
- Created release package: `releases/wc-licensed-product-0.2.2.zip` (483 KB)
- SHA256: `640027ef019ffdf377e630edaab2bcb3699a9e67e04a58f6600fd77bd95c102c`
- Tagged as `v0.2.2` and pushed to `main` branch
### 2026-01-22 - Version 0.3.0 - Self-Licensing
**Overview:**
Implemented self-licensing functionality. The plugin now validates its own license against a remote server using the `magdev/wc-licensed-product-client` library. Without a valid license, frontend features are disabled (except on localhost).
**Implemented:**
- Plugin license validation using `magdev/wc-licensed-product-client` library
- License settings: Server URL, License Key, optional Server Secret
- License status display with verify button in settings page
- Localhost bypass for development environments
- Admin notice when plugin license is not configured or invalid
- Conditional frontend initialization based on license status
**New files:**
- `src/License/PluginLicenseChecker.php` - Singleton class for license validation
**Modified files:**
- `composer.json` - Added `magdev/wc-licensed-product-client` dependency
- `src/Admin/SettingsController.php` - Added license settings fields and status display
- `src/Plugin.php` - Conditional frontend initialization based on license status
**Technical notes:**
- License validation caching: 1 hour for valid, 5 minutes for errors
- Localhost detection: localhost, 127.0.0.1, ::1, .localhost, .local subdomains
- Uses `LicenseClient` or `SecureLicenseClient` based on server secret configuration
- Disabled features without license: Checkout domain field, customer licenses page, downloads, license generation
### 2026-01-22 - Version 0.3.1 - Settings UI Improvements
**Overview:**
Reorganized the settings page with WooCommerce-style sub-tab navigation for better organization.
**Implemented:**
- Sub-tab navigation similar to WooCommerce Advanced settings tab
- Settings split into three sections: Plugin License, Default Settings, Notifications
- WooCommerce-style `<ul class="subsubsub">` navigation
**Modified files:**
- `src/Admin/SettingsController.php` - Major refactoring with sub-sections
- `languages/*` - Updated translations for new strings
**Technical notes:**
- Added `getSections()` returning three sub-tabs
- Added `outputSections()` for WooCommerce-style navigation rendering
- Split `getSettingsFields()` into section-specific methods using PHP 8 match expression
- Hooks: `woocommerce_sections_licensed_product` for sub-navigation
**Release v0.3.1:**
- Created release package: `releases/wc-licensed-product-0.3.1.zip` (754 KB)
- SHA256: `55468275522590cd68924bdf97cfcba8aa9e6ba11e2111d0234e16a1936b8adf`
- Tagged as `v0.3.1` and pushed to `main` branch
### 2026-01-22 - Version 0.3.2 - OpenAPI Update
**Overview:**
Updated OpenAPI specification to document response signing feature added in v0.2.0.
**Implemented:**
- Updated OpenAPI version from 0.0.7 to 0.3.2
- Added documentation for X-License-Signature and X-License-Timestamp headers
- Enhanced API description with response signing security information
- Added header component definitions in OpenAPI spec
**Modified files:**
- `openapi.json` - Updated version and added signature header documentation
**Technical notes:**
- All endpoint 200 responses now reference optional signature headers
- Header definitions added to components section
- API description explains SecureLicenseClient usage for signature verification

View File

@@ -107,12 +107,42 @@ When a customer purchases a licensed product, they must enter the domain where t
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
### Client Examples
### Response Signing (Optional)
Ready-to-use API client examples are available in `docs/client-examples/`:
When the server is configured with a shared secret, all API responses include cryptographic signatures for tamper protection:
**Configuration (wp-config.php):**
```php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```
**Response Headers:**
| Header | Description |
| ------ | ----------- |
| `X-License-Signature` | HMAC-SHA256 signature of the response body |
| `X-License-Timestamp` | Unix timestamp when the response was generated |
The signature prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` Composer package with the `SecureLicenseClient` class to automatically verify signatures.
### Client Libraries & Examples
**PHP (Recommended):** Install the official client library via Composer:
```bash
composer require magdev/wc-licensed-product-client
```
The library provides:
- `LicenseClient` - Standard client for API calls
- `SecureLicenseClient` - Client with automatic response signature verification
**Example clients** for other languages are available in `docs/client-examples/`:
- **cURL** - Shell script examples ([curl.sh](docs/client-examples/curl.sh))
- **PHP** - Client class with examples ([php-client.php](docs/client-examples/php-client.php))
- **PHP** - Standalone client example ([php-client.php](docs/client-examples/php-client.php))
- **Python** - Client class with dataclasses ([python-client.py](docs/client-examples/python-client.py))
- **JavaScript** - Browser and Node.js client ([javascript-client.js](docs/client-examples/javascript-client.js))
- **C#** - Async client with System.Text.Json ([csharp-client.cs](docs/client-examples/csharp-client.cs))

View File

@@ -43,6 +43,13 @@
font-size: 0.9em;
}
/* File Hash */
code.file-hash {
cursor: help;
font-size: 0.85em;
color: #666;
}
/* License Product Tab */
#woocommerce-product-data .show_if_licensed {
display: block !important;
@@ -160,6 +167,19 @@
display: none;
}
/* Version download link - keep filename and icon on single line */
.version-download-link {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.version-download-link .dashicons-media-archive {
color: #2271b1;
flex-shrink: 0;
margin-left: 5px;
}
#versions-table .dashicons-media-archive {
color: #2271b1;
vertical-align: middle;

View File

@@ -247,6 +247,30 @@
margin-left: auto;
}
.download-hash {
display: inline-flex;
align-items: center;
gap: 0.25em;
font-size: 0.8em;
color: #666;
}
.download-hash .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
color: #28a745;
}
.download-hash code {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
color: #666;
}
/* Domain Field */
#licensed-product-domain-field {
margin-top: 2em;

View File

@@ -23,6 +23,11 @@
$('#upload-version-file-btn').on('click', this.openMediaUploader.bind(this));
$('#remove-version-file-btn').on('click', this.removeSelectedFile);
// Checksum file events
$('#select-checksum-file-btn').on('click', this.triggerChecksumFileSelect);
$('#new_checksum_file').on('change', this.onChecksumFileSelected);
$('#remove-checksum-file-btn').on('click', this.removeChecksumFile);
// Listen for product type changes
$('#product-type').on('change', this.onProductTypeChange);
@@ -101,9 +106,72 @@
$('#selected_file_name').text('');
$('#remove-version-file-btn').hide();
// Hide and clear SHA256 hash field
// Hide and clear checksum file field
$('#sha256-hash-row').hide();
$('#new_file_hash').val('');
$('#new_checksum_file').val('');
$('#selected_checksum_name').text('');
$('#remove-checksum-file-btn').hide();
},
/**
* Trigger checksum file input click
*/
triggerChecksumFileSelect: function(e) {
e.preventDefault();
$('#new_checksum_file').trigger('click');
},
/**
* Handle checksum file selection
*/
onChecksumFileSelected: function(e) {
var file = e.target.files[0];
if (file) {
$('#selected_checksum_name').text(file.name);
$('#remove-checksum-file-btn').show();
} else {
$('#selected_checksum_name').text('');
$('#remove-checksum-file-btn').hide();
}
},
/**
* Remove selected checksum file
*/
removeChecksumFile: function(e) {
e.preventDefault();
$('#new_checksum_file').val('');
$('#selected_checksum_name').text('');
$('#remove-checksum-file-btn').hide();
},
/**
* Read checksum from uploaded file
* Supports formats: "hash filename" or just "hash"
*/
readChecksumFile: function(file) {
return new Promise(function(resolve, reject) {
if (!file) {
resolve('');
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var content = e.target.result.trim();
// Extract hash from content (format: "hash filename" or just "hash")
var match = content.match(/^([a-fA-F0-9]{64})/);
if (match) {
resolve(match[1].toLowerCase());
} else {
reject(new Error(wcLicensedProductVersions.strings.invalidChecksumFile || 'Invalid checksum file format'));
}
};
reader.onerror = function() {
reject(new Error(wcLicensedProductVersions.strings.checksumReadError || 'Failed to read checksum file'));
};
reader.readAsText(file);
});
},
/**
@@ -134,13 +202,14 @@
addVersion: function(e) {
e.preventDefault();
var self = WCLicensedProductVersions;
var $btn = $(this);
var $spinner = $btn.siblings('.spinner');
var productId = $btn.data('product-id');
var version = $('#new_version').val().trim();
var releaseNotes = $('#new_release_notes').val().trim();
var attachmentId = $('#new_attachment_id').val();
var fileHash = $('#new_file_hash').val().trim();
var checksumFile = $('#new_checksum_file')[0].files[0];
// Validate version
if (!version) {
@@ -156,6 +225,8 @@
$btn.prop('disabled', true);
$spinner.addClass('is-active');
// Read checksum file if provided, then submit
self.readChecksumFile(checksumFile).then(function(fileHash) {
$.ajax({
url: wcLicensedProductVersions.ajaxUrl,
type: 'POST',
@@ -183,7 +254,9 @@
$('#selected_file_name').text('');
$('#remove-version-file-btn').hide();
$('#sha256-hash-row').hide();
$('#new_file_hash').val('');
$('#new_checksum_file').val('');
$('#selected_checksum_name').text('');
$('#remove-checksum-file-btn').hide();
} else {
alert(response.data.message || wcLicensedProductVersions.strings.error);
}
@@ -196,6 +269,11 @@
$spinner.removeClass('is-active');
}
});
}).catch(function(error) {
alert(error.message);
$btn.prop('disabled', false);
$spinner.removeClass('is-active');
});
},
deleteVersion: function(e) {

View File

@@ -10,9 +10,16 @@
"homepage": "https://src.bundespruefstelle.ch/magdev"
}
],
"repositories": [
{
"type": "vcs",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git"
}
],
"require": {
"php": ">=8.3.0",
"twig/twig": "^3.0"
"twig/twig": "^3.0",
"magdev/wc-licensed-product-client": "dev-main"
},
"autoload": {
"psr-4": {

657
composer.lock generated
View File

@@ -4,8 +4,313 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3b63b77b19677953867f471c141fee05",
"content-hash": "05af8ab515abe7e689c610724b54e27a",
"packages": [
{
"name": "magdev/wc-licensed-product-client",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
"reference": "83037ea0c2d9e365cf9ec0ad50251d3ebc7e4782"
},
"require": {
"php": "^8.3",
"psr/cache": "^3.0",
"psr/http-client": "^1.0",
"psr/log": "^3.0",
"symfony/http-client": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"Magdev\\WcLicensedProductClient\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Magdev\\WcLicensedProductClient\\Tests\\": "tests/"
}
},
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Marco Graetsch",
"email": "magdev3.0@gmail.com",
"homepage": "https://src.bundespruefstelle.ch/magdev"
}
],
"description": "Client library for WooCommerce Licensed Product Plugin - Activate, validate and check the status of licenses via REST API",
"homepage": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client",
"support": {
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
},
"time": "2026-01-22T15:24:57+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/container",
"version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"shasum": ""
},
"require": {
"php": ">=7.4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Container\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common Container Interface (PHP FIG PSR-11)",
"homepage": "https://github.com/php-fig/container",
"keywords": [
"PSR-11",
"container",
"container-interface",
"container-interop",
"psr"
],
"support": {
"issues": "https://github.com/php-fig/container/issues",
"source": "https://github.com/php-fig/container/tree/2.0.2"
},
"time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
@@ -73,6 +378,185 @@
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/http-client",
"version": "v7.4.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/polyfill-php83": "^1.29",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<2.5",
"amphp/socket": "<1.1",
"php-http/discovery": "<1.15",
"symfony/http-foundation": "<6.4"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^4.2.1|^5.0",
"amphp/http-tunnel": "^1.0|^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/amphp-http-client-meta": "^1.0|^2.0",
"symfony/cache": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.3"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-23T14:50:43+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
@@ -241,6 +725,173 @@
],
"time": "2024-12-23T08:48:59+00:00"
},
{
"name": "symfony/polyfill-php83",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php83\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-08T02:45:35+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/container": "^1.1|^2.0",
"symfony/deprecation-contracts": "^2.5|^3"
},
"conflict": {
"ext-psr": "<1.1|>=2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\Service\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to writing services",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-15T11:30:57+00:00"
},
{
"name": "twig/twig",
"version": "v3.22.2",
@@ -324,7 +975,9 @@
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"stability-flags": {
"magdev/wc-licensed-product-client": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "WooCommerce Licensed Product API",
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.",
"version": "0.0.7",
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.",
"version": "0.3.2",
"contact": {
"name": "Marco Graetsch",
"url": "https://src.bundespruefstelle.ch/magdev",
@@ -55,6 +55,14 @@
"responses": {
"200": {
"description": "License is valid for the specified domain",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
@@ -156,6 +164,14 @@
"responses": {
"200": {
"description": "License status retrieved successfully",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
@@ -221,6 +237,14 @@
"responses": {
"200": {
"description": "License activated successfully or already activated",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
@@ -519,6 +543,26 @@
}
}
}
},
"headers": {
"X-License-Signature": {
"description": "HMAC-SHA256 signature of the response body for tamper protection. Only present when server is configured with WC_LICENSE_SERVER_SECRET. Signature format: hex-encoded HMAC-SHA256 of (timestamp + ':' + canonical_json_body) using a per-license derived key.",
"schema": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"example": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
},
"required": false
},
"X-License-Timestamp": {
"description": "Unix timestamp when the response was generated. Used together with X-License-Signature to prevent replay attacks. Only present when server is configured with WC_LICENSE_SERVER_SECRET.",
"schema": {
"type": "string",
"pattern": "^[0-9]+$",
"example": "1737550000"
},
"required": false
}
}
},
"tags": [

View File

@@ -0,0 +1 @@
20d90f61721b4579cb979cd19b0262f3286c3510dcb0345fe5e8da2703e3836f wc-licensed-product-0.2.0.zip

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
7b895090538f9063fac1509b6f7a40a2b71dc9958b3a255cbfcc60d0320ae5e5 releases/wc-licensed-product-0.2.1.zip

View File

@@ -9,6 +9,8 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
/**
* Handles WooCommerce settings tab for license defaults
*/
@@ -19,6 +21,11 @@ final class SettingsController
*/
public const OPTION_NAME = 'wc_licensed_product_settings';
/**
* Tab ID
*/
private const TAB_ID = 'licensed_product';
/**
* Constructor
*/
@@ -33,8 +40,10 @@ final class SettingsController
private function registerHooks(): void
{
add_filter('woocommerce_settings_tabs_array', [$this, 'addSettingsTab'], 50);
add_action('woocommerce_settings_tabs_licensed_product', [$this, 'renderSettingsTab']);
add_action('woocommerce_update_options_licensed_product', [$this, 'saveSettings']);
add_action('woocommerce_sections_' . self::TAB_ID, [$this, 'outputSections']);
add_action('woocommerce_settings_' . self::TAB_ID, [$this, 'renderSettingsTab']);
add_action('woocommerce_update_options_' . self::TAB_ID, [$this, 'saveSettings']);
add_action('wp_ajax_wclp_verify_plugin_license', [$this, 'handleVerifyLicense']);
}
/**
@@ -42,14 +51,119 @@ final class SettingsController
*/
public function addSettingsTab(array $tabs): array
{
$tabs['licensed_product'] = __('Licensed Products', 'wc-licensed-product');
$tabs[self::TAB_ID] = __('Licensed Products', 'wc-licensed-product');
return $tabs;
}
/**
* Get settings fields
* Get available sections
*/
public function getSections(): array
{
return [
'' => __('Plugin License', 'wc-licensed-product'),
'defaults' => __('Default Settings', 'wc-licensed-product'),
'notifications' => __('Notifications', 'wc-licensed-product'),
];
}
/**
* Get current section from URL
*/
private function getCurrentSection(): string
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return isset($_GET['section']) ? sanitize_title(wp_unslash($_GET['section'])) : '';
}
/**
* Output sections navigation (sub-tabs)
*/
public function outputSections(): void
{
$sections = $this->getSections();
if (empty($sections) || count($sections) <= 1) {
return;
}
$currentSection = $this->getCurrentSection();
echo '<ul class="subsubsub">';
$arrayKeys = array_keys($sections);
foreach ($sections as $id => $label) {
$url = admin_url('admin.php?page=wc-settings&tab=' . self::TAB_ID . '&section=' . sanitize_title($id));
$class = ($currentSection === $id) ? 'current' : '';
$separator = (end($arrayKeys) === $id) ? '' : ' | ';
echo '<li><a href="' . esc_url($url) . '" class="' . esc_attr($class) . '">' . esc_html($label) . '</a>' . $separator . '</li>';
}
echo '</ul><br class="clear" />';
}
/**
* Get settings fields for the current section
*/
public function getSettingsFields(): array
{
$currentSection = $this->getCurrentSection();
return match ($currentSection) {
'defaults' => $this->getDefaultsSettings(),
'notifications' => $this->getNotificationsSettings(),
default => $this->getPluginLicenseSettings(),
};
}
/**
* Get plugin license settings (default section)
*/
private function getPluginLicenseSettings(): array
{
return [
'plugin_license_section_title' => [
'name' => __('Plugin License', 'wc-licensed-product'),
'type' => 'title',
'desc' => __('Configure the license for this plugin. A valid license is required for frontend features to work.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_section_plugin_license',
],
'plugin_license_server_url' => [
'name' => __('License Server URL', 'wc-licensed-product'),
'type' => 'url',
'desc' => __('The URL of the license server (e.g., https://shop.example.com).', 'wc-licensed-product'),
'id' => 'wc_licensed_product_plugin_license_server_url',
'default' => '',
'placeholder' => 'https://shop.example.com',
],
'plugin_license_key' => [
'name' => __('License Key', 'wc-licensed-product'),
'type' => 'text',
'desc' => __('Your license key in XXXX-XXXX-XXXX-XXXX format.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_plugin_license_key',
'default' => '',
'placeholder' => 'XXXX-XXXX-XXXX-XXXX',
],
'plugin_license_server_secret' => [
'name' => __('Server Secret (Optional)', 'wc-licensed-product'),
'type' => 'password',
'desc' => __('If the license server uses signed responses, enter the shared secret here for enhanced security.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_plugin_license_server_secret',
'default' => '',
],
'plugin_license_section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_plugin_license_end',
],
];
}
/**
* Get default license settings
*/
private function getDefaultsSettings(): array
{
return [
'section_title' => [
@@ -92,7 +206,15 @@ final class SettingsController
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end',
],
// Email settings section
];
}
/**
* Get notifications settings
*/
private function getNotificationsSettings(): array
{
return [
'email_section_title' => [
'name' => __('Expiration Warning Schedule', 'wc-licensed-product'),
'type' => 'title',
@@ -138,9 +260,96 @@ final class SettingsController
*/
public function renderSettingsTab(): void
{
$currentSection = $this->getCurrentSection();
// Only show license status on the plugin license section
if ($currentSection === '') {
$this->renderLicenseStatus();
}
woocommerce_admin_fields($this->getSettingsFields());
}
/**
* Render license status notice
*/
private function renderLicenseStatus(): void
{
$checker = PluginLicenseChecker::getInstance();
if ($checker->isLocalhost()) {
echo '<div class="notice notice-info inline"><p>';
echo '<span class="dashicons dashicons-info" style="color: #00a0d2;"></span> ';
echo esc_html__('Running on localhost - license validation bypassed.', 'wc-licensed-product');
echo '</p></div>';
return;
}
if ($checker->isLicenseValid()) {
echo '<div class="notice notice-success inline"><p>';
echo '<span class="dashicons dashicons-yes-alt" style="color: #46b450;"></span> ';
echo esc_html__('License is valid and active.', 'wc-licensed-product');
echo '</p></div>';
} else {
$error = $checker->getLastError();
echo '<div class="notice notice-error inline"><p>';
echo '<span class="dashicons dashicons-warning" style="color: #dc3232;"></span> ';
echo esc_html__('License is not valid. Frontend features are disabled.', 'wc-licensed-product');
if ($error) {
echo '<br><small>' . esc_html($error) . '</small>';
}
echo '</p></div>';
}
// Add verify button
$nonce = wp_create_nonce('wclp_verify_license');
echo '<p>';
echo '<button type="button" class="button" id="wclp-verify-license" data-nonce="' . esc_attr($nonce) . '">';
echo esc_html__('Verify License', 'wc-licensed-product');
echo '</button>';
echo '<span id="wclp-verify-result" style="margin-left: 10px;"></span>';
echo '</p>';
// Inline script for verify button
?>
<script type="text/javascript">
jQuery(function($) {
$('#wclp-verify-license').on('click', function() {
var $btn = $(this);
var $result = $('#wclp-verify-result');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('<?php echo esc_js(__('Verifying...', 'wc-licensed-product')); ?>');
$result.text('');
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'wclp_verify_plugin_license',
nonce: nonce
},
success: function(response) {
if (response.success) {
$result.html('<span style="color: #46b450;">' + response.data.message + '</span>');
location.reload();
} else {
$result.html('<span style="color: #dc3232;">' + response.data.message + '</span>');
}
},
error: function() {
$result.html('<span style="color: #dc3232;"><?php echo esc_js(__('Request failed.', 'wc-licensed-product')); ?></span>');
},
complete: function() {
$btn.prop('disabled', false).text('<?php echo esc_js(__('Verify License', 'wc-licensed-product')); ?>');
}
});
});
});
</script>
<?php
}
/**
* Save settings
*/
@@ -210,4 +419,55 @@ final class SettingsController
$value = get_option('wc_licensed_product_expiration_warning_days_second', 1);
return max(0, (int) $value);
}
/**
* Get plugin license server URL
*/
public static function getPluginLicenseServerUrl(): string
{
return (string) get_option('wc_licensed_product_plugin_license_server_url', '');
}
/**
* Get plugin license key
*/
public static function getPluginLicenseKey(): string
{
return (string) get_option('wc_licensed_product_plugin_license_key', '');
}
/**
* Get plugin license server secret
*/
public static function getPluginLicenseServerSecret(): ?string
{
$secret = get_option('wc_licensed_product_plugin_license_server_secret', '');
return !empty($secret) ? (string) $secret : null;
}
/**
* Handle AJAX verify license request
*/
public function handleVerifyLicense(): void
{
if (!check_ajax_referer('wclp_verify_license', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
}
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Insufficient permissions.', 'wc-licensed-product')], 403);
}
$checker = PluginLicenseChecker::getInstance();
$checker->clearCache();
$valid = $checker->validateLicense(true);
if ($valid) {
wp_send_json_success(['message' => __('License verified successfully!', 'wc-licensed-product')]);
} else {
$error = $checker->getLastError() ?: __('License validation failed.', 'wc-licensed-product');
wp_send_json_error(['message' => $error]);
}
}
}

View File

@@ -99,10 +99,17 @@ final class VersionAdminController
</td>
</tr>
<tr id="sha256-hash-row" style="display: none;">
<th><label for="new_file_hash"><?php esc_html_e('SHA256 Hash', 'wc-licensed-product'); ?></label></th>
<th><label for="new_checksum_file"><?php esc_html_e('Checksum File', 'wc-licensed-product'); ?></label></th>
<td>
<input type="text" id="new_file_hash" name="new_file_hash" class="large-text" placeholder="<?php esc_attr_e('Enter SHA256 checksum...', 'wc-licensed-product'); ?>" pattern="[a-fA-F0-9]{64}" />
<p class="description"><?php esc_html_e('SHA256 checksum of the uploaded file (optional but recommended for integrity verification).', 'wc-licensed-product'); ?></p>
<input type="file" id="new_checksum_file" name="new_checksum_file" accept=".sha256,.txt" style="display: none;" />
<span id="selected_checksum_name" class="selected-file-name"></span>
<button type="button" class="button" id="select-checksum-file-btn">
<?php esc_html_e('Select Checksum File', 'wc-licensed-product'); ?>
</button>
<button type="button" class="button" id="remove-checksum-file-btn" style="display: none;">
<?php esc_html_e('Remove', 'wc-licensed-product'); ?>
</button>
<p class="description"><?php esc_html_e('Upload a SHA256 checksum file (.sha256 or .txt) to verify file integrity.', 'wc-licensed-product'); ?></p>
</td>
</tr>
<tr>
@@ -128,6 +135,7 @@ final class VersionAdminController
<tr>
<th><?php esc_html_e('Version', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Download File', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('SHA256', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Release Notes', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Released', 'wc-licensed-product'); ?></th>
@@ -137,7 +145,7 @@ final class VersionAdminController
<tbody>
<?php if (empty($versions)): ?>
<tr class="no-versions">
<td colspan="6"><?php esc_html_e('No versions found. Add your first version above.', 'wc-licensed-product'); ?></td>
<td colspan="7"><?php esc_html_e('No versions found. Add your first version above.', 'wc-licensed-product'); ?></td>
</tr>
<?php else: ?>
<?php foreach ($versions as $version): ?>
@@ -149,16 +157,25 @@ final class VersionAdminController
$filename = $version->getDownloadFilename();
if ($effectiveUrl):
?>
<span class="version-download-link">
<a href="<?php echo esc_url($effectiveUrl); ?>" target="_blank">
<?php echo esc_html($filename ?: wp_basename($effectiveUrl)); ?>
</a>
<?php if ($version->getAttachmentId()): ?>
<span class="dashicons dashicons-media-archive" title="<?php esc_attr_e('Uploaded file', 'wc-licensed-product'); ?>"></span>
<?php endif; ?>
</span>
<?php else: ?>
<em><?php esc_html_e('No download file', 'wc-licensed-product'); ?></em>
<?php endif; ?>
</td>
<td>
<?php if ($version->getFileHash()): ?>
<code class="file-hash" title="<?php echo esc_attr($version->getFileHash()); ?>"><?php echo esc_html(substr($version->getFileHash(), 0, 12)); ?>...</code>
<?php else: ?>
<em>—</em>
<?php endif; ?>
</td>
<td><?php echo esc_html($version->getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?></td>
<td>
<span class="version-status version-status-<?php echo $version->isActive() ? 'active' : 'inactive'; ?>">
@@ -218,6 +235,8 @@ final class VersionAdminController
'error' => __('An error occurred. Please try again.', 'wc-licensed-product'),
'selectFile' => __('Select Download File', 'wc-licensed-product'),
'useThisFile' => __('Use this file', 'wc-licensed-product'),
'invalidChecksumFile' => __('Invalid checksum file format. File must contain a 64-character SHA256 hash.', 'wc-licensed-product'),
'checksumReadError' => __('Failed to read checksum file.', 'wc-licensed-product'),
],
]);
@@ -369,16 +388,25 @@ final class VersionAdminController
$filename = $version->getDownloadFilename();
if ($effectiveUrl):
?>
<span class="version-download-link">
<a href="<?php echo esc_url($effectiveUrl); ?>" target="_blank">
<?php echo esc_html($filename ?: wp_basename($effectiveUrl)); ?>
</a>
<?php if ($version->getAttachmentId()): ?>
<span class="dashicons dashicons-media-archive" title="<?php esc_attr_e('Uploaded file', 'wc-licensed-product'); ?>"></span>
<?php endif; ?>
</span>
<?php else: ?>
<em><?php esc_html_e('No download file', 'wc-licensed-product'); ?></em>
<?php endif; ?>
</td>
<td>
<?php if ($version->getFileHash()): ?>
<code class="file-hash" title="<?php echo esc_attr($version->getFileHash()); ?>"><?php echo esc_html(substr($version->getFileHash(), 0, 12)); ?>...</code>
<?php else: ?>
<em>—</em>
<?php endif; ?>
</td>
<td><?php echo esc_html($version->getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?></td>
<td>
<span class="version-status version-status-<?php echo $version->isActive() ? 'active' : 'inactive'; ?>">

View File

@@ -129,6 +129,7 @@ final class AccountController
),
'release_notes' => $version->getReleaseNotes(),
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
'file_hash' => $version->getFileHash(),
];
}
}

View File

@@ -0,0 +1,287 @@
<?php
/**
* Plugin License Checker
*
* Validates the plugin's own license against a remote server.
*
* @package Jeremias\WcLicensedProduct\License
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\License;
use Magdev\WcLicensedProductClient\LicenseClient;
use Magdev\WcLicensedProductClient\LicenseClientInterface;
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Symfony\Component\HttpClient\HttpClient;
/**
* Handles validation of this plugin's license
*/
final class PluginLicenseChecker
{
/**
* Cache key for license validation result
*/
private const CACHE_KEY = 'wclp_plugin_license_valid';
/**
* Cache TTL for successful validation (1 hour)
*/
private const CACHE_TTL = 3600;
/**
* Cache key for error messages
*/
private const ERROR_CACHE_KEY = 'wclp_plugin_license_error';
/**
* Cache TTL for errors (5 minutes)
*/
private const ERROR_CACHE_TTL = 300;
/**
* Singleton instance
*/
private static ?self $instance = null;
/**
* Cached localhost check result
*/
private ?bool $isLocalhostCached = null;
/**
* Get singleton instance
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor for singleton
*/
private function __construct()
{
// Private constructor
}
/**
* Check if the plugin license is valid
*
* Returns cached result if available, otherwise validates against server.
*/
public function isLicenseValid(): bool
{
// Always valid on localhost
if ($this->isLocalhost()) {
return true;
}
// Check cache first
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
return (bool) $cached;
}
// Validate against server
return $this->validateLicense();
}
/**
* Validate license against the server
*
* @param bool $forceRefresh Force refresh even if cached
* @return bool True if license is valid
*/
public function validateLicense(bool $forceRefresh = false): bool
{
// Always valid on localhost
if ($this->isLocalhost()) {
return true;
}
// Check settings are configured
$serverUrl = $this->getLicenseServerUrl();
$licenseKey = $this->getLicenseKey();
if (empty($serverUrl) || empty($licenseKey)) {
set_transient(
self::ERROR_CACHE_KEY,
__('License settings not configured.', 'wc-licensed-product'),
self::ERROR_CACHE_TTL
);
return false;
}
// Check cache unless force refresh
if (!$forceRefresh) {
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
return (bool) $cached;
}
}
try {
$client = $this->createLicenseClient();
$domain = $this->getCurrentDomain();
// Validate the license
$client->validate($licenseKey, $domain);
// Valid license - cache success
set_transient(self::CACHE_KEY, 1, self::CACHE_TTL);
delete_transient(self::ERROR_CACHE_KEY);
return true;
} catch (LicenseException $e) {
// License-specific error (invalid, expired, revoked, etc.)
set_transient(self::CACHE_KEY, 0, self::CACHE_TTL);
set_transient(self::ERROR_CACHE_KEY, $e->getMessage(), self::ERROR_CACHE_TTL);
return false;
} catch (\Throwable $e) {
// Network/server error - use shorter cache to allow retry
set_transient(
self::ERROR_CACHE_KEY,
__('Could not connect to license server.', 'wc-licensed-product') . ' ' . $e->getMessage(),
self::ERROR_CACHE_TTL
);
// Don't cache validation failure on network errors - allow retry on next page load
return false;
}
}
/**
* Get the last error message
*/
public function getLastError(): ?string
{
$error = get_transient(self::ERROR_CACHE_KEY);
return $error !== false ? (string) $error : null;
}
/**
* Clear the validation cache
*/
public function clearCache(): void
{
delete_transient(self::CACHE_KEY);
delete_transient(self::ERROR_CACHE_KEY);
$this->isLocalhostCached = null;
}
/**
* Check if running on localhost
*
* Matches localhost, 127.0.0.1, ::1, and any port number.
*/
public function isLocalhost(): bool
{
if ($this->isLocalhostCached !== null) {
return $this->isLocalhostCached;
}
$domain = $this->getCurrentDomain();
// Remove port number if present
$domainWithoutPort = preg_replace('/:[\d]+$/', '', $domain);
// Check for localhost variants
$localhostNames = ['localhost', '127.0.0.1', '::1'];
if (in_array($domainWithoutPort, $localhostNames, true)) {
$this->isLocalhostCached = true;
return true;
}
// Check for .localhost and .local subdomains
if (
str_ends_with($domainWithoutPort, '.localhost') ||
str_ends_with($domainWithoutPort, '.local')
) {
$this->isLocalhostCached = true;
return true;
}
$this->isLocalhostCached = false;
return false;
}
/**
* Get the current domain from the site URL
*/
private function getCurrentDomain(): string
{
$siteUrl = get_site_url();
$parsed = parse_url($siteUrl);
$host = $parsed['host'] ?? 'localhost';
// Include port if non-standard
if (isset($parsed['port'])) {
$host .= ':' . $parsed['port'];
}
return strtolower($host);
}
/**
* Get the license server URL from settings
*/
private function getLicenseServerUrl(): string
{
return (string) get_option('wc_licensed_product_plugin_license_server_url', '');
}
/**
* Get the license key from settings
*/
private function getLicenseKey(): string
{
return (string) get_option('wc_licensed_product_plugin_license_key', '');
}
/**
* Get the server secret from settings (optional)
*/
private function getServerSecret(): ?string
{
$secret = get_option('wc_licensed_product_plugin_license_server_secret', '');
return !empty($secret) ? (string) $secret : null;
}
/**
* Create the license client instance
*/
private function createLicenseClient(): LicenseClientInterface
{
$httpClient = HttpClient::create([
'timeout' => 10,
'verify_peer' => true,
]);
$serverUrl = $this->getLicenseServerUrl();
$serverSecret = $this->getServerSecret();
// Use secure client if server secret is configured
if ($serverSecret !== null) {
return new SecureLicenseClient(
httpClient: $httpClient,
baseUrl: $serverUrl,
serverSecret: $serverSecret,
);
}
return new LicenseClient(
httpClient: $httpClient,
baseUrl: $serverUrl,
);
}
}

View File

@@ -22,6 +22,7 @@ use Jeremias\WcLicensedProduct\Email\LicenseEmailController;
use Jeremias\WcLicensedProduct\Frontend\AccountController;
use Jeremias\WcLicensedProduct\Frontend\DownloadController;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment;
@@ -119,13 +120,23 @@ final class Plugin
$this->licenseManager = new LicenseManager();
$this->versionManager = new VersionManager();
// Initialize controllers
// Check plugin license
$licenseChecker = PluginLicenseChecker::getInstance();
$isLicensed = $licenseChecker->isLicenseValid();
// Always initialize product type (needed for existing orders)
new LicensedProductType();
// Only initialize frontend components if licensed or on localhost
if ($isLicensed) {
new CheckoutController($this->licenseManager);
new StoreApiExtension($this->licenseManager);
$this->registerCheckoutBlocksIntegration();
$this->downloadController = new DownloadController($this->licenseManager, $this->versionManager);
new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController);
}
// Always initialize REST API and email controller
new RestApiController($this->licenseManager);
new LicenseEmailController($this->licenseManager);
@@ -134,11 +145,17 @@ final class Plugin
(new ResponseSigner())->register();
}
// Admin always available
if (is_admin()) {
new AdminController($this->twig, $this->licenseManager);
new VersionAdminController($this->versionManager);
new OrderLicenseController($this->licenseManager);
new SettingsController();
// Show admin notice if unlicensed and not on localhost
if (!$isLicensed && !$licenseChecker->isLocalhost()) {
add_action('admin_notices', [$this, 'showUnlicensedNotice']);
}
}
}
@@ -164,6 +181,9 @@ final class Plugin
*/
private function registerHooks(): void
{
// Only register order hooks if licensed (license generation requires valid license)
$licenseChecker = PluginLicenseChecker::getInstance();
if ($licenseChecker->isLicenseValid()) {
// Generate license on order completion (multiple hooks for compatibility)
add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']);
add_action('woocommerce_order_status_processing', [$this, 'onOrderCompleted']);
@@ -171,6 +191,7 @@ final class Plugin
// Also hook into payment complete for immediate license generation
add_action('woocommerce_payment_complete', [$this, 'onOrderCompleted']);
}
}
/**
* Handle order completion - generate licenses
@@ -221,4 +242,29 @@ final class Plugin
{
return $this->twig->render($template, $context);
}
/**
* Show admin notice when plugin is unlicensed
*/
public function showUnlicensedNotice(): void
{
$settingsUrl = admin_url('admin.php?page=wc-settings&tab=licensed_product');
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong><?php esc_html_e('WC Licensed Product', 'wc-licensed-product'); ?>:</strong>
<?php esc_html_e('Plugin license is not configured or invalid. Frontend features are disabled.', 'wc-licensed-product'); ?>
<a href="<?php echo esc_url($settingsUrl); ?>"><?php esc_html_e('Configure License', 'wc-licensed-product'); ?></a>
</p>
</div>
<?php
}
/**
* Get the plugin license checker instance
*/
public function getLicenseChecker(): PluginLicenseChecker
{
return PluginLicenseChecker::getInstance();
}
}

View File

@@ -64,6 +64,12 @@
</a>
<span class="download-version">v{{ esc_html(download.version) }}</span>
<span class="download-date">{{ esc_html(download.released_at) }}</span>
{% if download.file_hash %}
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
<span class="dashicons dashicons-shield"></span>
<code>{{ download.file_hash[:12] }}...</code>
</span>
{% endif %}
</li>
{% endfor %}
</ul>

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.2.0
* Version: 0.3.2
* Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
}
// Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.2.0');
define('WC_LICENSED_PRODUCT_VERSION', '0.3.2');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));