You've already forked wc-licensed-product
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:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
181
assets/js/versions.js
Normal 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);
|
||||||
347
src/Admin/VersionAdminController.php
Normal file
347
src/Admin/VersionAdminController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
198
src/Email/LicenseEmailController.php
Normal file
198
src/Email/LicenseEmailController.php
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
137
src/Product/ProductVersion.php
Normal file
137
src/Product/ProductVersion.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/Product/VersionManager.php
Normal file
209
src/Product/VersionManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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__));
|
||||||
|
|||||||
Reference in New Issue
Block a user