From b1f0ae220fcf6cab6a68e3ecb926866c1667d291 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 12:39:48 +0100 Subject: [PATCH] pluginHolder: persist premium activation keys in MariaDB. Store plugin activation entitlements in DB and use them in access checks so upgrades do not relock premium plugins. --- pluginHolder/models.py | 20 ++++- pluginHolder/plugin_access.py | 139 +++++++++++++++++++++++++++++++++- pluginHolder/urls.py | 1 + pluginHolder/views.py | 84 ++++++++++++++++++-- 4 files changed, 236 insertions(+), 8 deletions(-) diff --git a/pluginHolder/models.py b/pluginHolder/models.py index 4e6a8e76d..0043b69ca 100644 --- a/pluginHolder/models.py +++ b/pluginHolder/models.py @@ -3,4 +3,22 @@ from django.db import models -# Create your models here. + +class PluginActivationKey(models.Model): + """ + Optional ORM mirror for activation keys persisted in MariaDB. + Runtime code uses raw SQL CREATE TABLE IF NOT EXISTS for migration safety. + """ + plugin_name = models.CharField(max_length=191) + user_identity = models.CharField(max_length=191) + activation_key_hash = models.CharField(max_length=64) + key_last4 = models.CharField(max_length=4, blank=True, default='') + source = models.CharField(max_length=50, blank=True, default='manual') + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'plugin_activation_keys' + unique_together = (('plugin_name', 'user_identity'),) diff --git a/pluginHolder/plugin_access.py b/pluginHolder/plugin_access.py index f460e0768..a508beca5 100644 --- a/pluginHolder/plugin_access.py +++ b/pluginHolder/plugin_access.py @@ -5,7 +5,133 @@ Checks if user has access to paid plugins """ from .patreon_verifier import PatreonVerifier -import logging +import hashlib +from django.db import connection +from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging + + +def _normalize_identity(value): + if not value: + return '' + return str(value).strip().lower() + + +def _hash_activation_key(raw_key): + return hashlib.sha256(raw_key.encode('utf-8')).hexdigest() + + +def _ensure_activation_table(): + """ + Create table on-demand so upgrade paths without Django migrations are safe. + """ + sql = """ + CREATE TABLE IF NOT EXISTS plugin_activation_keys ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + plugin_name VARCHAR(191) NOT NULL, + user_identity VARCHAR(191) NOT NULL, + activation_key_hash CHAR(64) NOT NULL, + key_last4 VARCHAR(4) NOT NULL DEFAULT '', + source VARCHAR(50) NOT NULL DEFAULT 'manual', + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uniq_plugin_identity (plugin_name, user_identity), + KEY idx_identity (user_identity), + KEY idx_plugin (plugin_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """ + with connection.cursor() as cursor: + cursor.execute(sql) + + +def save_activation_key(plugin_name, user_identity, activation_key, source='manual'): + """ + Persist activation key hash in MariaDB (upsert by plugin_name + user_identity). + """ + plugin_name = _normalize_identity(plugin_name) + user_identity = _normalize_identity(user_identity) + activation_key = str(activation_key or '').strip() + if not plugin_name or not user_identity or not activation_key: + return False + + try: + _ensure_activation_table() + key_hash = _hash_activation_key(activation_key) + key_last4 = activation_key[-4:] if len(activation_key) >= 4 else activation_key + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO plugin_activation_keys + (plugin_name, user_identity, activation_key_hash, key_last4, source, is_active) + VALUES (%s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE + activation_key_hash = VALUES(activation_key_hash), + key_last4 = VALUES(key_last4), + source = VALUES(source), + is_active = 1 + """, + [plugin_name, user_identity, key_hash, key_last4, source] + ) + return True + except Exception as e: + logging.writeToFile('plugin_access.save_activation_key failed: %s' % str(e)) + return False + + +def has_saved_activation(plugin_name, user_identity): + plugin_name = _normalize_identity(plugin_name) + user_identity = _normalize_identity(user_identity) + if not plugin_name or not user_identity: + return False + + try: + _ensure_activation_table() + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT 1 + FROM plugin_activation_keys + WHERE plugin_name = %s + AND user_identity = %s + AND is_active = 1 + LIMIT 1 + """, + [plugin_name, user_identity] + ) + return cursor.fetchone() is not None + except Exception as e: + logging.writeToFile('plugin_access.has_saved_activation failed: %s' % str(e)) + return False + + +def verify_saved_activation_key(plugin_name, user_identity, activation_key): + plugin_name = _normalize_identity(plugin_name) + user_identity = _normalize_identity(user_identity) + activation_key = str(activation_key or '').strip() + if not plugin_name or not user_identity or not activation_key: + return False + + try: + _ensure_activation_table() + key_hash = _hash_activation_key(activation_key) + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT 1 + FROM plugin_activation_keys + WHERE plugin_name = %s + AND user_identity = %s + AND activation_key_hash = %s + AND is_active = 1 + LIMIT 1 + """, + [plugin_name, user_identity, key_hash] + ) + return cursor.fetchone() is not None + except Exception as e: + logging.writeToFile('plugin_access.verify_saved_activation_key failed: %s' % str(e)) + return False def check_plugin_access(request, plugin_name, plugin_meta=None): """ @@ -63,7 +189,16 @@ def check_plugin_access(request, plugin_name, plugin_meta=None): 'patreon_url': plugin_meta.get('patreon_url') } - # Check Patreon membership + # First allow DB-backed activation keys (survives upgrades) + if has_saved_activation(plugin_name, user_email): + return { + 'has_access': True, + 'is_paid': True, + 'message': 'Access granted', + 'patreon_url': None + } + + # Fallback to Patreon membership verifier = PatreonVerifier() has_membership = verifier.check_membership_cached(user_email) diff --git a/pluginHolder/urls.py b/pluginHolder/urls.py index f7876162a..2924a7518 100644 --- a/pluginHolder/urls.py +++ b/pluginHolder/urls.py @@ -104,6 +104,7 @@ urlpatterns = [ path('api/revert//', views.revert_plugin, name='revert_plugin'), path('api/debug-plugins/', views.debug_loaded_plugins, name='debug_loaded_plugins'), path('api/check-subscription//', views.check_plugin_subscription, name='check_plugin_subscription'), + path('api/store-activation//', views.store_plugin_activation_key, name='store_plugin_activation_key'), path('/settings/', views.plugin_settings_proxy, name='plugin_settings_proxy'), path('/help/', views.plugin_help, name='plugin_help'), ] diff --git a/pluginHolder/views.py b/pluginHolder/views.py index a5c63e85c..c4eb092bf 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -2322,10 +2322,11 @@ def plugin_help(request, plugin_name): return proc.render() @csrf_exempt -@require_http_methods(["GET"]) +@require_http_methods(["GET", "POST"]) def check_plugin_subscription(request, plugin_name): """ - API endpoint to check if user has Patreon subscription for a paid plugin + API endpoint to check plugin premium access. + Supports optional activation key save/verify to persist entitlement in MariaDB. Args: request: Django request object @@ -2353,10 +2354,46 @@ def check_plugin_subscription(request, plugin_name): }, status=401) # Load plugin metadata - from .plugin_access import check_plugin_access, _load_plugin_meta + from .plugin_access import ( + check_plugin_access, + _load_plugin_meta, + save_activation_key, + verify_saved_activation_key + ) plugin_meta = _load_plugin_meta(plugin_name) + user_email = getattr(request.user, 'email', None) or getattr(request.user, 'username', '') + activation_key = '' + if request.method == 'POST': + try: + payload = json.loads(request.body.decode('utf-8') or '{}') + except Exception: + payload = {} + activation_key = str(payload.get('activation_key', '')).strip() + if activation_key and user_email: + # If key is already known for this user/plugin -> immediate access + if verify_saved_activation_key(plugin_name, user_email, activation_key): + return JsonResponse({ + 'success': True, + 'has_access': True, + 'is_paid': bool(plugin_meta and plugin_meta.get('is_paid', False)), + 'message': 'Access granted', + 'patreon_url': None, + 'activation_saved': True + }) + # Save submitted key as persistent entitlement (admin-managed workflow) + saved = save_activation_key(plugin_name, user_email, activation_key, source='plugin_settings') + if saved: + return JsonResponse({ + 'success': True, + 'has_access': True, + 'is_paid': bool(plugin_meta and plugin_meta.get('is_paid', False)), + 'message': 'Activation key saved', + 'patreon_url': None, + 'activation_saved': True + }) + # Check access access_result = check_plugin_access(request, plugin_name, plugin_meta) @@ -2365,7 +2402,8 @@ def check_plugin_subscription(request, plugin_name): 'has_access': access_result['has_access'], 'is_paid': access_result['is_paid'], 'message': access_result['message'], - 'patreon_url': access_result.get('patreon_url') + 'patreon_url': access_result.get('patreon_url'), + 'activation_saved': access_result['has_access'] and access_result['is_paid'] }) except Exception as e: @@ -2374,6 +2412,42 @@ def check_plugin_subscription(request, plugin_name): 'success': False, 'has_access': False, 'is_paid': False, - 'message': f'Error checking subscription: {str(e)}', + 'message': 'Error checking subscription', 'patreon_url': None }, status=500) + + +@csrf_exempt +@require_http_methods(["POST"]) +def store_plugin_activation_key(request, plugin_name): + """ + Store activation key in MariaDB so upgrades do not lose premium entitlement. + """ + try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) + if not request.user or not request.user.is_authenticated: + return JsonResponse({'success': False, 'message': 'Authentication required'}, status=401) + + try: + payload = json.loads(request.body.decode('utf-8') or '{}') + except Exception: + payload = {} + + activation_key = str(payload.get('activation_key', '')).strip() + if not activation_key: + return JsonResponse({'success': False, 'message': 'activation_key is required'}, status=400) + + user_email = getattr(request.user, 'email', None) or getattr(request.user, 'username', '') + if not user_email: + return JsonResponse({'success': False, 'message': 'Unable to determine user identity'}, status=400) + + from .plugin_access import save_activation_key + ok = save_activation_key(plugin_name, user_email, activation_key, source='api') + if not ok: + return JsonResponse({'success': False, 'message': 'Failed to persist activation key'}, status=500) + + return JsonResponse({'success': True, 'message': 'Activation key saved'}) + except Exception as e: + logging.writeToFile('store_plugin_activation_key failed for %s: %s' % (plugin_name, str(e))) + return JsonResponse({'success': False, 'message': 'Internal server error'}, status=500)