From dec4bd609bcdc34fc065b0d1f45f21cdbda3b174 Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 21 Jan 2026 19:15:19 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 29 ++- assets/css/admin.css | 56 +++++ assets/js/versions.js | 181 ++++++++++++++ src/Admin/VersionAdminController.php | 347 +++++++++++++++++++++++++++ src/Api/RestApiController.php | 101 ++++++++ src/Email/LicenseEmailController.php | 198 +++++++++++++++ src/Plugin.php | 11 + src/Product/ProductVersion.php | 137 +++++++++++ src/Product/VersionManager.php | 209 ++++++++++++++++ wc-licensed-product.php | 4 +- 10 files changed, 1269 insertions(+), 4 deletions(-) create mode 100644 assets/js/versions.js create mode 100644 src/Admin/VersionAdminController.php create mode 100644 src/Email/LicenseEmailController.php create mode 100644 src/Product/ProductVersion.php create mode 100644 src/Product/VersionManager.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 78166f9..547e07f 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/assets/css/admin.css b/assets/css/admin.css index 7008182..a2ae274 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -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; +} diff --git a/assets/js/versions.js b/assets/js/versions.js new file mode 100644 index 0000000..12ff30c --- /dev/null +++ b/assets/js/versions.js @@ -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( + '' + + 'No versions found. Add your first version above.' + + '' + ); + } + }); + } 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); diff --git a/src/Admin/VersionAdminController.php b/src/Admin/VersionAdminController.php new file mode 100644 index 0000000..9728cf6 --- /dev/null +++ b/src/Admin/VersionAdminController.php @@ -0,0 +1,347 @@ +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 '

' . esc_html__('This meta box is only available for Licensed Products.', 'wc-licensed-product') . '

'; + return; + } + + $versions = $this->versionManager->getVersionsByProduct($post->ID); + wp_nonce_field('wc_licensed_product_versions', 'wc_licensed_product_versions_nonce'); + ?> +
+
+

+ + + + + + + + + + + + + +
+ +

+
+ +

+
+ +
+

+ + +

+
+ +
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
getVersion()); ?> + getDownloadUrl()): ?> + + getDownloadUrl())); ?> + + + + + getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?> + + isActive() ? esc_html__('Active', 'wc-licensed-product') : esc_html__('Inactive', 'wc-licensed-product'); ?> + + getReleasedAt()->format(get_option('date_format'))); ?> + + +
+
+ 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(); + ?> + + getVersion()); ?> + + getDownloadUrl()): ?> + + getDownloadUrl())); ?> + + + + + + getReleaseNotes() ? wp_trim_words($version->getReleaseNotes(), 10) : '—'); ?> + + + isActive() ? esc_html__('Active', 'wc-licensed-product') : esc_html__('Inactive', 'wc-licensed-product'); ?> + + + getReleasedAt()->format(get_option('date_format'))); ?> + + + + + + 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'); diff --git a/src/Email/LicenseEmailController.php b/src/Email/LicenseEmailController.php new file mode 100644 index 0000000..d413c54 --- /dev/null +++ b/src/Email/LicenseEmailController.php @@ -0,0 +1,198 @@ +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 { + ?> +
+ + + getLicenseKey()); ?> + +
+ 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'); + ?> +
+

+ + +

+ + +

+ + + + + + + + + + + + + + + + + + + +
+ + getLicenseKey()); ?> + + + getExpiresAt(); + echo $expiresAt + ? esc_html($expiresAt->format(get_option('date_format'))) + : esc_html__('Never', 'wc-licensed-product'); + ?> +
+ +

+ +

+
+ 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"; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index dfa51d9..3d7062f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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); } } diff --git a/src/Product/ProductVersion.php b/src/Product/ProductVersion.php new file mode 100644 index 0000000..69d2b85 --- /dev/null +++ b/src/Product/ProductVersion.php @@ -0,0 +1,137 @@ +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'), + ]; + } +} diff --git a/src/Product/VersionManager.php b/src/Product/VersionManager.php new file mode 100644 index 0000000..e622e23 --- /dev/null +++ b/src/Product/VersionManager.php @@ -0,0 +1,209 @@ +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; + } +} diff --git a/wc-licensed-product.php b/wc-licensed-product.php index daba5f6..91fecd5 100644 --- a/wc-licensed-product.php +++ b/wc-licensed-product.php @@ -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__));