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]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -44,5 +68,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- WordPress REST API integration
|
||||
- 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
|
||||
|
||||
@@ -89,3 +89,59 @@
|
||||
.column-license .dashicons-admin-network {
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager)
|
||||
@@ -37,6 +47,77 @@ final class RestApiController
|
||||
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
|
||||
*/
|
||||
@@ -125,6 +206,11 @@ final class RestApiController
|
||||
*/
|
||||
public function validateLicense(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$rateLimitResponse = $this->checkRateLimit();
|
||||
if ($rateLimitResponse !== null) {
|
||||
return $rateLimitResponse;
|
||||
}
|
||||
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
@@ -140,6 +226,11 @@ final class RestApiController
|
||||
*/
|
||||
public function checkStatus(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$rateLimitResponse = $this->checkRateLimit();
|
||||
if ($rateLimitResponse !== null) {
|
||||
return $rateLimitResponse;
|
||||
}
|
||||
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$license = $this->licenseManager->getLicenseByKey($licenseKey);
|
||||
|
||||
@@ -166,6 +257,11 @@ final class RestApiController
|
||||
*/
|
||||
public function activateLicense(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$rateLimitResponse = $this->checkRateLimit();
|
||||
if ($rateLimitResponse !== null) {
|
||||
return $rateLimitResponse;
|
||||
}
|
||||
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
@@ -228,6 +324,11 @@ final class RestApiController
|
||||
*/
|
||||
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
|
||||
{
|
||||
$rateLimitResponse = $this->checkRateLimit();
|
||||
if ($rateLimitResponse !== null) {
|
||||
return $rateLimitResponse;
|
||||
}
|
||||
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$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;
|
||||
|
||||
use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||
use Jeremias\WcLicensedProduct\Email\LicenseEmailController;
|
||||
use Jeremias\WcLicensedProduct\Frontend\AccountController;
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
@@ -38,6 +41,11 @@ final class Plugin
|
||||
*/
|
||||
private LicenseManager $licenseManager;
|
||||
|
||||
/**
|
||||
* Version manager
|
||||
*/
|
||||
private VersionManager $versionManager;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
@@ -90,15 +98,18 @@ final class Plugin
|
||||
private function initComponents(): void
|
||||
{
|
||||
$this->licenseManager = new LicenseManager();
|
||||
$this->versionManager = new VersionManager();
|
||||
|
||||
// Initialize controllers
|
||||
new LicensedProductType();
|
||||
new CheckoutController($this->licenseManager);
|
||||
new AccountController($this->twig, $this->licenseManager);
|
||||
new RestApiController($this->licenseManager);
|
||||
new LicenseEmailController($this->licenseManager);
|
||||
|
||||
if (is_admin()) {
|
||||
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 URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
|
||||
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
|
||||
* Version: 0.0.1
|
||||
* Version: 0.0.2
|
||||
* Author: Marco Graetsch
|
||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.0.1');
|
||||
define('WC_LICENSED_PRODUCT_VERSION', '0.0.2');
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user