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.
This commit is contained in:
master3395
2026-03-26 12:39:48 +01:00
parent 638ad8f04e
commit b1f0ae220f
4 changed files with 236 additions and 8 deletions

View File

@@ -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'),)

View File

@@ -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)

View File

@@ -104,6 +104,7 @@ urlpatterns = [
path('api/revert/<str:plugin_name>/', views.revert_plugin, name='revert_plugin'),
path('api/debug-plugins/', views.debug_loaded_plugins, name='debug_loaded_plugins'),
path('api/check-subscription/<str:plugin_name>/', views.check_plugin_subscription, name='check_plugin_subscription'),
path('api/store-activation/<str:plugin_name>/', views.store_plugin_activation_key, name='store_plugin_activation_key'),
path('<str:plugin_name>/settings/', views.plugin_settings_proxy, name='plugin_settings_proxy'),
path('<str:plugin_name>/help/', views.plugin_help, name='plugin_help'),
]

View File

@@ -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)