feat: Implement OAuth license activation flow

- Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA
- Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints
- Update App.tsx to render license-connect outside BaseLayout (no header/footer)
- Add license_activation_method field to product settings in Admin SPA
- Create LICENSING_MODULE.md with comprehensive OAuth flow documentation
- Update API_ROUTES.md with license module endpoints
This commit is contained in:
Dwindi Ramadhana
2026-01-31 22:22:22 +07:00
parent d80f34c8b9
commit a0b5f8496d
23 changed files with 3218 additions and 806 deletions

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Api\Controllers;
use WP_REST_Controller;
@@ -9,21 +10,23 @@ use WP_Error;
* Cart Controller
* Handles cart operations via REST API
*/
class CartController extends WP_REST_Controller {
class CartController extends WP_REST_Controller
{
/**
* Register routes
*/
public function register_routes() {
public function register_routes()
{
$namespace = 'woonoow/v1';
// Get cart
register_rest_route($namespace, '/cart', [
'methods' => 'GET',
'callback' => [$this, 'get_cart'],
'permission_callback' => '__return_true',
]);
// Add to cart
register_rest_route($namespace, '/cart/add', [
'methods' => 'POST',
@@ -48,7 +51,7 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Update cart item
register_rest_route($namespace, '/cart/update', [
'methods' => 'POST',
@@ -66,7 +69,7 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Remove from cart
register_rest_route($namespace, '/cart/remove', [
'methods' => 'POST',
@@ -79,14 +82,14 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Clear cart
register_rest_route($namespace, '/cart/clear', [
'methods' => 'POST',
'callback' => [$this, 'clear_cart'],
'permission_callback' => '__return_true',
]);
// Apply coupon
register_rest_route($namespace, '/cart/apply-coupon', [
'methods' => 'POST',
@@ -100,7 +103,7 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Remove coupon
register_rest_route($namespace, '/cart/remove-coupon', [
'methods' => 'POST',
@@ -115,25 +118,26 @@ class CartController extends WP_REST_Controller {
],
]);
}
/**
* Get cart contents
*/
public function get_cart($request) {
public function get_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$cart = WC()->cart;
// Calculate totals to ensure discounts are computed
$cart->calculate_totals();
// Format coupons with discount amounts
$coupons_with_discounts = [];
foreach ($cart->get_applied_coupons() as $coupon_code) {
@@ -145,7 +149,7 @@ class CartController extends WP_REST_Controller {
'type' => $coupon->get_discount_type(),
];
}
return new WP_REST_Response([
'items' => $this->format_cart_items($cart->get_cart()),
'totals' => [
@@ -167,42 +171,107 @@ class CartController extends WP_REST_Controller {
'item_count' => $cart->get_cart_contents_count(),
], 200);
}
/**
* Add product to cart
*/
public function add_to_cart($request) {
public function add_to_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$product_id = $request->get_param('product_id');
$quantity = $request->get_param('quantity') ?: 1;
$variation_id = $request->get_param('variation_id') ?: 0;
$variation = $request->get_param('variation') ?: [];
// TEMPORARY DEBUG: Return early to confirm this code is reached
if ($product_id == 512) {
return new WP_REST_Response([
'debug' => true,
'message' => 'CartController reached',
'product_id' => $product_id,
'variation_id' => $variation_id,
'variation' => $variation,
], 200);
}
// Validate product
$product = wc_get_product($product_id);
if (!$product) {
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
}
// Check stock
if (!$product->is_in_stock()) {
return new WP_Error('out_of_stock', 'Product is out of stock', ['status' => 400]);
}
// Add to cart
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id);
if (!$cart_item_key) {
return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]);
// For variable products with a variation_id, handle attributes properly
// This ensures correct attribute keys even if frontend sends wrong/empty data
if ($variation_id > 0) {
$variation_product = wc_get_product($variation_id);
if ($variation_product && $variation_product->is_type('variation')) {
// Get the actual attributes stored on the variation
$stored_attributes = $variation_product->get_variation_attributes();
// Merge: use stored attributes as base, but fill in empty values from frontend
// This handles variations created with "Any X" option (empty values)
$frontend_variation = $request->get_param('variation') ?: [];
foreach ($stored_attributes as $key => $value) {
if ($value === '' && isset($frontend_variation[$key]) && $frontend_variation[$key] !== '') {
// Stored value is empty ("Any"), use frontend value
$stored_attributes[$key] = sanitize_text_field($frontend_variation[$key]);
}
}
$variation = $stored_attributes;
}
}
// DEBUG: Log what we're passing to add_to_cart
error_log('[CartController] product_id: ' . $product_id);
error_log('[CartController] quantity: ' . $quantity);
error_log('[CartController] variation_id: ' . $variation_id);
error_log('[CartController] variation: ' . json_encode($variation));
error_log('[CartController] frontend_variation (raw): ' . json_encode($request->get_param('variation')));
// Add to cart
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation);
if (!$cart_item_key) {
// Get error notices from WooCommerce to provide a specific reason
$notices = wc_get_notices('error');
$message = 'Failed to add product to cart';
if (!empty($notices)) {
// Get the last error message
$last_notice = end($notices);
if (isset($last_notice['notice'])) {
// Strip HTML tags for clean error message
$message = wp_strip_all_tags($last_notice['notice']);
}
}
wc_clear_notices();
return new WP_Error('add_to_cart_failed', $message, [
'status' => 400,
'debug' => [
'product_id' => $product_id,
'variation_id' => $variation_id,
'variation_passed' => $variation,
'frontend_variation' => $request->get_param('variation'),
],
]);
}
return new WP_REST_Response([
'success' => true,
'cart_item_key' => $cart_item_key,
@@ -210,166 +279,172 @@ class CartController extends WP_REST_Controller {
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Update cart item quantity
*/
public function update_cart_item($request) {
public function update_cart_item($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$cart_item_key = $request->get_param('cart_item_key');
$quantity = $request->get_param('quantity');
// Validate cart item
$cart = WC()->cart->get_cart();
if (!isset($cart[$cart_item_key])) {
return new WP_Error('invalid_cart_item', 'Cart item not found', ['status' => 404]);
}
// Update quantity
$updated = WC()->cart->set_quantity($cart_item_key, $quantity);
if (!$updated) {
return new WP_Error('update_failed', 'Failed to update cart item', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Cart updated successfully',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Remove item from cart
*/
public function remove_from_cart($request) {
public function remove_from_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$cart_item_key = $request->get_param('cart_item_key');
// Validate cart item
$cart = WC()->cart->get_cart();
if (!isset($cart[$cart_item_key])) {
return new WP_Error('invalid_cart_item', 'Cart item not found', ['status' => 404]);
}
// Remove item
$removed = WC()->cart->remove_cart_item($cart_item_key);
if (!$removed) {
return new WP_Error('remove_failed', 'Failed to remove cart item', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Item removed from cart',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Clear cart
*/
public function clear_cart($request) {
public function clear_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
WC()->cart->empty_cart();
return new WP_REST_Response([
'success' => true,
'message' => 'Cart cleared successfully',
], 200);
}
/**
* Apply coupon
*/
public function apply_coupon($request) {
public function apply_coupon($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$coupon_code = $request->get_param('coupon_code');
// Apply coupon
$applied = WC()->cart->apply_coupon($coupon_code);
if (!$applied) {
return new WP_Error('coupon_failed', 'Failed to apply coupon', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Coupon applied successfully',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Remove coupon
*/
public function remove_coupon($request) {
public function remove_coupon($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$coupon_code = $request->get_param('coupon_code');
// Remove coupon
$removed = WC()->cart->remove_coupon($coupon_code);
if (!$removed) {
return new WP_Error('coupon_remove_failed', 'Failed to remove coupon', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Coupon removed successfully',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Format cart items for response
*/
private function format_cart_items($cart_items) {
private function format_cart_items($cart_items)
{
$formatted = [];
foreach ($cart_items as $cart_item_key => $cart_item) {
$product = $cart_item['data'];
$formatted[] = [
'key' => $cart_item_key,
'product_id' => $cart_item['product_id'],
@@ -385,7 +460,7 @@ class CartController extends WP_REST_Controller {
'downloadable' => $product->is_downloadable(),
];
}
return $formatted;
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licenses API Controller
*
@@ -17,91 +18,111 @@ use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Licensing\LicenseManager;
class LicensesController {
class LicensesController
{
/**
* Register REST routes
*/
public static function register_routes() {
public static function register_routes()
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
// Admin routes
register_rest_route('woonoow/v1', '/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_licenses'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_license'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'revoke_license'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)/activations', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_activations'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
// Customer routes
register_rest_route('woonoow/v1', '/account/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_customer_licenses'],
'permission_callback' => function() {
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/licenses/(?P<id>\d+)/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_deactivate'],
'permission_callback' => function() {
'permission_callback' => function () {
return is_user_logged_in();
},
]);
// Public API routes (for software validation)
register_rest_route('woonoow/v1', '/licenses/validate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'validate_license'],
'permission_callback' => '__return_true',
]);
register_rest_route('woonoow/v1', '/licenses/activate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'activate_license'],
'permission_callback' => '__return_true',
]);
register_rest_route('woonoow/v1', '/licenses/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'deactivate_license'],
'permission_callback' => '__return_true',
]);
// OAuth endpoints for license-connect
register_rest_route('woonoow/v1', '/licenses/oauth/validate', [
'methods' => 'GET',
'callback' => [__CLASS__, 'oauth_validate'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/licenses/oauth/confirm', [
'methods' => 'POST',
'callback' => [__CLASS__, 'oauth_confirm'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/**
* Get all licenses (admin)
*/
public static function get_licenses(WP_REST_Request $request) {
public static function get_licenses(WP_REST_Request $request)
{
$args = [
'search' => $request->get_param('search'),
'status' => $request->get_param('status'),
@@ -110,14 +131,14 @@ class LicensesController {
'limit' => $request->get_param('per_page') ?: 50,
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 50),
];
$result = LicenseManager::get_all_licenses($args);
// Enrich with product and user info
foreach ($result['licenses'] as &$license) {
$license = self::enrich_license($license);
}
return new WP_REST_Response([
'licenses' => $result['licenses'],
'total' => $result['total'],
@@ -125,167 +146,282 @@ class LicensesController {
'per_page' => $args['limit'],
]);
}
/**
* Get single license (admin)
*/
public static function get_license(WP_REST_Request $request) {
public static function get_license(WP_REST_Request $request)
{
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license) {
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
}
$license = self::enrich_license($license);
$license['activations'] = LicenseManager::get_activations($license['id']);
return new WP_REST_Response($license);
}
/**
* Revoke license (admin)
*/
public static function revoke_license(WP_REST_Request $request) {
public static function revoke_license(WP_REST_Request $request)
{
$result = LicenseManager::revoke($request->get_param('id'));
if (!$result) {
return new WP_Error('revoke_failed', __('Failed to revoke license', 'woonoow'), ['status' => 500]);
}
return new WP_REST_Response(['success' => true]);
}
/**
* Get activations for license (admin)
*/
public static function get_activations(WP_REST_Request $request) {
public static function get_activations(WP_REST_Request $request)
{
$activations = LicenseManager::get_activations($request->get_param('id'));
return new WP_REST_Response($activations);
}
/**
* Get customer's licenses
*/
public static function get_customer_licenses(WP_REST_Request $request) {
public static function get_customer_licenses(WP_REST_Request $request)
{
$user_id = get_current_user_id();
$licenses = LicenseManager::get_user_licenses($user_id);
// Enrich each license
foreach ($licenses as &$license) {
$license = self::enrich_license($license);
$license['activations'] = LicenseManager::get_activations($license['id']);
}
return new WP_REST_Response($licenses);
}
/**
* Customer deactivate their own activation
*/
public static function customer_deactivate(WP_REST_Request $request) {
public static function customer_deactivate(WP_REST_Request $request)
{
$user_id = get_current_user_id();
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license || $license['user_id'] != $user_id) {
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
}
$data = $request->get_json_params();
$result = LicenseManager::deactivate(
$license['license_key'],
$data['activation_id'] ?? null,
$data['machine_id'] ?? null
);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Validate license (public API)
*/
public static function validate_license(WP_REST_Request $request) {
public static function validate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$result = LicenseManager::validate($data['license_key']);
return new WP_REST_Response($result);
}
/**
* Activate license (public API)
*/
public static function activate_license(WP_REST_Request $request) {
public static function activate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$activation_data = [
'domain' => $data['domain'] ?? null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'machine_id' => $data['machine_id'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'return_url' => $data['return_url'] ?? null,
'activation_token' => $data['activation_token'] ?? null,
];
$result = LicenseManager::activate($data['license_key'], $activation_data);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Deactivate license (public API)
*/
public static function deactivate_license(WP_REST_Request $request) {
public static function deactivate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$result = LicenseManager::deactivate(
$data['license_key'],
$data['activation_id'] ?? null,
$data['machine_id'] ?? null
);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Enrich license with product and user info
*/
private static function enrich_license($license) {
private static function enrich_license($license)
{
// Add product info
$product = wc_get_product($license['product_id']);
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
// Add user info
$user = get_userdata($license['user_id']);
$license['user_email'] = $user ? $user->user_email : '';
$license['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
// Add computed fields
$license['is_expired'] = $license['expires_at'] && strtotime($license['expires_at']) < time();
$license['activations_remaining'] = $license['activation_limit'] > 0
$license['activations_remaining'] = $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'])
: -1;
return $license;
}
/**
* OAuth validate endpoint - validates license key and state for OAuth flow
*/
public static function oauth_validate(WP_REST_Request $request)
{
$license_key = sanitize_text_field($request->get_param('license_key'));
$state = sanitize_text_field($request->get_param('state'));
if (empty($license_key)) {
return new WP_Error('missing_license_key', __('License key is required.', 'woonoow'), ['status' => 400]);
}
// Get license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
return new WP_Error('license_not_found', __('License key not found.', 'woonoow'), ['status' => 404]);
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ($license['user_id'] != $current_user_id) {
return new WP_Error('unauthorized', __('This license does not belong to your account.', 'woonoow'), ['status' => 403]);
}
// Verify state token if provided
if (!empty($state)) {
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
return new WP_Error('invalid_state', __('Invalid or expired state token.', 'woonoow'), ['status' => 400]);
}
}
// Get product info
$product = wc_get_product($license['product_id']);
return new WP_REST_Response([
'license_key' => $license['license_key'],
'product_id' => $license['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'woonoow'),
'status' => $license['status'],
'activation_limit' => $license['activation_limit'],
'activation_count' => $license['activation_count'],
'expires_at' => $license['expires_at'],
]);
}
/**
* OAuth confirm endpoint - confirms license activation and returns token
*/
public static function oauth_confirm(WP_REST_Request $request)
{
$data = $request->get_json_params();
$license_key = sanitize_text_field($data['license_key'] ?? '');
$site_url = esc_url_raw($data['site_url'] ?? '');
$state = sanitize_text_field($data['state'] ?? '');
$nonce = sanitize_text_field($data['nonce'] ?? '');
if (empty($license_key) || empty($site_url) || empty($state)) {
return new WP_Error('missing_params', __('Missing required parameters.', 'woonoow'), ['status' => 400]);
}
// Get license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
return new WP_Error('license_not_found', __('License key not found.', 'woonoow'), ['status' => 404]);
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ($license['user_id'] != $current_user_id) {
return new WP_Error('unauthorized', __('This license does not belong to your account.', 'woonoow'), ['status' => 403]);
}
// Verify state token
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
return new WP_Error('invalid_state', __('Invalid or expired state token.', 'woonoow'), ['status' => 400]);
}
// Generate activation token
$token_data = LicenseManager::generate_activation_token($license['id'], $site_url);
if (is_wp_error($token_data)) {
return $token_data;
}
$activation_token = $token_data['token'];
// Build return URL with token
$return_url = $state_data['return_url'] ?? $site_url;
$redirect_url = add_query_arg([
'activation_token' => $activation_token,
'license_key' => $license_key,
'nonce' => $nonce,
], $return_url);
return new WP_REST_Response([
'success' => true,
'redirect_url' => $redirect_url,
'activation_token' => $activation_token,
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,9 @@ class EmailRenderer
$to = $this->get_recipient_email($recipient_type, $data);
if (!$to) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailRenderer] Failed to get recipient email for event: ' . $event_id);
}
return null;
}
@@ -125,14 +128,48 @@ class EmailRenderer
}
// Customer
if ($data instanceof WC_Order) {
if ($data instanceof \WC_Order) {
return $data->get_billing_email();
}
if ($data instanceof WC_Customer) {
if ($data instanceof \WC_Customer) {
return $data->get_email();
}
if ($data instanceof \WP_User) {
return $data->user_email;
}
// Handle array data (e.g. subscription notifications)
if (is_array($data)) {
// Check for customer object in array
if (isset($data['customer'])) {
if ($data['customer'] instanceof \WP_User) {
return $data['customer']->user_email;
}
if ($data['customer'] instanceof \WC_Customer) {
return $data['customer']->get_email();
}
}
// Check for direct email in data
if (isset($data['email']) && is_email($data['email'])) {
return $data['email'];
}
// Check for user_id
if (isset($data['user_id'])) {
$user = get_user_by('id', $data['user_id']);
if ($user) {
return $user->user_email;
}
}
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[EmailRenderer] Could not determine recipient email for type: ' . $recipient_type);
}
return null;
}
@@ -292,6 +329,46 @@ class EmailRenderer
]);
}
// Subscription variables (passed as array)
if (is_array($data) && isset($data['subscription'])) {
$sub = $data['subscription'];
// subscription object usually has: id, user_id, product_id, status, ...
$sub_variables = [
'subscription_id' => $sub->id ?? '',
'subscription_status' => isset($sub->status) ? ucfirst($sub->status) : '',
'billing_period' => isset($sub->billing_period) ? ucfirst($sub->billing_period) : '',
'recurring_amount' => isset($sub->recurring_amount) ? wc_price($sub->recurring_amount) : '',
'next_payment_date' => isset($sub->next_payment_date) ? date('F j, Y', strtotime($sub->next_payment_date)) : 'N/A',
'end_date' => isset($sub->end_date) ? date('F j, Y', strtotime($sub->end_date)) : 'N/A',
'cancel_reason' => $data['reason'] ?? '',
'failed_count' => $data['failed_count'] ?? 0,
'payment_link' => $data['payment_link'] ?? '',
];
// Get product name if not already set
if (!isset($variables['product_name']) && isset($data['product']) && $data['product'] instanceof \WC_Product) {
$sub_variables['product_name'] = $data['product']->get_name();
$sub_variables['product_url'] = get_permalink($data['product']->get_id());
}
// Get customer details if not already set
if (!isset($variables['customer_name']) && isset($data['customer']) && $data['customer'] instanceof \WP_User) {
$user = $data['customer'];
$sub_variables['customer_name'] = $user->display_name;
$sub_variables['customer_email'] = $user->user_email;
}
$variables = array_merge($variables, $sub_variables);
} else if (is_array($data) && isset($data['customer']) && $data['customer'] instanceof \WP_User) {
// Basic user data passed in array without subscription (e.g. generalized notification)
$user = $data['customer'];
$variables = array_merge($variables, [
'customer_name' => $user->display_name,
'customer_email' => $user->user_email,
]);
}
// Merge extra data
$variables = array_merge($variables, $extra_data);

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
use WP_REST_Request;
@@ -79,7 +80,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'update_cart'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'cart_item_key' => [
'required' => true,
@@ -97,7 +99,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'remove_from_cart'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'cart_item_key' => [
'required' => true,
@@ -111,7 +114,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'apply_coupon'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'coupon_code' => [
'required' => true,
@@ -125,7 +129,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'clear_cart'],
'permission_callback' => function () {
return true; },
return true;
},
]);
// Remove coupon
@@ -133,7 +138,8 @@ class CartController
'methods' => 'POST',
'callback' => [__CLASS__, 'remove_coupon'],
'permission_callback' => function () {
return true; },
return true;
},
'args' => [
'coupon_code' => [
'required' => true,
@@ -227,6 +233,12 @@ class CartController
if (!empty($value)) {
$variation_attributes[$meta_key] = $value;
} else {
// Value is empty ("Any" variation) - check if frontend sent value in 'variation' param
$frontend_variation = $request->get_param('variation');
if (is_array($frontend_variation) && isset($frontend_variation[$meta_key]) && !empty($frontend_variation[$meta_key])) {
$variation_attributes[$meta_key] = sanitize_text_field($frontend_variation[$meta_key]);
}
}
}
}

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
use WP_REST_Request;
@@ -9,14 +10,16 @@ use WP_Error;
* Shop Controller - Customer-facing product catalog API
* Handles product listing, search, and categories for customer-spa
*/
class ShopController {
class ShopController
{
/**
* Register REST API routes
*/
public static function register_routes() {
public static function register_routes()
{
$namespace = 'woonoow/v1';
// Get products (public)
register_rest_route($namespace, '/shop/products', [
'methods' => 'GET',
@@ -53,7 +56,7 @@ class ShopController {
],
],
]);
// Get single product (public)
register_rest_route($namespace, '/shop/products/(?P<id>\d+)', [
'methods' => 'GET',
@@ -61,20 +64,20 @@ class ShopController {
'permission_callback' => '__return_true',
'args' => [
'id' => [
'validate_callback' => function($param) {
'validate_callback' => function ($param) {
return is_numeric($param);
},
],
],
]);
// Get categories (public)
register_rest_route($namespace, '/shop/categories', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_categories'],
'permission_callback' => '__return_true',
]);
// Search products (public)
register_rest_route($namespace, '/shop/search', [
'methods' => 'GET',
@@ -88,11 +91,12 @@ class ShopController {
],
]);
}
/**
* Get products list
*/
public static function get_products(WP_REST_Request $request) {
public static function get_products(WP_REST_Request $request)
{
$page = $request->get_param('page');
$per_page = $request->get_param('per_page');
$category = $request->get_param('category');
@@ -102,7 +106,7 @@ class ShopController {
$slug = $request->get_param('slug');
$include = $request->get_param('include');
$exclude = $request->get_param('exclude');
$args = [
'post_type' => 'product',
'post_status' => 'publish',
@@ -111,25 +115,25 @@ class ShopController {
'orderby' => $orderby,
'order' => $order,
];
// Add slug filter (for single product lookup)
if (!empty($slug)) {
$args['name'] = $slug;
}
// Add include filter (specific product IDs)
if (!empty($include)) {
$ids = array_map('intval', explode(',', $include));
$args['post__in'] = $ids;
$args['orderby'] = 'post__in'; // Maintain order of IDs
}
// Add exclude filter (exclude specific product IDs)
if (!empty($exclude)) {
$ids = array_map('intval', explode(',', $exclude));
$args['post__not_in'] = $ids;
}
// Add category filter
if (!empty($category)) {
// Check if category is numeric (ID) or string (slug)
@@ -142,23 +146,23 @@ class ShopController {
],
];
}
// Add search
if (!empty($search)) {
$args['s'] = $search;
}
$query = new \WP_Query($args);
// Check if this is a single product request (by slug)
$is_single = !empty($slug);
$products = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$product = wc_get_product(get_the_ID());
if ($product) {
// Return detailed data for single product requests
$products[] = self::format_product($product, $is_single);
@@ -166,7 +170,7 @@ class ShopController {
}
wp_reset_postdata();
}
return new WP_REST_Response([
'products' => $products,
'total' => $query->found_posts,
@@ -175,34 +179,36 @@ class ShopController {
'per_page' => $per_page,
], 200);
}
/**
* Get single product
*/
public static function get_product(WP_REST_Request $request) {
public static function get_product(WP_REST_Request $request)
{
$product_id = $request->get_param('id');
$product = wc_get_product($product_id);
if (!$product) {
return new WP_Error('product_not_found', 'Product not found', ['status' => 404]);
}
return new WP_REST_Response(self::format_product($product, true), 200);
}
/**
* Get categories
*/
public static function get_categories(WP_REST_Request $request) {
public static function get_categories(WP_REST_Request $request)
{
$terms = get_terms([
'taxonomy' => 'product_cat',
'hide_empty' => true,
]);
if (is_wp_error($terms)) {
return new WP_Error('categories_error', 'Failed to get categories', ['status' => 500]);
}
$categories = [];
foreach ($terms as $term) {
$thumbnail_id = get_term_meta($term->term_id, 'thumbnail_id', true);
@@ -214,45 +220,47 @@ class ShopController {
'image' => $thumbnail_id ? wp_get_attachment_url($thumbnail_id) : '',
];
}
return new WP_REST_Response($categories, 200);
}
/**
* Search products
*/
public static function search_products(WP_REST_Request $request) {
public static function search_products(WP_REST_Request $request)
{
$search = $request->get_param('s');
$args = [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 10,
's' => $search,
];
$query = new \WP_Query($args);
$products = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$product = wc_get_product(get_the_ID());
if ($product) {
$products[] = self::format_product($product);
}
}
wp_reset_postdata();
}
return new WP_REST_Response($products, 200);
}
/**
* Format product data for API response
*/
private static function format_product($product, $detailed = false) {
private static function format_product($product, $detailed = false)
{
$data = [
'id' => $product->get_id(),
'name' => $product->get_name(),
@@ -272,18 +280,18 @@ class ShopController {
'virtual' => $product->is_virtual(),
'downloadable' => $product->is_downloadable(),
];
// Add detailed info if requested
if ($detailed) {
$data['description'] = $product->get_description();
$data['short_description'] = $product->get_short_description();
$data['sku'] = $product->get_sku();
$data['tags'] = wp_get_post_terms($product->get_id(), 'product_tag', ['fields' => 'names']);
// Gallery images
$gallery_ids = $product->get_gallery_image_ids();
$data['gallery'] = array_map('wp_get_attachment_url', $gallery_ids);
// Images array (featured + gallery) for frontend
$images = [];
if ($data['image']) {
@@ -291,39 +299,41 @@ class ShopController {
}
$images = array_merge($images, $data['gallery']);
$data['images'] = $images;
// Attributes and Variations for variable products
if ($product->is_type('variable')) {
$data['attributes'] = self::get_product_attributes($product);
$data['variations'] = self::get_product_variations($product);
}
// Related products
$related_ids = wc_get_related_products($product->get_id(), 4);
$data['related_products'] = array_map(function($id) {
$data['related_products'] = array_map(function ($id) {
$related = wc_get_product($id);
return $related ? self::format_product($related) : null;
}, $related_ids);
$data['related_products'] = array_filter($data['related_products']);
}
return $data;
}
/**
* Get product attributes
*/
private static function get_product_attributes($product) {
private static function get_product_attributes($product)
{
$attributes = [];
foreach ($product->get_attributes() as $attribute) {
$attribute_data = [
'name' => wc_attribute_label($attribute->get_name()),
'slug' => sanitize_title($attribute->get_name()),
'options' => [],
'visible' => $attribute->get_visible(),
'variation' => $attribute->get_variation(),
];
// Get attribute options
if ($attribute->is_taxonomy()) {
$terms = wc_get_product_terms($product->get_id(), $attribute->get_name(), ['fields' => 'names']);
@@ -331,39 +341,29 @@ class ShopController {
} else {
$attribute_data['options'] = $attribute->get_options();
}
$attributes[] = $attribute_data;
}
return $attributes;
}
/**
* Get product variations
*/
private static function get_product_variations($product) {
private static function get_product_variations($product)
{
$variations = [];
foreach ($product->get_available_variations() as $variation) {
$variation_obj = wc_get_product($variation['variation_id']);
if ($variation_obj) {
// Get attributes directly from post meta (most reliable)
$attributes = [];
// Use attributes directly from WooCommerce's get_available_variations()
// This correctly handles custom attributes, taxonomy attributes, and "Any" selections
$attributes = $variation['attributes'];
$variation_id = $variation['variation_id'];
// Query all post meta for this variation
global $wpdb;
$meta_rows = $wpdb->get_results($wpdb->prepare(
"SELECT meta_key, meta_value FROM {$wpdb->postmeta}
WHERE post_id = %d AND meta_key LIKE 'attribute_%%'",
$variation_id
));
foreach ($meta_rows as $row) {
$attributes[$row->meta_key] = $row->meta_value;
}
$variations[] = [
'id' => $variation_id,
'attributes' => $attributes,
@@ -376,7 +376,7 @@ class ShopController {
];
}
}
return $variations;
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* License Manager
*
@@ -13,53 +14,57 @@ if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class LicenseManager {
class LicenseManager
{
private static $table_name = 'woonoow_licenses';
private static $activations_table = 'woonoow_license_activations';
/**
* Initialize
*/
public static function init() {
public static function init()
{
// Only initialize if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
// Hook into order completion - multiple hooks to catch all scenarios
add_action('woocommerce_order_status_completed', [__CLASS__, 'generate_licenses_for_order']);
add_action('woocommerce_order_status_processing', [__CLASS__, 'generate_licenses_for_order']);
add_action('woocommerce_payment_complete', [__CLASS__, 'generate_licenses_for_order']);
// Also hook into thank you page for COD/pending orders (with lower priority)
add_action('woocommerce_thankyou', [__CLASS__, 'maybe_generate_on_thankyou'], 10);
}
/**
* Maybe generate licenses on thank you page (for COD and pending orders)
*/
public static function maybe_generate_on_thankyou($order_id) {
public static function maybe_generate_on_thankyou($order_id)
{
if (!$order_id) return;
$order = wc_get_order($order_id);
if (!$order) return;
// Only generate for orders that didn't already get licenses via status hooks
// Check if it's a virtual-only order that might skip payment completion
$needs_payment = $order->needs_payment();
$is_virtual = self::is_virtual_order($order);
// Generate if: virtual order OR already paid (processing/completed)
if ($is_virtual || in_array($order->get_status(), ['processing', 'completed'])) {
self::generate_licenses_for_order($order_id);
}
}
/**
* Check if order contains only virtual items
*/
private static function is_virtual_order($order) {
private static function is_virtual_order($order)
{
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && !$product->is_virtual()) {
@@ -68,19 +73,20 @@ class LicenseManager {
}
return true;
}
/**
* Create database tables
*/
public static function create_tables() {
public static function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$licenses_table = $wpdb->prefix . self::$table_name;
$activations_table = $wpdb->prefix . self::$activations_table;
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
// Create licenses table - dbDelta requires each CREATE TABLE to be called separately
$sql_licenses = "CREATE TABLE $licenses_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -102,9 +108,9 @@ class LicenseManager {
KEY user_id (user_id),
KEY status (status)
) $charset_collate;";
dbDelta($sql_licenses);
// Create activations table
$sql_activations = "CREATE TABLE $activations_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
@@ -120,44 +126,45 @@ class LicenseManager {
KEY license_id (license_id),
KEY status (status)
) $charset_collate;";
dbDelta($sql_activations);
}
/**
* Generate licenses for completed order
*/
public static function generate_licenses_for_order($order_id) {
public static function generate_licenses_for_order($order_id)
{
$order = wc_get_order($order_id);
if (!$order) return;
foreach ($order->get_items() as $item_id => $item) {
$product_id = $item->get_product_id();
$product = wc_get_product($product_id);
if (!$product) continue;
// Check if product has licensing enabled
$licensing_enabled = get_post_meta($product_id, '_woonoow_licensing_enabled', true);
if ($licensing_enabled !== 'yes') continue;
// Check if license already exists for this order item
if (self::license_exists_for_order_item($item_id)) continue;
// Get activation limit from product or default
$activation_limit = (int) get_post_meta($product_id, '_woonoow_license_activation_limit', true);
if ($activation_limit <= 0) {
$activation_limit = (int) get_option('woonoow_licensing_default_activation_limit', 1);
}
// Get expiry from product or default
$expiry_days = (int) get_post_meta($product_id, '_woonoow_license_expiry_days', true);
if ($expiry_days <= 0 && get_option('woonoow_licensing_license_expiry_enabled', false)) {
$expiry_days = (int) get_option('woonoow_licensing_default_expiry_days', 365);
}
$expires_at = $expiry_days > 0 ? gmdate('Y-m-d H:i:s', strtotime("+$expiry_days days")) : null;
// Generate license for each quantity
$quantity = $item->get_quantity();
for ($i = 0; $i < $quantity; $i++) {
@@ -172,29 +179,31 @@ class LicenseManager {
}
}
}
/**
* Check if license already exists for order item
*/
public static function license_exists_for_order_item($order_item_id) {
public static function license_exists_for_order_item($order_item_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return (bool) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $table WHERE order_item_id = %d",
$order_item_id
));
}
/**
* Create a new license
*/
public static function create_license($data) {
public static function create_license($data)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$license_key = self::generate_license_key();
$wpdb->insert($table, [
'license_key' => $license_key,
'product_id' => $data['product_id'],
@@ -205,24 +214,25 @@ class LicenseManager {
'expires_at' => $data['expires_at'] ?? null,
'status' => 'active',
]);
$license_id = $wpdb->insert_id;
do_action('woonoow/license/created', $license_id, $license_key, $data);
return [
'id' => $license_id,
'license_key' => $license_key,
];
}
/**
* Generate license key
*/
public static function generate_license_key() {
public static function generate_license_key()
{
$format = get_option('woonoow_licensing_license_key_format', 'serial');
$prefix = get_option('woonoow_licensing_license_key_prefix', '');
switch ($format) {
case 'uuid':
$key = wp_generate_uuid4();
@@ -241,80 +251,84 @@ class LicenseManager {
));
break;
}
return $prefix . $key;
}
/**
* Get license by key
*/
public static function get_license_by_key($license_key) {
public static function get_license_by_key($license_key)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE license_key = %s",
$license_key
), ARRAY_A);
}
/**
* Get license by ID
*/
public static function get_license($license_id) {
public static function get_license($license_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE id = %d",
$license_id
), ARRAY_A);
}
/**
* Get licenses for user
*/
public static function get_user_licenses($user_id, $args = []) {
public static function get_user_licenses($user_id, $args = [])
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$defaults = [
'status' => null,
'limit' => 50,
'offset' => 0,
];
$args = wp_parse_args($args, $defaults);
$where = "user_id = %d";
$params = [$user_id];
if ($args['status']) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
$sql = "SELECT * FROM $table WHERE $where ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $args['limit'];
$params[] = $args['offset'];
return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
}
/**
* Activate license
*/
public static function activate($license_key, $activation_data = []) {
public static function activate($license_key, $activation_data = [])
{
global $wpdb;
$license = self::get_license_by_key($license_key);
if (!$license) {
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
}
if ($license['status'] !== 'active') {
return new \WP_Error('license_inactive', __('License is not active', 'woonoow'));
}
// Check expiry
if ($license['expires_at'] && strtotime($license['expires_at']) < time()) {
$block_expired = get_option('woonoow_licensing_block_expired_activations', true);
@@ -322,22 +336,52 @@ class LicenseManager {
return new \WP_Error('license_expired', __('License has expired', 'woonoow'));
}
}
// Check subscription status if linked
$subscription_status = self::get_order_subscription_status($license['order_id']);
if ($subscription_status !== null && !in_array($subscription_status, ['active', 'pending-cancel'])) {
return new \WP_Error('subscription_inactive', __('Subscription is not active', 'woonoow'));
}
// Check activation limit
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
return new \WP_Error('activation_limit_reached', __('Activation limit reached', 'woonoow'));
}
// Check if product requires OAuth activation
// Get licensing module settings
$licensing_settings = get_option('woonoow_module_licensing_settings', []);
// 1. Get site-level setting (default)
$activation_method = $licensing_settings['activation_method'] ?? 'api';
// 2. Check for product-level override (only if allow_product_override is enabled)
$allow_override = $licensing_settings['allow_product_override'] ?? false;
if ($allow_override) {
$product_method = get_post_meta($license['product_id'], '_woonoow_license_activation_method', true);
if (!empty($product_method)) {
$activation_method = $product_method;
}
}
if ($activation_method === 'oauth') {
// Check if this is an OAuth callback (has valid activation token)
if (!empty($activation_data['activation_token'])) {
$validated = self::validate_activation_token($activation_data['activation_token'], $license_key);
if (is_wp_error($validated)) {
return $validated;
}
// Token is valid, proceed with activation
} else {
// Not a callback, return redirect URL for OAuth flow
return self::build_oauth_redirect_response($license, $activation_data);
}
}
// Create activation record
$activations_table = $wpdb->prefix . self::$activations_table;
$licenses_table = $wpdb->prefix . self::$table_name;
$wpdb->insert($activations_table, [
'license_id' => $license['id'],
'domain' => $activation_data['domain'] ?? null,
@@ -346,48 +390,186 @@ class LicenseManager {
'user_agent' => $activation_data['user_agent'] ?? null,
'status' => 'active',
]);
// Increment activation count
$wpdb->query($wpdb->prepare(
"UPDATE $licenses_table SET activation_count = activation_count + 1 WHERE id = %d",
$license['id']
));
do_action('woonoow/license/activated', $license['id'], $activation_data);
return [
'success' => true,
'activation_id' => $wpdb->insert_id,
'activations_remaining' => $license['activation_limit'] > 0
'activations_remaining' => $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'] - 1)
: -1,
];
}
/**
* Deactivate license
* Build OAuth redirect response for license activation
*/
public static function deactivate($license_key, $activation_id = null, $machine_id = null) {
private static function build_oauth_redirect_response($license, $activation_data)
{
// Generate state token for CSRF protection
$state = self::generate_oauth_state($license['license_key'], $activation_data['domain'] ?? '');
// Build redirect URL to vendor site
$connect_url = home_url('/my-account/license-connect/');
$redirect_url = add_query_arg([
'license_key' => $license['license_key'],
'site_url' => $activation_data['domain'] ?? '',
'return_url' => $activation_data['return_url'] ?? '',
'state' => $state,
'nonce' => wp_create_nonce('woonoow_oauth_connect'),
], $connect_url);
return [
'success' => false,
'code' => 'oauth_required',
'message' => __('This license requires account verification. You will be redirected to complete activation.', 'woonoow'),
'redirect_url' => $redirect_url,
];
}
/**
* Generate OAuth state token
*/
public static function generate_oauth_state($license_key, $domain)
{
$data = [
'license_key' => $license_key,
'domain' => $domain,
'timestamp' => time(),
];
$payload = base64_encode(wp_json_encode($data));
$signature = hash_hmac('sha256', $payload, wp_salt('auth'));
return $payload . '.' . $signature;
}
/**
* Verify OAuth state token
*/
public static function verify_oauth_state($state)
{
$parts = explode('.', $state, 2);
if (count($parts) !== 2) {
return false;
}
list($payload, $signature) = $parts;
$expected_signature = hash_hmac('sha256', $payload, wp_salt('auth'));
if (!hash_equals($expected_signature, $signature)) {
return false;
}
$data = json_decode(base64_decode($payload), true);
if (!$data) {
return false;
}
// Check timestamp (10 minute expiry)
if (empty($data['timestamp']) || (time() - $data['timestamp']) > 600) {
return false;
}
return $data;
}
/**
* Generate activation token (short-lived, single-use)
*/
public static function generate_activation_token($license_id, $domain)
{
global $wpdb;
$token = wp_generate_password(32, false);
$expires_at = gmdate('Y-m-d H:i:s', time() + 300); // 5 minute expiry
// Store token in activations table temporarily
$table = $wpdb->prefix . self::$activations_table;
$wpdb->insert($table, [
'license_id' => $license_id,
'domain' => $domain,
'machine_id' => 'oauth_token:' . $token,
'status' => 'pending',
'user_agent' => 'OAuth activation token expires: ' . $expires_at,
]);
return [
'token' => $token,
'activation_id' => $wpdb->insert_id,
];
}
/**
* Validate activation token
*/
private static function validate_activation_token($token, $license_key)
{
global $wpdb;
$license = self::get_license_by_key($license_key);
if (!$license) {
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
}
$table = $wpdb->prefix . self::$activations_table;
$activation = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE license_id = %d AND machine_id = %s AND status = 'pending' LIMIT 1",
$license['id'],
'oauth_token:' . $token
), ARRAY_A);
if (!$activation) {
return new \WP_Error('invalid_token', __('Invalid or expired activation token', 'woonoow'));
}
// Check expiry from user_agent field
if (preg_match('/expires: (.+)$/', $activation['user_agent'], $matches)) {
$expires_at = strtotime($matches[1]);
if ($expires_at && time() > $expires_at) {
// Delete expired token
$wpdb->delete($table, ['id' => $activation['id']]);
return new \WP_Error('token_expired', __('Activation token has expired', 'woonoow'));
}
}
// Delete the pending record (it will be replaced by actual activation)
$wpdb->delete($table, ['id' => $activation['id']]);
return true;
}
/**
* Deactivate license
*/
public static function deactivate($license_key, $activation_id = null, $machine_id = null)
{
global $wpdb;
$license = self::get_license_by_key($license_key);
if (!$license) {
return new \WP_Error('invalid_license', __('Invalid license key', 'woonoow'));
}
// Check if deactivation is allowed
$allow_deactivation = get_option('woonoow_licensing_allow_deactivation', true);
if (!$allow_deactivation) {
return new \WP_Error('deactivation_disabled', __('License deactivation is disabled', 'woonoow'));
}
$activations_table = $wpdb->prefix . self::$activations_table;
$licenses_table = $wpdb->prefix . self::$table_name;
// Find activation to deactivate
$where = "license_id = %d AND status = 'active'";
$params = [$license['id']];
if ($activation_id) {
$where .= " AND id = %d";
$params[] = $activation_id;
@@ -395,40 +577,41 @@ class LicenseManager {
$where .= " AND machine_id = %s";
$params[] = $machine_id;
}
$activation = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $activations_table WHERE $where LIMIT 1",
$params
), ARRAY_A);
if (!$activation) {
return new \WP_Error('no_activation', __('No active activation found', 'woonoow'));
}
// Deactivate
$wpdb->update(
$activations_table,
['status' => 'deactivated', 'deactivated_at' => current_time('mysql')],
['id' => $activation['id']]
);
// Decrement activation count
$wpdb->query($wpdb->prepare(
"UPDATE $licenses_table SET activation_count = GREATEST(0, activation_count - 1) WHERE id = %d",
$license['id']
));
do_action('woonoow/license/deactivated', $license['id'], $activation['id']);
return ['success' => true];
}
/**
* Validate license (check if valid without activating)
*/
public static function validate($license_key) {
public static function validate($license_key)
{
$license = self::get_license_by_key($license_key);
if (!$license) {
return [
'valid' => false,
@@ -436,20 +619,20 @@ class LicenseManager {
'message' => __('Invalid license key', 'woonoow'),
];
}
$is_expired = $license['expires_at'] && strtotime($license['expires_at']) < time();
// Check subscription status if linked
$subscription_status = self::get_order_subscription_status($license['order_id']);
$is_subscription_valid = $subscription_status === null || in_array($subscription_status, ['active', 'pending-cancel']);
return [
'valid' => $license['status'] === 'active' && !$is_expired && $is_subscription_valid,
'license_key' => $license['license_key'],
'status' => $license['status'],
'activation_limit' => (int) $license['activation_limit'],
'activation_count' => (int) $license['activation_count'],
'activations_remaining' => $license['activation_limit'] > 0
'activations_remaining' => $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'])
: -1,
'expires_at' => $license['expires_at'],
@@ -458,76 +641,79 @@ class LicenseManager {
'subscription_active' => $is_subscription_valid,
];
}
/**
* Check if an order has a linked subscription and return its status
*
* @param int $order_id
* @return string|null Subscription status or null if no subscription
*/
public static function get_order_subscription_status($order_id) {
public static function get_order_subscription_status($order_id)
{
// Check if subscription module is enabled
if (!ModuleRegistry::is_enabled('subscription')) {
return null;
}
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscription_orders';
// Check if table exists
$table_exists = $wpdb->get_var("SHOW TABLES LIKE '$table'");
if (!$table_exists) {
return null;
}
// Find subscription linked to this order
$subscription_id = $wpdb->get_var($wpdb->prepare(
"SELECT subscription_id FROM $table WHERE order_id = %d LIMIT 1",
$order_id
));
if (!$subscription_id) {
return null;
}
// Get subscription status
$subscriptions_table = $wpdb->prefix . 'woonoow_subscriptions';
$status = $wpdb->get_var($wpdb->prepare(
"SELECT status FROM $subscriptions_table WHERE id = %d",
$subscription_id
));
return $status;
}
/**
* Revoke license
*/
public static function revoke($license_id) {
public static function revoke($license_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$result = $wpdb->update(
$table,
['status' => 'revoked'],
['id' => $license_id]
);
if ($result !== false) {
do_action('woonoow/license/revoked', $license_id);
return true;
}
return false;
}
/**
* Get all licenses (admin)
*/
public static function get_all_licenses($args = []) {
public static function get_all_licenses($args = [])
{
global $wpdb;
$table = $wpdb->prefix . self::$table_name;
$defaults = [
'search' => '',
'status' => null,
@@ -539,56 +725,57 @@ class LicenseManager {
'order' => 'DESC',
];
$args = wp_parse_args($args, $defaults);
$where_clauses = ['1=1'];
$params = [];
if ($args['search']) {
$where_clauses[] = "license_key LIKE %s";
$params[] = '%' . $wpdb->esc_like($args['search']) . '%';
}
if ($args['status']) {
$where_clauses[] = "status = %s";
$params[] = $args['status'];
}
if ($args['product_id']) {
$where_clauses[] = "product_id = %d";
$params[] = $args['product_id'];
}
if ($args['user_id']) {
$where_clauses[] = "user_id = %d";
$params[] = $args['user_id'];
}
$where = implode(' AND ', $where_clauses);
$orderby = sanitize_sql_orderby($args['orderby'] . ' ' . $args['order']) ?: 'created_at DESC';
$sql = "SELECT * FROM $table WHERE $where ORDER BY $orderby LIMIT %d OFFSET %d";
$params[] = $args['limit'];
$params[] = $args['offset'];
$licenses = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
// Get total count
$count_sql = "SELECT COUNT(*) FROM $table WHERE $where";
$total = $wpdb->get_var($wpdb->prepare($count_sql, array_slice($params, 0, -2)));
return [
'licenses' => $licenses,
'total' => (int) $total,
];
}
/**
* Get activations for a license
*/
public static function get_activations($license_id) {
public static function get_activations($license_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$activations_table;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE license_id = %d ORDER BY activated_at DESC",
$license_id

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licensing Module Bootstrap
*
@@ -12,79 +13,306 @@ if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\LicensingSettings;
class LicensingModule {
class LicensingModule
{
/**
* Initialize the licensing module
*/
public static function init() {
public static function init()
{
// Register settings schema
LicensingSettings::init();
// Initialize license manager immediately since we're already in plugins_loaded
// Note: This is called from woonoow.php inside plugins_loaded action,
// so we can call maybe_init_manager directly instead of scheduling another hook
self::maybe_init_manager();
// Install tables on module enable
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_licensing_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_licensing_fields']);
// License Connect OAuth endpoint
add_action('init', [__CLASS__, 'register_license_connect_endpoint']);
add_action('template_redirect', [__CLASS__, 'handle_license_connect'], 5);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager() {
public static function maybe_init_manager()
{
if (ModuleRegistry::is_enabled('licensing')) {
// Ensure tables exist
self::ensure_tables();
LicenseManager::init();
}
}
/**
* Ensure database tables exist
*/
private static function ensure_tables() {
private static function ensure_tables()
{
global $wpdb;
$table = $wpdb->prefix . 'woonoow_licenses';
// Check if table exists
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
LicenseManager::create_tables();
}
}
/**
* Handle module enable
*/
public static function on_module_enabled($module_id) {
public static function on_module_enabled($module_id)
{
if ($module_id === 'licensing') {
LicenseManager::create_tables();
}
}
/**
* Register license connect rewrite endpoint
*/
public static function register_license_connect_endpoint()
{
add_rewrite_endpoint('license-connect', EP_ROOT | EP_PAGES);
}
/**
* Handle license-connect endpoint (OAuth confirmation page)
*/
public static function handle_license_connect()
{
// Parse the request URI to check if this is the license-connect page
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
$parsed_path = parse_url($request_uri, PHP_URL_PATH);
// Check if path contains license-connect
if (strpos($parsed_path, '/license-connect') === false) {
return;
}
// Get parameters
$license_key = sanitize_text_field($_GET['license_key'] ?? '');
$site_url = esc_url_raw($_GET['site_url'] ?? '');
$return_url = esc_url_raw($_GET['return_url'] ?? '');
$state = sanitize_text_field($_GET['state'] ?? '');
$action = sanitize_text_field($_GET['action'] ?? '');
// Handle form submission (confirmation)
if ($action === 'confirm' && !empty($_POST['confirm_license'])) {
self::process_license_confirmation();
return;
}
// Require login
if (!is_user_logged_in()) {
$login_url = wp_login_url(add_query_arg($_GET, home_url('/my-account/license-connect/')));
wp_redirect($login_url);
exit;
}
// Validate parameters
if (empty($license_key) || empty($site_url) || empty($state)) {
self::render_license_connect_page([
'error' => __('Invalid license connection request. Missing required parameters.', 'woonoow'),
]);
return;
}
// Verify state token
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
self::render_license_connect_page([
'error' => __('Invalid or expired connection request. Please try again.', 'woonoow'),
]);
return;
}
// Get license and verify ownership
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
self::render_license_connect_page([
'error' => __('License key not found.', 'woonoow'),
]);
return;
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ((int)$license['user_id'] !== $current_user_id) {
self::render_license_connect_page([
'error' => __('This license does not belong to your account.', 'woonoow'),
]);
return;
}
// Check license status
if ($license['status'] !== 'active') {
self::render_license_connect_page([
'error' => __('This license is not active.', 'woonoow'),
]);
return;
}
// Check activation limit
if ($license['activation_limit'] > 0 && $license['activation_count'] >= $license['activation_limit']) {
self::render_license_connect_page([
'error' => sprintf(
__('Activation limit reached (%d/%d sites).', 'woonoow'),
$license['activation_count'],
$license['activation_limit']
),
]);
return;
}
// Get product info
$product = wc_get_product($license['product_id']);
$product_name = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
// Render confirmation page
self::render_license_connect_page([
'license' => $license,
'product_name' => $product_name,
'site_url' => $site_url,
'return_url' => $return_url,
'state' => $state,
]);
}
/**
* Process license confirmation form submission
*/
private static function process_license_confirmation()
{
if (!is_user_logged_in()) {
wp_die(__('You must be logged in.', 'woonoow'));
}
// Verify nonce
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'woonoow_license_connect')) {
wp_die(__('Security check failed.', 'woonoow'));
}
$license_key = sanitize_text_field($_POST['license_key'] ?? '');
$site_url = esc_url_raw($_POST['site_url'] ?? '');
$return_url = esc_url_raw($_POST['return_url'] ?? '');
$state = sanitize_text_field($_POST['state'] ?? '');
// Verify state
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
wp_die(__('Invalid or expired request.', 'woonoow'));
}
// Get and verify license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license || (int)$license['user_id'] !== get_current_user_id()) {
wp_die(__('Invalid license.', 'woonoow'));
}
// Generate activation token
$token_data = LicenseManager::generate_activation_token($license['id'], $site_url);
// Build return URL with token
$callback_url = add_query_arg([
'activation_token' => $token_data['token'],
'license_key' => $license_key,
'state' => $state,
], $return_url);
// Redirect back to client site
wp_redirect($callback_url);
exit;
}
/**
* Render license connect confirmation page
*/
private static function render_license_connect_page($args)
{
// Set headers
status_header(200);
nocache_headers();
// Include WP header
get_header('woonoow');
echo '<div class="woonoow-license-connect" style="max-width: 600px; margin: 40px auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif;">';
if (!empty($args['error'])) {
echo '<div style="background: #fee; border: 1px solid #c00; padding: 15px; border-radius: 4px; margin-bottom: 20px;">';
echo '<strong>' . esc_html__('Error', 'woonoow') . ':</strong> ' . esc_html($args['error']);
echo '</div>';
echo '<a href="' . esc_url(home_url()) . '" style="color: #0073aa;">&larr; ' . esc_html__('Return Home', 'woonoow') . '</a>';
} else {
$license = $args['license'];
$activations_remaining = $license['activation_limit'] > 0
? $license['activation_limit'] - $license['activation_count']
: '∞';
echo '<h1 style="font-size: 24px; margin-bottom: 20px;">' . esc_html__('Connect Site to License', 'woonoow') . '</h1>';
echo '<div style="background: #f8f9fa; border: 1px solid #e5e7eb; padding: 20px; border-radius: 8px; margin-bottom: 20px;">';
echo '<table style="width: 100%; border-collapse: collapse;">';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Site', 'woonoow') . ':</td><td style="padding: 8px 0; font-weight: 600;">' . esc_html($args['site_url']) . '</td></tr>';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Product', 'woonoow') . ':</td><td style="padding: 8px 0; font-weight: 600;">' . esc_html($args['product_name']) . '</td></tr>';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('License', 'woonoow') . ':</td><td style="padding: 8px 0; font-family: monospace;">' . esc_html($license['license_key']) . '</td></tr>';
echo '<tr><td style="padding: 8px 0; color: #666;">' . esc_html__('Activations', 'woonoow') . ':</td><td style="padding: 8px 0;">' . esc_html($license['activation_count']) . '/' . ($license['activation_limit'] ?: '∞') . ' ' . esc_html__('used', 'woonoow') . '</td></tr>';
echo '</table>';
echo '</div>';
echo '<form method="post" action="' . esc_url(add_query_arg('action', 'confirm', home_url('/my-account/license-connect/'))) . '">';
echo wp_nonce_field('woonoow_license_connect', '_wpnonce', true, false);
echo '<input type="hidden" name="license_key" value="' . esc_attr($license['license_key']) . '">';
echo '<input type="hidden" name="site_url" value="' . esc_attr($args['site_url']) . '">';
echo '<input type="hidden" name="return_url" value="' . esc_attr($args['return_url']) . '">';
echo '<input type="hidden" name="state" value="' . esc_attr($args['state']) . '">';
echo '<div style="display: flex; gap: 10px;">';
echo '<button type="submit" name="confirm_license" value="1" style="background: #2563eb; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer;">';
echo esc_html__('Connect This Site', 'woonoow');
echo '</button>';
echo '<a href="' . esc_url($args['return_url'] ?: home_url()) . '" style="background: #e5e7eb; color: #374151; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; text-decoration: none;">';
echo esc_html__('Cancel', 'woonoow');
echo '</a>';
echo '</div>';
echo '</form>';
}
echo '</div>';
get_footer('woonoow');
exit;
}
/**
* Add licensing fields to product edit page
*/
public static function add_product_licensing_fields() {
public static function add_product_licensing_fields()
{
global $post;
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
echo '<div class="options_group show_if_simple show_if_downloadable">';
woocommerce_wp_checkbox([
'id' => '_woonoow_licensing_enabled',
'label' => __('Enable Licensing', 'woonoow'),
'description' => __('Generate license keys for this product on purchase', 'woonoow'),
]);
woocommerce_wp_text_input([
'id' => '_woonoow_license_activation_limit',
'label' => __('Activation Limit', 'woonoow'),
@@ -95,7 +323,7 @@ class LicensingModule {
'step' => '1',
],
]);
woocommerce_wp_text_input([
'id' => '_woonoow_license_expiry_days',
'label' => __('License Expiry (Days)', 'woonoow'),
@@ -106,23 +334,48 @@ class LicensingModule {
'step' => '1',
],
]);
// Only show activation method if per-product override is enabled
$licensing_settings = get_option('woonoow_module_licensing_settings', []);
$allow_override = $licensing_settings['allow_product_override'] ?? false;
if ($allow_override) {
woocommerce_wp_select([
'id' => '_woonoow_license_activation_method',
'label' => __('Activation Method', 'woonoow'),
'description' => __('Override site-level setting for this product', 'woonoow'),
'options' => [
'' => __('Use Site Default', 'woonoow'),
'api' => __('Simple API (license key only)', 'woonoow'),
'oauth' => __('Secure OAuth (requires account login)', 'woonoow'),
],
]);
}
echo '</div>';
}
/**
* Save licensing fields
*/
public static function save_product_licensing_fields($post_id) {
public static function save_product_licensing_fields($post_id)
{
$licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled);
if (isset($_POST['_woonoow_license_activation_limit'])) {
update_post_meta($post_id, '_woonoow_license_activation_limit', absint($_POST['_woonoow_license_activation_limit']));
}
if (isset($_POST['_woonoow_license_expiry_days'])) {
update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_POST['_woonoow_license_expiry_days']));
}
if (isset($_POST['_woonoow_license_activation_method'])) {
$method = $_POST['_woonoow_license_activation_method'];
// Accept empty (site default), api, or oauth
if ($method === '' || in_array($method, ['api', 'oauth'])) {
update_post_meta($post_id, '_woonoow_license_activation_method', sanitize_key($method));
}
}
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licensing Module Settings
*
@@ -9,19 +10,22 @@ namespace WooNooW\Modules;
if (!defined('ABSPATH')) exit;
class LicensingSettings {
class LicensingSettings
{
/**
* Initialize the settings
*/
public static function init() {
public static function init()
{
add_filter('woonoow/module_settings_schema', [__CLASS__, 'register_schema']);
}
/**
* Register licensing settings schema
*/
public static function register_schema($schemas) {
public static function register_schema($schemas)
{
$schemas['licensing'] = [
'license_key_format' => [
'type' => 'select',
@@ -88,8 +92,24 @@ class LicensingSettings {
'min' => 1,
'max' => 30,
],
'activation_method' => [
'type' => 'select',
'label' => __('Activation Method', 'woonoow'),
'description' => __('How licenses are activated. OAuth requires user login on your site (anti-piracy).', 'woonoow'),
'options' => [
'api' => __('Simple API (license key only)', 'woonoow'),
'oauth' => __('Secure OAuth (requires account login)', 'woonoow'),
],
'default' => 'api',
],
'allow_product_override' => [
'type' => 'toggle',
'label' => __('Allow Per-Product Override', 'woonoow'),
'description' => __('Show activation method field on each product for individual customization', 'woonoow'),
'default' => false,
],
];
return $schemas;
}
}