Implement version 0.0.2 features

Add product version management:
- ProductVersion model and VersionManager class
- VersionAdminController with meta box on product edit page
- AJAX-based version CRUD (add, delete, toggle status)
- JavaScript for version management UI

Add email notifications:
- LicenseEmailController for order emails
- License keys included in order completed emails
- Support for both HTML and plain text emails

Add REST API rate limiting:
- 30 requests per minute per IP
- Cloudflare and proxy-aware IP detection
- HTTP 429 response with Retry-After header

Other changes:
- Bump version to 0.0.2
- Update CHANGELOG.md
- Add version status styles to admin.css

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 19:15:19 +01:00
parent 82c18633a1
commit dec4bd609b
10 changed files with 1269 additions and 4 deletions

View File

@@ -7,7 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.0.1] - 2024-01-21 ## [0.0.2] - 2026-01-21
### Added
- Product version management UI in admin (meta box on product edit page)
- AJAX-based version CRUD operations (add, delete, toggle active status)
- ProductVersion model and VersionManager for version data handling
- Email notifications with license keys on order completion
- License information included in WooCommerce order completed emails
- Rate limiting for REST API endpoints (30 requests/minute per IP)
- Cloudflare and proxy-aware IP detection for rate limiting
- JavaScript for version management interactions
### Changed
- Declared WooCommerce HPOS and cart/checkout blocks compatibility
- Plugin name changed from "WC Licensed Product" to "WooCommerce Licensed Product"
### Technical Details
- New classes: ProductVersion, VersionManager, VersionAdminController, LicenseEmailController
- Rate limiting uses WordPress transients for request counting
- HTTP 429 response with Retry-After header when rate limited
## [0.0.1] - 2026-01-21
### Added ### Added
@@ -44,5 +68,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- WordPress REST API integration - WordPress REST API integration
- Custom WooCommerce product type extending WC_Product - Custom WooCommerce product type extending WC_Product
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...HEAD [Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.2...HEAD
[0.0.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...v0.0.2
[0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1

View File

@@ -89,3 +89,59 @@
.column-license .dashicons-admin-network { .column-license .dashicons-admin-network {
color: #2271b1; color: #2271b1;
} }
/* Version Status Badges */
.version-status {
display: inline-block;
padding: 0.2em 0.5em;
font-size: 0.85em;
font-weight: 500;
line-height: 1.2;
border-radius: 3px;
}
.version-status-active {
background-color: #d4edda;
color: #155724;
}
.version-status-inactive {
background-color: #fff3cd;
color: #856404;
}
/* Version Meta Box */
.wc-licensed-product-versions .versions-add-form {
background: #f9f9f9;
padding: 15px;
border: 1px solid #e5e5e5;
border-radius: 4px;
margin-bottom: 20px;
}
.wc-licensed-product-versions .versions-add-form h4 {
margin-top: 0;
}
.wc-licensed-product-versions .form-table {
margin: 0;
}
.wc-licensed-product-versions .form-table th {
padding: 10px 10px 10px 0;
width: 120px;
}
.wc-licensed-product-versions .form-table td {
padding: 10px 0;
}
#versions-table {
margin-top: 10px;
}
#versions-table .no-versions td {
text-align: center;
font-style: italic;
color: #666;
}

181
assets/js/versions.js Normal file
View File

@@ -0,0 +1,181 @@
/**
* WC Licensed Product - Version Management
*
* @package Jeremias\WcLicensedProduct
*/
(function($) {
'use strict';
var WCLicensedProductVersions = {
init: function() {
this.bindEvents();
},
bindEvents: function() {
$('#add-version-btn').on('click', this.addVersion);
$(document).on('click', '.delete-version-btn', this.deleteVersion);
$(document).on('click', '.toggle-version-btn', this.toggleVersion);
},
addVersion: function(e) {
e.preventDefault();
var $btn = $(this);
var $spinner = $btn.siblings('.spinner');
var productId = $btn.data('product-id');
var version = $('#new_version').val().trim();
var downloadUrl = $('#new_download_url').val().trim();
var releaseNotes = $('#new_release_notes').val().trim();
// Validate version
if (!version) {
alert(wcLicensedProductVersions.strings.versionRequired);
return;
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
alert(wcLicensedProductVersions.strings.versionInvalid);
return;
}
$btn.prop('disabled', true);
$spinner.addClass('is-active');
$.ajax({
url: wcLicensedProductVersions.ajaxUrl,
type: 'POST',
data: {
action: 'wc_licensed_product_add_version',
nonce: wcLicensedProductVersions.nonce,
product_id: productId,
version: version,
download_url: downloadUrl,
release_notes: releaseNotes
},
success: function(response) {
if (response.success) {
// Remove "no versions" row if present
$('#versions-table tbody .no-versions').remove();
// Add new row to table
$('#versions-table tbody').prepend(response.data.html);
// Clear form
$('#new_version').val('');
$('#new_download_url').val('');
$('#new_release_notes').val('');
} else {
alert(response.data.message || wcLicensedProductVersions.strings.error);
}
},
error: function() {
alert(wcLicensedProductVersions.strings.error);
},
complete: function() {
$btn.prop('disabled', false);
$spinner.removeClass('is-active');
}
});
},
deleteVersion: function(e) {
e.preventDefault();
if (!confirm(wcLicensedProductVersions.strings.confirmDelete)) {
return;
}
var $btn = $(this);
var $row = $btn.closest('tr');
var versionId = $btn.data('version-id');
$btn.prop('disabled', true);
$.ajax({
url: wcLicensedProductVersions.ajaxUrl,
type: 'POST',
data: {
action: 'wc_licensed_product_delete_version',
nonce: wcLicensedProductVersions.nonce,
version_id: versionId
},
success: function(response) {
if (response.success) {
$row.fadeOut(300, function() {
$(this).remove();
// Show "no versions" message if table is empty
if ($('#versions-table tbody tr').length === 0) {
$('#versions-table tbody').append(
'<tr class="no-versions"><td colspan="6">' +
'No versions found. Add your first version above.' +
'</td></tr>'
);
}
});
} else {
alert(response.data.message || wcLicensedProductVersions.strings.error);
$btn.prop('disabled', false);
}
},
error: function() {
alert(wcLicensedProductVersions.strings.error);
$btn.prop('disabled', false);
}
});
},
toggleVersion: function(e) {
e.preventDefault();
var $btn = $(this);
var $row = $btn.closest('tr');
var versionId = $btn.data('version-id');
var currentlyActive = $btn.data('active') === 1 || $btn.data('active') === '1';
$btn.prop('disabled', true);
$.ajax({
url: wcLicensedProductVersions.ajaxUrl,
type: 'POST',
data: {
action: 'wc_licensed_product_toggle_version',
nonce: wcLicensedProductVersions.nonce,
version_id: versionId,
currently_active: currentlyActive ? 1 : 0
},
success: function(response) {
if (response.success) {
var isActive = response.data.isActive;
var $status = $row.find('.version-status');
// Update status badge
$status
.removeClass('version-status-active version-status-inactive')
.addClass('version-status-' + (isActive ? 'active' : 'inactive'))
.text(isActive ? 'Active' : 'Inactive');
// Update button
$btn
.data('active', isActive ? 1 : 0)
.text(isActive ? 'Deactivate' : 'Activate');
} else {
alert(response.data.message || wcLicensedProductVersions.strings.error);
}
},
error: function() {
alert(wcLicensedProductVersions.strings.error);
},
complete: function() {
$btn.prop('disabled', false);
}
});
}
};
$(document).ready(function() {
WCLicensedProductVersions.init();
});
})(jQuery);

View File

@@ -0,0 +1,347 @@
<?php
/**
* Version Admin Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\Product\VersionManager;
/**
* Handles admin UI for product version management
*/
final class VersionAdminController
{
private VersionManager $versionManager;
public function __construct(VersionManager $versionManager)
{
$this->versionManager = $versionManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
// Add versions meta box to licensed products
add_action('add_meta_boxes', [$this, 'addVersionsMetaBox']);
// Handle AJAX actions for version management
add_action('wp_ajax_wc_licensed_product_add_version', [$this, 'ajaxAddVersion']);
add_action('wp_ajax_wc_licensed_product_delete_version', [$this, 'ajaxDeleteVersion']);
add_action('wp_ajax_wc_licensed_product_toggle_version', [$this, 'ajaxToggleVersion']);
// Enqueue scripts for product edit page
add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']);
}
/**
* Add versions meta box to product edit page
*/
public function addVersionsMetaBox(): void
{
add_meta_box(
'wc_licensed_product_versions',
__('Product Versions', 'wc-licensed-product'),
[$this, 'renderVersionsMetaBox'],
'product',
'normal',
'high'
);
}
/**
* Render versions meta box
*/
public function renderVersionsMetaBox(\WP_Post $post): void
{
$product = wc_get_product($post->ID);
if (!$product || !$product->is_type('licensed')) {
echo '<p>' . esc_html__('This meta box is only available for Licensed Products.', 'wc-licensed-product') . '</p>';
return;
}
$versions = $this->versionManager->getVersionsByProduct($post->ID);
wp_nonce_field('wc_licensed_product_versions', 'wc_licensed_product_versions_nonce');
?>
<div class="wc-licensed-product-versions">
<div class="versions-add-form">
<h4><?php esc_html_e('Add New Version', 'wc-licensed-product'); ?></h4>
<table class="form-table">
<tr>
<th><label for="new_version"><?php esc_html_e('Version', 'wc-licensed-product'); ?></label></th>
<td>
<input type="text" id="new_version" name="new_version" placeholder="1.0.0" class="regular-text" pattern="[0-9]+\.[0-9]+\.[0-9]+" />
<p class="description"><?php esc_html_e('Use semantic versioning (e.g., 1.0.0)', 'wc-licensed-product'); ?></p>
</td>
</tr>
<tr>
<th><label for="new_download_url"><?php esc_html_e('Download URL', 'wc-licensed-product'); ?></label></th>
<td>
<input type="url" id="new_download_url" name="new_download_url" class="large-text" placeholder="https://" />
<p class="description"><?php esc_html_e('URL to the downloadable file for this version.', 'wc-licensed-product'); ?></p>
</td>
</tr>
<tr>
<th><label for="new_release_notes"><?php esc_html_e('Release Notes', 'wc-licensed-product'); ?></label></th>
<td>
<textarea id="new_release_notes" name="new_release_notes" rows="3" class="large-text"></textarea>
</td>
</tr>
</table>
<p>
<button type="button" class="button button-primary" id="add-version-btn" data-product-id="<?php echo esc_attr($post->ID); ?>">
<?php esc_html_e('Add Version', 'wc-licensed-product'); ?>
</button>
<span class="spinner" style="float: none;"></span>
</p>
</div>
<hr />
<h4><?php esc_html_e('Existing Versions', 'wc-licensed-product'); ?></h4>
<table class="wp-list-table widefat fixed striped" id="versions-table">
<thead>
<tr>
<th><?php esc_html_e('Version', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Download URL', '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>
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<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>
</tr>
<?php else: ?>
<?php foreach ($versions as $version): ?>
<tr data-version-id="<?php echo esc_attr($version->getId()); ?>">
<td><strong><?php echo esc_html($version->getVersion()); ?></strong></td>
<td>
<?php if ($version->getDownloadUrl()): ?>
<a href="<?php echo esc_url($version->getDownloadUrl()); ?>" target="_blank">
<?php echo esc_html(wp_basename($version->getDownloadUrl())); ?>
</a>
<?php else: ?>
<em><?php esc_html_e('No download URL', 'wc-licensed-product'); ?></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'; ?>">
<?php echo $version->isActive() ? esc_html__('Active', 'wc-licensed-product') : esc_html__('Inactive', 'wc-licensed-product'); ?>
</span>
</td>
<td><?php echo esc_html($version->getReleasedAt()->format(get_option('date_format'))); ?></td>
<td>
<button type="button" class="button button-small toggle-version-btn" data-version-id="<?php echo esc_attr($version->getId()); ?>" data-active="<?php echo $version->isActive() ? '1' : '0'; ?>">
<?php echo $version->isActive() ? esc_html__('Deactivate', 'wc-licensed-product') : esc_html__('Activate', 'wc-licensed-product'); ?>
</button>
<button type="button" class="button button-small button-link-delete delete-version-btn" data-version-id="<?php echo esc_attr($version->getId()); ?>">
<?php esc_html_e('Delete', 'wc-licensed-product'); ?>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
}
/**
* Enqueue scripts for product edit page
*/
public function enqueueScripts(string $hook): void
{
if ($hook !== 'post.php' && $hook !== 'post-new.php') {
return;
}
global $post;
if (!$post || $post->post_type !== 'product') {
return;
}
wp_enqueue_script(
'wc-licensed-product-versions',
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/js/versions.js',
['jquery'],
WC_LICENSED_PRODUCT_VERSION,
true
);
wp_localize_script('wc-licensed-product-versions', 'wcLicensedProductVersions', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('wc_licensed_product_versions'),
'strings' => [
'confirmDelete' => __('Are you sure you want to delete this version?', 'wc-licensed-product'),
'versionRequired' => __('Please enter a version number.', 'wc-licensed-product'),
'versionInvalid' => __('Please enter a valid version number (e.g., 1.0.0).', 'wc-licensed-product'),
'error' => __('An error occurred. Please try again.', 'wc-licensed-product'),
],
]);
wp_enqueue_style(
'wc-licensed-product-admin',
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/admin.css',
[],
WC_LICENSED_PRODUCT_VERSION
);
}
/**
* AJAX handler for adding a version
*/
public function ajaxAddVersion(): void
{
check_ajax_referer('wc_licensed_product_versions', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
}
$productId = absint($_POST['product_id'] ?? 0);
$version = sanitize_text_field($_POST['version'] ?? '');
$downloadUrl = esc_url_raw($_POST['download_url'] ?? '');
$releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
if (!$productId || !$version) {
wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
}
// Validate version format
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
wp_send_json_error(['message' => __('Invalid version format. Use semantic versioning (e.g., 1.0.0).', 'wc-licensed-product')]);
}
// Check if version already exists
if ($this->versionManager->versionExists($productId, $version)) {
wp_send_json_error(['message' => __('This version already exists.', 'wc-licensed-product')]);
}
$newVersion = $this->versionManager->createVersion(
$productId,
$version,
$releaseNotes ?: null,
$downloadUrl ?: null
);
if (!$newVersion) {
wp_send_json_error(['message' => __('Failed to create version.', 'wc-licensed-product')]);
}
// Also update the product's current version meta
update_post_meta($productId, '_licensed_current_version', $version);
wp_send_json_success([
'message' => __('Version added successfully.', 'wc-licensed-product'),
'version' => $newVersion->toArray(),
'html' => $this->getVersionRowHtml($newVersion),
]);
}
/**
* AJAX handler for deleting a version
*/
public function ajaxDeleteVersion(): void
{
check_ajax_referer('wc_licensed_product_versions', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
}
$versionId = absint($_POST['version_id'] ?? 0);
if (!$versionId) {
wp_send_json_error(['message' => __('Version ID is required.', 'wc-licensed-product')]);
}
$result = $this->versionManager->deleteVersion($versionId);
if (!$result) {
wp_send_json_error(['message' => __('Failed to delete version.', 'wc-licensed-product')]);
}
wp_send_json_success(['message' => __('Version deleted successfully.', 'wc-licensed-product')]);
}
/**
* AJAX handler for toggling version status
*/
public function ajaxToggleVersion(): void
{
check_ajax_referer('wc_licensed_product_versions', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
}
$versionId = absint($_POST['version_id'] ?? 0);
$currentlyActive = (bool) ($_POST['currently_active'] ?? false);
if (!$versionId) {
wp_send_json_error(['message' => __('Version ID is required.', 'wc-licensed-product')]);
}
$result = $this->versionManager->updateVersion($versionId, null, null, !$currentlyActive);
if (!$result) {
wp_send_json_error(['message' => __('Failed to update version.', 'wc-licensed-product')]);
}
wp_send_json_success([
'message' => __('Version updated successfully.', 'wc-licensed-product'),
'isActive' => !$currentlyActive,
]);
}
/**
* Get HTML for a version table row
*/
private function getVersionRowHtml(\Jeremias\WcLicensedProduct\Product\ProductVersion $version): string
{
ob_start();
?>
<tr data-version-id="<?php echo esc_attr($version->getId()); ?>">
<td><strong><?php echo esc_html($version->getVersion()); ?></strong></td>
<td>
<?php if ($version->getDownloadUrl()): ?>
<a href="<?php echo esc_url($version->getDownloadUrl()); ?>" target="_blank">
<?php echo esc_html(wp_basename($version->getDownloadUrl())); ?>
</a>
<?php else: ?>
<em><?php esc_html_e('No download URL', 'wc-licensed-product'); ?></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'; ?>">
<?php echo $version->isActive() ? esc_html__('Active', 'wc-licensed-product') : esc_html__('Inactive', 'wc-licensed-product'); ?>
</span>
</td>
<td><?php echo esc_html($version->getReleasedAt()->format(get_option('date_format'))); ?></td>
<td>
<button type="button" class="button button-small toggle-version-btn" data-version-id="<?php echo esc_attr($version->getId()); ?>" data-active="<?php echo $version->isActive() ? '1' : '0'; ?>">
<?php echo $version->isActive() ? esc_html__('Deactivate', 'wc-licensed-product') : esc_html__('Activate', 'wc-licensed-product'); ?>
</button>
<button type="button" class="button button-small button-link-delete delete-version-btn" data-version-id="<?php echo esc_attr($version->getId()); ?>">
<?php esc_html_e('Delete', 'wc-licensed-product'); ?>
</button>
</td>
</tr>
<?php
return ob_get_clean();
}
}

