fahd-edx-plugin 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fahd_edx/__init__.py +3 -0
- fahd_edx/apps.py +53 -0
- fahd_edx/middleware.py +165 -0
- fahd_edx/settings/__init__.py +1 -0
- fahd_edx/settings/common.py +28 -0
- fahd_edx/settings/production.py +24 -0
- fahd_edx_plugin-1.0.0.dist-info/METADATA +5 -0
- fahd_edx_plugin-1.0.0.dist-info/RECORD +10 -0
- fahd_edx_plugin-1.0.0.dist-info/WHEEL +4 -0
- fahd_edx_plugin-1.0.0.dist-info/entry_points.txt +5 -0
fahd_edx/__init__.py
ADDED
fahd_edx/apps.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# FILE: fahd_edx/apps.py
|
|
3
|
+
# PURPOSE: Django App Plugin registration for Open edX
|
|
4
|
+
#
|
|
5
|
+
# HOW THIS WORKS:
|
|
6
|
+
# Open edX discovers this via the lms.djangoapp / cms.djangoapp
|
|
7
|
+
# entry points in pyproject.toml. At startup, it reads plugin_app
|
|
8
|
+
# and auto-loads our settings (which register the middleware).
|
|
9
|
+
#
|
|
10
|
+
# No Tutor patches needed. No manual MIDDLEWARE.append().
|
|
11
|
+
# Just: pip install fahd-edx-plugin → restart → Fahd appears.
|
|
12
|
+
#
|
|
13
|
+
# PATTERN FROM:
|
|
14
|
+
# - github.com/openedx/sample-plugin (official reference)
|
|
15
|
+
# - github.com/mitodl/open-edx-plugins (MIT production)
|
|
16
|
+
# ============================================================
|
|
17
|
+
|
|
18
|
+
from django.apps import AppConfig
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from edx_django_utils.plugins.constants import PluginSettings
|
|
22
|
+
except ImportError:
|
|
23
|
+
# Fallback if edx-django-utils is not installed (e.g., local testing).
|
|
24
|
+
# These are just string constants — the actual values don't matter
|
|
25
|
+
# as long as they match what edx-django-utils expects.
|
|
26
|
+
class PluginSettings:
|
|
27
|
+
CONFIG = "settings_config"
|
|
28
|
+
RELATIVE_PATH = "relative_path"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FahdPluginConfig(AppConfig):
|
|
32
|
+
"""
|
|
33
|
+
Django App Plugin: Fahd AI assistant for Open edX.
|
|
34
|
+
|
|
35
|
+
Auto-discovered via lms.djangoapp / cms.djangoapp entry points.
|
|
36
|
+
Just pip install and restart — no Tutor patches needed.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name = "fahd_edx"
|
|
40
|
+
verbose_name = "Fahd AI Assistant"
|
|
41
|
+
|
|
42
|
+
plugin_app = {
|
|
43
|
+
PluginSettings.CONFIG: {
|
|
44
|
+
"lms.djangoapp": {
|
|
45
|
+
"common": {PluginSettings.RELATIVE_PATH: "settings.common"},
|
|
46
|
+
"production": {PluginSettings.RELATIVE_PATH: "settings.production"},
|
|
47
|
+
},
|
|
48
|
+
"cms.djangoapp": {
|
|
49
|
+
"common": {PluginSettings.RELATIVE_PATH: "settings.common"},
|
|
50
|
+
"production": {PluginSettings.RELATIVE_PATH: "settings.production"},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}
|
fahd_edx/middleware.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# FILE: fahd_edx/middleware.py
|
|
3
|
+
# PURPOSE: Inject Fahd embed.js into every legacy HTML page
|
|
4
|
+
#
|
|
5
|
+
# COVERS: Dashboard, Grades, Course About, Profile, Admin, etc.
|
|
6
|
+
# DOES NOT COVER: MFE React pages (Phase 2 — footer slot)
|
|
7
|
+
#
|
|
8
|
+
# HOW IT WORKS:
|
|
9
|
+
# 1. Django renders an HTML response
|
|
10
|
+
# 2. This middleware intercepts it
|
|
11
|
+
# 3. If user is authenticated and Fahd is configured:
|
|
12
|
+
# - Extract user identity (uid, role, course)
|
|
13
|
+
# - Generate HMAC-SHA256 token (same format as Moodle/BB plugins)
|
|
14
|
+
# - Inject <script src="embed.js" data-token="..."> before </head>
|
|
15
|
+
# 4. embed.js creates the floating Fahd button
|
|
16
|
+
#
|
|
17
|
+
# PERFORMANCE:
|
|
18
|
+
# - ~1ms overhead (string replacement, no network calls)
|
|
19
|
+
# - Script loads with `defer` (non-blocking)
|
|
20
|
+
# - If Fahd server is down, button shows grayed out (no page impact)
|
|
21
|
+
#
|
|
22
|
+
# TOKEN FORMAT (must match v2 core/auth.py and PHP token_generator.php):
|
|
23
|
+
# - json.dumps with ensure_ascii=False + sort_keys=True + COMPACT separators
|
|
24
|
+
# - PHP equivalent: ksort() + JSON_UNESCAPED_UNICODE (compact, no spaces)
|
|
25
|
+
#
|
|
26
|
+
# PORTED FROM fahd-agent-v1/plugins/edx/fahd_edx/middleware.py. TWO semantic
|
|
27
|
+
# changes only (see _create_token): (a) `tid` is the BARE tenant id (v1's
|
|
28
|
+
# "tenant:" prefix breaks v2 auth's CAST(:tid AS uuid)); (b) compact JSON
|
|
29
|
+
# separators (v1 used the default spaced separators, which were NOT byte-equal
|
|
30
|
+
# to v2 core/auth.py — the latent v1 byte-equivalence bug that v2 fixed).
|
|
31
|
+
# ============================================================
|
|
32
|
+
|
|
33
|
+
import base64
|
|
34
|
+
import hashlib
|
|
35
|
+
import hmac
|
|
36
|
+
import html
|
|
37
|
+
import json
|
|
38
|
+
import re
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
from django.conf import settings as django_settings
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FahdMiddleware:
|
|
45
|
+
"""Inject Fahd embed.js into every HTML response."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, get_response):
|
|
48
|
+
self.get_response = get_response
|
|
49
|
+
self.fahd_url = getattr(django_settings, "FAHD_URL", "")
|
|
50
|
+
self.tenant_id = getattr(django_settings, "FAHD_TENANT_ID", "")
|
|
51
|
+
self.auth_secret = getattr(django_settings, "FAHD_AUTH_SECRET", "")
|
|
52
|
+
|
|
53
|
+
def __call__(self, request):
|
|
54
|
+
response = self.get_response(request)
|
|
55
|
+
|
|
56
|
+
if "text/html" not in response.get("Content-Type", ""):
|
|
57
|
+
return response
|
|
58
|
+
if not hasattr(request, "user") or not request.user.is_authenticated:
|
|
59
|
+
return response
|
|
60
|
+
# Fail-closed: a misconfigured install missing the URL, signing secret, OR
|
|
61
|
+
# tenant id injects NOTHING rather than minting permanently-invalid tokens
|
|
62
|
+
# (e.g. a "default" tid that no tenant row will ever match).
|
|
63
|
+
if not self.fahd_url or not self.auth_secret or not self.tenant_id:
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
user = request.user
|
|
67
|
+
course_id = self._extract_course_id(request.path)
|
|
68
|
+
role = self._detect_role(user, course_id)
|
|
69
|
+
token = self._create_token(user, role, course_id)
|
|
70
|
+
|
|
71
|
+
# XSS defense-in-depth: escape settings-sourced values placed into HTML
|
|
72
|
+
# attributes (mirrors the M5-T01 view.php s() fix). `token` is base64url and
|
|
73
|
+
# therefore already attribute-safe (its alphabet excludes <>&"'), so it is
|
|
74
|
+
# left as-is.
|
|
75
|
+
safe_url = html.escape(self.fahd_url, quote=True)
|
|
76
|
+
safe_tenant = html.escape(self.tenant_id, quote=True)
|
|
77
|
+
script = (
|
|
78
|
+
f'<script src="{safe_url}/embed.js" '
|
|
79
|
+
f'data-tenant="{safe_tenant}" '
|
|
80
|
+
f'data-token="{token}" data-lang="ar" defer></script>'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Guard non-UTF-8 legacy pages: if the body is not valid UTF-8, pass it
|
|
84
|
+
# through unchanged (no injection) rather than 500-ing on every such page.
|
|
85
|
+
try:
|
|
86
|
+
content = response.content.decode("utf-8")
|
|
87
|
+
except UnicodeDecodeError:
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
# Inject ONCE, before the FIRST `</head>` only. replace-all would also
|
|
91
|
+
# rewrite stray `</head>` occurrences inside JS strings or <pre> blocks.
|
|
92
|
+
content = content.replace("</head>", f"{script}\n</head>", 1)
|
|
93
|
+
response.content = content.encode("utf-8")
|
|
94
|
+
response["Content-Length"] = len(response.content)
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
def _extract_course_id(self, path):
|
|
98
|
+
"""Extract course ID from URL path if inside a course."""
|
|
99
|
+
match = re.search(r"/courses/(course-v1:[^/]+)", path)
|
|
100
|
+
return match.group(1) if match else ""
|
|
101
|
+
|
|
102
|
+
def _detect_role(self, user, course_id):
|
|
103
|
+
"""Detect user's role (student/teacher/admin) in the course."""
|
|
104
|
+
if user.is_staff or user.is_superuser:
|
|
105
|
+
return "admin"
|
|
106
|
+
if course_id:
|
|
107
|
+
try:
|
|
108
|
+
from opaque_keys.edx.keys import CourseKey
|
|
109
|
+
from student.roles import CourseInstructorRole, CourseStaffRole
|
|
110
|
+
|
|
111
|
+
key = CourseKey.from_string(course_id)
|
|
112
|
+
if CourseInstructorRole(key).has_user(user) or CourseStaffRole(key).has_user(user):
|
|
113
|
+
return "teacher"
|
|
114
|
+
except Exception:
|
|
115
|
+
# Graceful degradation (Architecture Rule #15): ANY failure here —
|
|
116
|
+
# a malformed course_id, the edX role imports being absent (e.g. when
|
|
117
|
+
# this middleware runs outside a full edX install), or a role-lookup
|
|
118
|
+
# DB error — degrades to "student". This token only carries a UI hint;
|
|
119
|
+
# the adapter's get_user_role stays the source of truth (Rule #9), so
|
|
120
|
+
# under-claiming the role here cannot grant unauthorized access.
|
|
121
|
+
pass
|
|
122
|
+
return "student"
|
|
123
|
+
|
|
124
|
+
def _create_token(self, user, role, course_id):
|
|
125
|
+
"""Generate HMAC-SHA256 signed token for Fahd.
|
|
126
|
+
|
|
127
|
+
TWO v1->v2 retarget changes (the rest is verbatim from v1):
|
|
128
|
+
|
|
129
|
+
1. BARE `tid`. v1 set `tid = f"tenant:{self.tenant_id}"` (a SurrealDB
|
|
130
|
+
record-id convention). v2 auth does `CAST(:tid AS uuid)`, which raises
|
|
131
|
+
on `"tenant:<uuid>"`, so a prefixed token would fail
|
|
132
|
+
verify_token_for_tenant. We emit the bare tenant id, matching v2
|
|
133
|
+
core/auth.py and core/moodle_bridge.build_fahd_payload.
|
|
134
|
+
|
|
135
|
+
2. COMPACT separators=(",", ":"). v1 signed
|
|
136
|
+
`json.dumps(payload, ensure_ascii=False, sort_keys=True)` with the
|
|
137
|
+
DEFAULT separators (a space after every ":" and ","). PHP's
|
|
138
|
+
json_encode and v2 core/auth.py both emit COMPACT JSON (no spaces),
|
|
139
|
+
so v1's bytes — and therefore its HMAC — did NOT match. This is the
|
|
140
|
+
latent v1 byte-equivalence bug that v2 fixed; the compact separators
|
|
141
|
+
here make the inline token byte-identical to create_fahd_token.
|
|
142
|
+
"""
|
|
143
|
+
now = int(time.time())
|
|
144
|
+
payload = {
|
|
145
|
+
"uid": str(user.id),
|
|
146
|
+
"roles": [role],
|
|
147
|
+
"tid": self.tenant_id,
|
|
148
|
+
"cid": course_id,
|
|
149
|
+
"cname": "",
|
|
150
|
+
"lang": "ar",
|
|
151
|
+
"platform": "edx",
|
|
152
|
+
"iat": now,
|
|
153
|
+
"exp": now + 3600,
|
|
154
|
+
}
|
|
155
|
+
# CRITICAL: must match v2 core/auth.py — ensure_ascii=False + sort_keys=True
|
|
156
|
+
# + COMPACT separators (no spaces). The separators are the load-bearing fix.
|
|
157
|
+
payload_bytes = json.dumps(
|
|
158
|
+
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
159
|
+
).encode("utf-8")
|
|
160
|
+
sig = hmac.new(self.auth_secret.encode(), payload_bytes, hashlib.sha256).digest()
|
|
161
|
+
return (
|
|
162
|
+
base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
|
|
163
|
+
+ "."
|
|
164
|
+
+ base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
|
|
165
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Fahd edX plugin settings package (loaded by Open edX via apps.py plugin_app)."""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# FILE: fahd_edx/settings/common.py
|
|
3
|
+
# PURPOSE: Register Fahd middleware and set config defaults
|
|
4
|
+
#
|
|
5
|
+
# HOW THIS WORKS:
|
|
6
|
+
# Open edX calls plugin_settings(settings) during startup.
|
|
7
|
+
# We append our middleware to the MIDDLEWARE list and set
|
|
8
|
+
# default config values. No Tutor patches needed.
|
|
9
|
+
#
|
|
10
|
+
# This runs for BOTH LMS and CMS (Studio).
|
|
11
|
+
# ============================================================
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plugin_settings(settings):
|
|
15
|
+
"""
|
|
16
|
+
Called by Open edX during startup.
|
|
17
|
+
Appends Fahd middleware and sets config defaults.
|
|
18
|
+
"""
|
|
19
|
+
# Register middleware — injects embed.js into every HTML page
|
|
20
|
+
settings.MIDDLEWARE.append("fahd_edx.middleware.FahdMiddleware")
|
|
21
|
+
|
|
22
|
+
# Config defaults (overridden by production.py or env vars)
|
|
23
|
+
settings.FAHD_URL = getattr(settings, "FAHD_URL", "http://localhost:8000")
|
|
24
|
+
# Fail-closed default: empty (not the non-UUID sentinel "default") so the
|
|
25
|
+
# middleware guard (`not self.tenant_id`) skips injection on a misconfigured
|
|
26
|
+
# install instead of minting tokens that fail server-side CAST(:tid AS uuid).
|
|
27
|
+
settings.FAHD_TENANT_ID = getattr(settings, "FAHD_TENANT_ID", "")
|
|
28
|
+
settings.FAHD_AUTH_SECRET = getattr(settings, "FAHD_AUTH_SECRET", "")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# FILE: fahd_edx/settings/production.py
|
|
3
|
+
# PURPOSE: Production overrides — reads config from environment
|
|
4
|
+
#
|
|
5
|
+
# HOW THIS WORKS:
|
|
6
|
+
# Open edX calls plugin_settings(settings) AFTER common.py.
|
|
7
|
+
# We override defaults with environment variables if set.
|
|
8
|
+
#
|
|
9
|
+
# ENVIRONMENT VARIABLES:
|
|
10
|
+
# FAHD_URL — Fahd server URL (e.g., https://fahd.eduarabia.com)
|
|
11
|
+
# FAHD_TENANT_ID — Institution tenant ID
|
|
12
|
+
# FAHD_AUTH_SECRET — HMAC signing secret (must match Fahd server)
|
|
13
|
+
# ============================================================
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def plugin_settings(settings):
|
|
19
|
+
"""
|
|
20
|
+
Production overrides — reads from environment variables.
|
|
21
|
+
"""
|
|
22
|
+
settings.FAHD_URL = os.environ.get("FAHD_URL", settings.FAHD_URL)
|
|
23
|
+
settings.FAHD_TENANT_ID = os.environ.get("FAHD_TENANT_ID", settings.FAHD_TENANT_ID)
|
|
24
|
+
settings.FAHD_AUTH_SECRET = os.environ.get("FAHD_AUTH_SECRET", settings.FAHD_AUTH_SECRET)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fahd_edx/__init__.py,sha256=vuJ_N8J1-OdxRp5r_tMg07oZDxTWHtlk5linlF1gTQk,115
|
|
2
|
+
fahd_edx/apps.py,sha256=bOOB-LJ4sLoNPD_EhKGWI9ZtBzVhWyzjEe4rxcBqsYE,1899
|
|
3
|
+
fahd_edx/middleware.py,sha256=0lrzJ9u8UfHve50wt5iH8iiREZE13paYEnWsloLKkdw,7444
|
|
4
|
+
fahd_edx/settings/__init__.py,sha256=Dtx-CgBO7a7Kq3THEEMfH8ll5LlHVn4hnE__sxVfNc8,84
|
|
5
|
+
fahd_edx/settings/common.py,sha256=xedhZ9idOHMkAwztPHIdjYZ_WsSBcDFkuc5QtEzSjBg,1262
|
|
6
|
+
fahd_edx/settings/production.py,sha256=porecd1_x619YP8ZOGPkYqDjmQAF8m7fz8pecCcLEfQ,969
|
|
7
|
+
fahd_edx_plugin-1.0.0.dist-info/METADATA,sha256=ZZbBWLyj8nqW5xFYo4ArPi-hZlhh7wWyq_-yVWlDXb8,155
|
|
8
|
+
fahd_edx_plugin-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
fahd_edx_plugin-1.0.0.dist-info/entry_points.txt,sha256=_tXDhd853PL-TeITwepu5Ft9gsCLTKKCiH4Os0jTOIg,117
|
|
10
|
+
fahd_edx_plugin-1.0.0.dist-info/RECORD,,
|