View File

@@ -21,6 +21,16 @@ final class RestApiController
{ {
private const NAMESPACE = 'wc-licensed-product/v1'; private const NAMESPACE = 'wc-licensed-product/v1';
/**
* Rate limit: requests per minute per IP
*/
private const RATE_LIMIT_REQUESTS = 30;
/**
* Rate limit window in seconds
*/
private const RATE_LIMIT_WINDOW = 60;
private LicenseManager $licenseManager; private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager) public function __construct(LicenseManager $licenseManager)
@@ -37,6 +47,77 @@ final class RestApiController
add_action('rest_api_init', [$this, 'registerRoutes']); add_action('rest_api_init', [$this, 'registerRoutes']);
} }
/**
* Check rate limit for current IP
*
* @return WP_REST_Response|null Returns error response if rate limited, null if OK
*/
private function checkRateLimit(): ?WP_REST_Response
{
$ip = $this->getClientIp();
$transientKey = 'wclp_rate_' . md5($ip);
$data = get_transient($transientKey);
if ($data === false) {
// First request, start counting
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
return null;
}
$count = (int) ($data['count'] ?? 0);
$start = (int) ($data['start'] ?? time());
// Check if window has expired
if (time() - $start >= self::RATE_LIMIT_WINDOW) {
// Reset counter
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
return null;
}
// Check if limit exceeded
if ($count >= self::RATE_LIMIT_REQUESTS) {
$retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start);
$response = new WP_REST_Response([
'success' => false,
'error' => 'rate_limit_exceeded',
'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'),
'retry_after' => $retryAfter,
], 429);
$response->header('Retry-After', (string) $retryAfter);
return $response;
}
// Increment counter
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW);
return null;
}
/**
* Get client IP address
*/
private function getClientIp(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '0.0.0.0';
}
/** /**
* Register REST API routes * Register REST API routes
*/ */
@@ -125,6 +206,11 @@ final class RestApiController
*/ */
public function validateLicense(WP_REST_Request $request): WP_REST_Response public function validateLicense(WP_REST_Request $request): WP_REST_Response
{ {
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key'); $licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain'); $domain = $request->get_param('domain');
@@ -140,6 +226,11 @@ final class RestApiController
*/ */
public function checkStatus(WP_REST_Request $request): WP_REST_Response public function checkStatus(WP_REST_Request $request): WP_REST_Response
{ {
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key'); $licenseKey = $request->get_param('license_key');
$license = $this->licenseManager->getLicenseByKey($licenseKey); $license = $this->licenseManager->getLicenseByKey($licenseKey);
@@ -166,6 +257,11 @@ final class RestApiController
*/ */
public function activateLicense(WP_REST_Request $request): WP_REST_Response public function activateLicense(WP_REST_Request $request): WP_REST_Response
{ {
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key'); $licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain'); $domain = $request->get_param('domain');
@@ -228,6 +324,11 @@ final class RestApiController
*/ */
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
{ {
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key'); $licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain'); $domain = $request->get_param('domain');

View File

@@ -0,0 +1,198 @@
<?php
/**
* License Email Controller
*
* @package Jeremias\WcLicensedProduct\Email
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Email;
use Jeremias\WcLicensedProduct\License\LicenseManager;
/**
* Handles email notifications for licenses
*/
final class LicenseEmailController
{
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
// Add license info to order completed email
add_action('woocommerce_email_after_order_table', [$this, 'addLicenseInfoToEmail'], 20, 4);
// Add license info to order details in emails
add_action('woocommerce_order_item_meta_end', [$this, 'addLicenseToOrderItem'], 10, 4);
}
/**
* Add license information to order completed email
*/
public function addLicenseInfoToEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText, $email): void
{
// Only add to completed order email sent to customer
if ($sentToAdmin || !$email || $email->id !== 'customer_completed_order') {
return;
}
$licenses = $this->getLicensesForOrder($order);
if (empty($licenses)) {
return;
}
if ($plainText) {
$this->renderPlainTextLicenseInfo($licenses, $order);
} else {
$this->renderHtmlLicenseInfo($licenses, $order);
}
}
/**
* Add license key to order item in email
*/
public function addLicenseToOrderItem(int $itemId, \WC_Order_Item $item, \WC_Order $order, bool $plainText): void
{
$product = $item->get_product();
if (!$product || !$product->is_type('licensed')) {
return;
}
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
if (!$license) {
return;
}
if ($plainText) {
echo "\n" . esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($license->getLicenseKey()) . "\n";
} else {
?>
<div style="margin-top: 10px; padding: 10px; background-color: #f8f9fa; border-left: 3px solid #0073aa;">
<strong><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></strong>
<code style="display: block; margin-top: 5px; padding: 5px; background: #fff; font-family: monospace;">
<?php echo esc_html($license->getLicenseKey()); ?>
</code>
</div>
<?php
}
}
/**
* Get all licenses for an order
*/
private function getLicensesForOrder(\WC_Order $order): array
{
$licenses = [];
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
if ($license) {
$licenses[] = [
'license' => $license,
'product_name' => $product->get_name(),
];
}
}
}
return $licenses;
}
/**
* Render license info in HTML format
*/
private function renderHtmlLicenseInfo(array $licenses, \WC_Order $order): void
{
$domain = $order->get_meta('_licensed_product_domain');
?>
<div style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e5e5e5; border-radius: 4px;">
<h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2>
<?php if ($domain): ?>
<p style="margin-bottom: 15px;">
<strong><?php esc_html_e('Licensed Domain:', 'wc-licensed-product'); ?></strong>
<?php echo esc_html($domain); ?>
</p>
<?php endif; ?>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($licenses as $item): ?>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><?php echo esc_html($item['product_name']); ?></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">
<code style="background: #fff; padding: 3px 6px; font-family: monospace;">
<?php echo esc_html($item['license']->getLicenseKey()); ?>
</code>
</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">
<?php
$expiresAt = $item['license']->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p style="margin-top: 15px; margin-bottom: 0; font-size: 0.9em; color: #666;">
<?php esc_html_e('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product'); ?>
</p>
</div>
<?php
}
/**
* Render license info in plain text format
*/
private function renderPlainTextLicenseInfo(array $licenses, \WC_Order $order): void
{
$domain = $order->get_meta('_licensed_product_domain');
echo "\n\n";
echo "==========================================================\n";
echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n";
if ($domain) {
echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n";
}
foreach ($licenses as $item) {
echo esc_html($item['product_name']) . "\n";
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n";
$expiresAt = $item['license']->getExpiresAt();
echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
echo "\n\n";
}
echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n";
}
}

View File

@@ -10,11 +10,14 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct; namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController; use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
use Jeremias\WcLicensedProduct\Api\RestApiController; use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController; use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
use Jeremias\WcLicensedProduct\Email\LicenseEmailController;
use Jeremias\WcLicensedProduct\Frontend\AccountController; use Jeremias\WcLicensedProduct\Frontend\AccountController;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\LicensedProductType; use Jeremias\WcLicensedProduct\Product\LicensedProductType;
use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment; use Twig\Environment;
use Twig\Loader\FilesystemLoader; use Twig\Loader\FilesystemLoader;
@@ -38,6 +41,11 @@ final class Plugin
*/ */
private LicenseManager $licenseManager; private LicenseManager $licenseManager;
/**
* Version manager
*/
private VersionManager $versionManager;
/** /**
* Get singleton instance * Get singleton instance
*/ */
@@ -90,15 +98,18 @@ final class Plugin
private function initComponents(): void private function initComponents(): void
{ {
$this->licenseManager = new LicenseManager(); $this->licenseManager = new LicenseManager();
$this->versionManager = new VersionManager();
// Initialize controllers // Initialize controllers
new LicensedProductType(); new LicensedProductType();
new CheckoutController($this->licenseManager); new CheckoutController($this->licenseManager);
new AccountController($this->twig, $this->licenseManager); new AccountController($this->twig, $this->licenseManager);
new RestApiController($this->licenseManager); new RestApiController($this->licenseManager);
new LicenseEmailController($this->licenseManager);
if (is_admin()) { if (is_admin()) {
new AdminController($this->twig, $this->licenseManager); new AdminController($this->twig, $this->licenseManager);
new VersionAdminController($this->versionManager);
} }
} }

View File

@@ -0,0 +1,137 @@
<?php
/**
* Product Version Model
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
/**
* Product version entity model
*/
class ProductVersion
{
private int $id;
private int $productId;
private string $version;
private int $majorVersion;
private int $minorVersion;
private int $patchVersion;
private ?string $releaseNotes;
private ?string $downloadUrl;
private bool $isActive;
private \DateTimeInterface $releasedAt;
private \DateTimeInterface $createdAt;
/**
* Create version from database row
*/
public static function fromArray(array $data): self
{
$version = new self();
$version->id = (int) $data['id'];
$version->productId = (int) $data['product_id'];
$version->version = $data['version'];
$version->majorVersion = (int) $data['major_version'];
$version->minorVersion = (int) $data['minor_version'];
$version->patchVersion = (int) $data['patch_version'];
$version->releaseNotes = $data['release_notes'] ?: null;
$version->downloadUrl = $data['download_url'] ?: null;
$version->isActive = (bool) $data['is_active'];
$version->releasedAt = new \DateTimeImmutable($data['released_at']);
$version->createdAt = new \DateTimeImmutable($data['created_at']);
return $version;
}
/**
* Parse version string into components
*/
public static function parseVersion(string $versionString): array
{
$parts = explode('.', $versionString);
return [
'major' => (int) ($parts[0] ?? 0),
'minor' => (int) ($parts[1] ?? 0),
'patch' => (int) ($parts[2] ?? 0),
];
}
public function getId(): int
{
return $this->id;
}
public function getProductId(): int
{
return $this->productId;
}
public function getVersion(): string
{
return $this->version;
}
public function getMajorVersion(): int
{
return $this->majorVersion;
}
public function getMinorVersion(): int
{
return $this->minorVersion;
}
public function getPatchVersion(): int
{
return $this->patchVersion;
}
public function getReleaseNotes(): ?string
{
return $this->releaseNotes;
}
public function getDownloadUrl(): ?string
{
return $this->downloadUrl;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getReleasedAt(): \DateTimeInterface
{
return $this->releasedAt;
}
public function getCreatedAt(): \DateTimeInterface
{
return $this->createdAt;
}
/**
* Convert to array
*/
public function toArray(): array
{
return [
'id' => $this->id,
'product_id' => $this->productId,
'version' => $this->version,
'major_version' => $this->majorVersion,
'minor_version' => $this->minorVersion,
'patch_version' => $this->patchVersion,
'release_notes' => $this->releaseNotes,
'download_url' => $this->downloadUrl,
'is_active' => $this->isActive,
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
];
}
}

View File

@@ -0,0 +1,209 @@
<?php
/**
* Version Manager
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Installer;
/**
* Manages product versions (CRUD operations)
*/
class VersionManager
{
/**
* Get version by ID
*/
public function getVersionById(int $id): ?ProductVersion
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$tableName} WHERE id = %d", $id),
ARRAY_A
);
return $row ? ProductVersion::fromArray($row) : null;
}
/**
* Get all versions for a product
*/
public function getVersionsByProduct(int $productId): array
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE product_id = %d ORDER BY major_version DESC, minor_version DESC, patch_version DESC",
$productId
),
ARRAY_A
);
return array_map(fn(array $row) => ProductVersion::fromArray($row), $rows ?: []);
}
/**
* Get latest active version for a product
*/
public function getLatestVersion(int $productId): ?ProductVersion
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE product_id = %d AND is_active = 1 ORDER BY major_version DESC, minor_version DESC, patch_version DESC LIMIT 1",
$productId
),
ARRAY_A
);
return $row ? ProductVersion::fromArray($row) : null;
}
/**
* Get latest version for a specific major version
*/
public function getLatestVersionForMajor(int $productId, int $majorVersion): ?ProductVersion
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE product_id = %d AND major_version = %d AND is_active = 1 ORDER BY minor_version DESC, patch_version DESC LIMIT 1",
$productId,
$majorVersion
),
ARRAY_A
);
return $row ? ProductVersion::fromArray($row) : null;
}
/**
* Create a new version
*/
public function createVersion(
int $productId,
string $version,
?string $releaseNotes = null,
?string $downloadUrl = null
): ?ProductVersion {
global $wpdb;
$parsed = ProductVersion::parseVersion($version);
$tableName = Installer::getVersionsTable();
$result = $wpdb->insert(
$tableName,
[
'product_id' => $productId,
'version' => $version,
'major_version' => $parsed['major'],
'minor_version' => $parsed['minor'],
'patch_version' => $parsed['patch'],
'release_notes' => $releaseNotes,
'download_url' => $downloadUrl,
'is_active' => 1,
],
['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d']
);
if ($result === false) {
return null;
}
return $this->getVersionById((int) $wpdb->insert_id);
}
/**
* Update a version
*/
public function updateVersion(
int $versionId,
?string $releaseNotes = null,
?string $downloadUrl = null,
?bool $isActive = null
): bool {
global $wpdb;
$data = [];
$formats = [];
if ($releaseNotes !== null) {
$data['release_notes'] = $releaseNotes;
$formats[] = '%s';
}
if ($downloadUrl !== null) {
$data['download_url'] = $downloadUrl;
$formats[] = '%s';
}
if ($isActive !== null) {
$data['is_active'] = $isActive ? 1 : 0;
$formats[] = '%d';
}
if (empty($data)) {
return true;
}
$tableName = Installer::getVersionsTable();
$result = $wpdb->update(
$tableName,
$data,
['id' => $versionId],
$formats,
['%d']
);
return $result !== false;
}
/**
* Delete a version
*/
public function deleteVersion(int $versionId): bool
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$result = $wpdb->delete(
$tableName,
['id' => $versionId],
['%d']
);
return $result !== false;
}
/**
* Check if version exists for product
*/
public function versionExists(int $productId, string $version): bool
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$tableName} WHERE product_id = %d AND version = %s",
$productId,
$version
)
);
return (int) $count > 0;
}
}

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