Compare commits
142 Commits
main
...
feat-lti-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48537515cb | ||
|
|
e6db138d11 | ||
|
|
2f2d32f0db | ||
|
|
f4d3439246 | ||
|
|
7fe9891942 | ||
|
|
9eb8a1ad62 | ||
|
|
23ee0dc7cc | ||
|
|
e5be39f392 | ||
|
|
f0c084fa53 | ||
|
|
571bfcc4ce | ||
|
|
c04380af47 | ||
|
|
97741f780e | ||
|
|
78cce0eb10 | ||
|
|
472b3029c4 | ||
|
|
343f1e7009 | ||
|
|
8c78b67b0c | ||
|
|
29fc7fb861 | ||
|
|
b03a33d93e | ||
|
|
64472be406 | ||
|
|
cc0f4d4645 | ||
|
|
095e4d2cb4 | ||
|
|
5c8978453e | ||
|
|
83189076e4 | ||
|
|
ca6dbf3740 | ||
|
|
8646bd70dc | ||
|
|
1f493c8e15 | ||
|
|
e11cb7ea6e | ||
|
|
3131e76ef7 | ||
|
|
809cdccc42 | ||
|
|
ed36240f45 | ||
|
|
77bafff6f6 | ||
|
|
f6252f4f77 | ||
|
|
764580287f | ||
|
|
ce6c9a0a3c | ||
|
|
1ced023a07 | ||
|
|
981fec296c | ||
|
|
40cd7916e7 | ||
|
|
bcef59c3a9 | ||
|
|
e93c8225c4 | ||
|
|
5c3c33ca84 | ||
|
|
7a954e7a3d | ||
|
|
8610df0c2b | ||
|
|
8ab9030d14 | ||
|
|
15c8dec041 | ||
|
|
9af4686bd4 | ||
|
|
bcc8a0858c | ||
|
|
549b672d48 | ||
|
|
abe950f1da | ||
|
|
5fecda02d6 | ||
|
|
3c6f8c102c | ||
|
|
2d28520cd4 | ||
|
|
4bd56da2d8 | ||
|
|
fdfa857794 | ||
|
|
2c1f27c0be | ||
|
|
2f0bbd2533 | ||
|
|
54336f6c31 | ||
|
|
37e21f7ebf | ||
|
|
3deee80dd0 | ||
|
|
2e57164831 | ||
|
|
de0c16729b | ||
|
|
2c0bba1427 | ||
|
|
54a8e41f6d | ||
|
|
78fb19b464 | ||
|
|
8e5e7991b7 | ||
|
|
5cf435eca0 | ||
|
|
5026ce73da | ||
|
|
8b2ebe2415 | ||
|
|
8df320e134 | ||
|
|
8c8f737460 | ||
|
|
995faedb08 | ||
|
|
bde300b4bd | ||
|
|
fd5c0a2908 | ||
|
|
9c145da2e2 | ||
|
|
e9e5d44c3e | ||
|
|
a624c2e5b8 | ||
|
|
748d3b39ba | ||
|
|
ddc6bf9e67 | ||
|
|
aa7dbfe534 | ||
|
|
5cc72357c6 | ||
|
|
01b061a47b | ||
|
|
fbc78e7944 | ||
|
|
9e7a8afdda | ||
|
|
5572a67019 | ||
|
|
610590972f | ||
|
|
bdf7d3c2d0 | ||
|
|
a47bf5a3f8 | ||
|
|
38caea3c7c | ||
|
|
30491bf420 | ||
|
|
d0ebe19c2a | ||
|
|
59be9f16c0 | ||
|
|
a2d898c54e | ||
|
|
9733d53c0b | ||
|
|
70e2c67f3d | ||
|
|
77721d9c0e | ||
|
|
06bc64b2c4 | ||
|
|
b9899476b9 | ||
|
|
107750406e | ||
|
|
ae4ae5a07e | ||
|
|
f346a5604c | ||
|
|
56026a1a96 | ||
|
|
a88413ce14 | ||
|
|
9dab3ad858 | ||
|
|
dfe7e8fab0 | ||
|
|
1181d16ab9 | ||
|
|
d032ee3baa | ||
|
|
93f66d206b | ||
|
|
0585513439 | ||
|
|
9667e6b0ad | ||
|
|
f56948a4a2 | ||
|
|
8b3e76b554 | ||
|
|
dc417de628 | ||
|
|
35cd56c85c | ||
|
|
f0b2451815 | ||
|
|
7696251394 | ||
|
|
b95725660b | ||
|
|
d6bf98b30e | ||
|
|
3baa8ef7d7 | ||
|
|
45246eac4f | ||
|
|
9685c1b5d4 | ||
|
|
20a1da22bb | ||
|
|
f9a94321ad | ||
|
|
f85299a600 | ||
|
|
29ab2a715b | ||
|
|
43ce685f08 | ||
|
|
8c682a76af | ||
|
|
ec6b6daa81 | ||
|
|
cf90169240 | ||
|
|
fb3f377e27 | ||
|
|
f5f9a7beac | ||
|
|
726a5b74a1 | ||
|
|
40c31f295a | ||
|
|
1d77293afc | ||
|
|
5c702387ca | ||
|
|
0001f370a9 | ||
|
|
af71d4c906 | ||
|
|
eb7503125d | ||
|
|
f897d0ba2b | ||
|
|
545cca154e | ||
|
|
ef4ff9cb1d | ||
|
|
3a40fc6d88 | ||
|
|
f67d2a4d78 | ||
|
|
295578dae2 |
23
HISTORY.md
23
HISTORY.md
@ -1,23 +0,0 @@
|
||||
# History
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Features
|
||||
- Updates Python/Django requirements and Dockerfile to use latest 3.11 Python - https://github.com/mediacms-io/mediacms/pull/826/files. This update requires some manual steps, for existing (not new) installations. Check the update section under the [Admin docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#2-server-installation), either for single server or for Docker Compose installations
|
||||
- Upgrade postgres on Docker Compose - https://github.com/mediacms-io/mediacms/pull/749
|
||||
|
||||
### Fixes
|
||||
- video player options for HLS - https://github.com/mediacms-io/mediacms/pull/832
|
||||
- AVI videos not correctly recognised as videos - https://github.com/mediacms-io/mediacms/pull/833
|
||||
|
||||
## 2.1.0
|
||||
|
||||
### Fixes
|
||||
- Increase uwsgi buffer-size parameter. This prevents an error by uwsgi with large headers - [#5b60](https://github.com/mediacms-io/mediacms/commit/5b601698a41ad97f08c1830e14b1c18f73ab8315)
|
||||
- Fix issues with comments. These were not reported on the tracker but it is certain that they would not show comments on media files (non videos but also videos). Unfortunately this reverts work done with Timestamps on comments + Mentions on comments, more on PR [#802](https://github.com/mediacms-io/mediacms/pull/802)
|
||||
|
||||
### Features
|
||||
- Allow tags to contains other characters too, not only English alphabet ones [#801](https://github.com/mediacms-io/mediacms/pull/801)
|
||||
- Add simple cookie consent code [#799](https://github.com/mediacms-io/mediacms/pull/799)
|
||||
- Allow password reset & email verify pages on global login required [#790](https://github.com/mediacms-io/mediacms/pull/790)
|
||||
- Add api_url field to search api [#692](https://github.com/mediacms-io/mediacms/pull/692)
|
||||
253
LTI_SETUP.md
Executable file
253
LTI_SETUP.md
Executable file
@ -0,0 +1,253 @@
|
||||
# MediaCMS LTI 1.3 Integration Setup Guide
|
||||
|
||||
This guide walks you through integrating MediaCMS with a Learning Management System (LMS) like Moodle using LTI 1.3.
|
||||
|
||||
## 1. Configure MediaCMS Settings
|
||||
|
||||
Add these settings to `cms/local_settings.py`:
|
||||
|
||||
```python
|
||||
# Enable LTI integration
|
||||
USE_LTI = True
|
||||
|
||||
# Enable RBAC for course-based access control
|
||||
USE_RBAC = True
|
||||
|
||||
# Your production domain
|
||||
FRONTEND_HOST = 'https://your-mediacms-domain.com'
|
||||
ALLOWED_HOSTS = ['your-mediacms-domain.com', 'localhost']
|
||||
```
|
||||
|
||||
**Note:** LTI-specific cookie settings (SESSION_COOKIE_SAMESITE='None', etc.) are automatically applied when `USE_LTI=True`.
|
||||
|
||||
## 2. MediaCMS Configuration
|
||||
|
||||
### A. Verify HTTPS Setup
|
||||
|
||||
Ensure your MediaCMS server is running on HTTPS. LTI 1.3 requires HTTPS for security and iframe embedding.
|
||||
|
||||
### B. Register Your LMS Platform
|
||||
|
||||
1. Access Django Admin: `https://your-mediacms-domain.com/admin/lti/ltiplatform/`
|
||||
2. Add new LTI Platform with these settings:
|
||||
|
||||
**Basic Info:**
|
||||
- **Name:** My LMS (or any descriptive name)
|
||||
- **Platform ID (Issuer):** Get this from your LMS (e.g., `https://mylms.example.com`)
|
||||
- **Client ID:** You'll get this from your LMS after registering MediaCMS as an external tool
|
||||
|
||||
**OIDC Endpoints (get from your LMS):**
|
||||
- **Auth Login URL:** `https://mylms.example.com/mod/lti/auth.php`
|
||||
- **Auth Token URL:** `https://mylms.example.com/mod/lti/token.php`
|
||||
- **Key Set URL:** `https://mylms.example.com/mod/lti/certs.php`
|
||||
|
||||
**Deployment IDs:** Add the deployment ID(s) provided by your LMS as a JSON list, e.g., `["1"]`
|
||||
|
||||
**Features:**
|
||||
- ✓ Enable NRPS (Names and Role Provisioning)
|
||||
- ✓ Enable Deep Linking
|
||||
- ✓ Auto-create categories
|
||||
- ✓ Auto-create users
|
||||
- ✓ Auto-sync roles
|
||||
|
||||
### C. Note MediaCMS URLs for LMS Configuration
|
||||
|
||||
You'll need these URLs when configuring your LMS:
|
||||
|
||||
- **Tool URL:** `https://your-mediacms-domain.com/lti/launch/`
|
||||
- **OIDC Login URL:** `https://your-mediacms-domain.com/lti/oidc/login/`
|
||||
- **JWK Set URL:** `https://your-mediacms-domain.com/lti/jwks/`
|
||||
- **Redirection URI:** `https://your-mediacms-domain.com/lti/launch/`
|
||||
- **Deep Linking URL:** `https://your-mediacms-domain.com/lti/select-media/`
|
||||
|
||||
## 3. LMS Configuration (Moodle Example)
|
||||
|
||||
### A. Register MediaCMS as External Tool
|
||||
|
||||
1. Navigate to: **Site administration → Plugins → Activity modules → External tool → Manage tools**
|
||||
2. Click **Configure a tool manually** or add new tool
|
||||
|
||||
**Basic Settings:**
|
||||
- **Tool name:** MediaCMS
|
||||
- **Tool URL:** `https://your-mediacms-domain.com/lti/launch/`
|
||||
- **LTI version:** LTI 1.3
|
||||
- **Tool configuration usage:** Show in activity chooser
|
||||
|
||||
**URLs:**
|
||||
- **Public keyset URL:** `https://your-mediacms-domain.com/lti/jwks/`
|
||||
- **Initiate login URL:** `https://your-mediacms-domain.com/lti/oidc/login/`
|
||||
- **Redirection URI(s):** `https://your-mediacms-domain.com/lti/launch/`
|
||||
|
||||
**Launch Settings:**
|
||||
- **Default launch container:** Embed (without blocks) or New window
|
||||
- **Accept grades from tool:** Optional
|
||||
- **Share launcher's name:** Always ⚠️ **REQUIRED for user names**
|
||||
- **Share launcher's email:** Always ⚠️ **REQUIRED for user emails**
|
||||
|
||||
> **Important:** MediaCMS creates user accounts automatically on first LTI launch. To ensure users have proper names and email addresses in MediaCMS, you **must** set both "Share launcher's name with tool" and "Share launcher's email with tool" to **Always** in the Privacy settings. Without these settings, users will be created with only a username based on their LTI user ID.
|
||||
|
||||
**Services:**
|
||||
- ✓ IMS LTI Names and Role Provisioning (for roster sync)
|
||||
- ✓ IMS LTI Deep Linking (for media selection)
|
||||
|
||||
**Tool Settings (Important for Deep Linking):**
|
||||
- ✓ **Supports Deep Linking (Content-Item Message)** - Enable this to allow instructors to browse and select media from MediaCMS when adding activities
|
||||
|
||||
3. Save the tool configuration
|
||||
|
||||
### B. Copy Platform Details to MediaCMS
|
||||
|
||||
After saving, your LMS will provide:
|
||||
- Platform ID (Issuer URL)
|
||||
- Client ID
|
||||
- Deployment ID
|
||||
|
||||
Copy these values back to the LTIPlatform configuration in MediaCMS admin (step 2B above).
|
||||
|
||||
### C. Using MediaCMS in Courses
|
||||
|
||||
**Option 1: Embed "My Media" view (Default)**
|
||||
- In a course, add activity → External tool → MediaCMS
|
||||
- Leave the custom URL blank (uses default launch URL)
|
||||
- Students/teachers will see their MediaCMS profile in an iframe
|
||||
|
||||
**Option 2: Link to a Specific Video**
|
||||
- Add activity → External tool → MediaCMS
|
||||
- Activity name: "November 2020 Video" (or any descriptive name)
|
||||
- In the activity settings, find **"Custom parameters"** (may be under "Privacy" or "Additional Settings")
|
||||
- Add this parameter:
|
||||
```
|
||||
media_friendly_token=abc123def
|
||||
```
|
||||
- Replace `abc123def` with your video's token from MediaCMS (found in the URL: `/view?m=abc123def`)
|
||||
- Students clicking this activity will go directly to that specific video
|
||||
|
||||
**Option 3: Link to Any MediaCMS Page**
|
||||
- Add activity → External tool → MediaCMS
|
||||
- In **"Custom parameters"**, add:
|
||||
```
|
||||
redirect_path=/featured
|
||||
```
|
||||
- Supported paths:
|
||||
- `/featured` - Featured videos page
|
||||
- `/latest` - Latest videos
|
||||
- `/search/?q=keyword` - Search results
|
||||
- `/category/category-name` - Specific category
|
||||
- `/user/username` - User's profile
|
||||
- Any other MediaCMS page path
|
||||
|
||||
**Option 4: Embed Specific Media via Deep Linking (Interactive)**
|
||||
|
||||
⚠️ **Prerequisite:** Ensure "Supports Deep Linking (Content-Item Message)" is enabled in the External Tool configuration (see section 3.A above)
|
||||
|
||||
When adding the activity to your course:
|
||||
1. Add activity → External tool → MediaCMS
|
||||
2. In the activity settings, enable **"Supports Deep Linking"** checkbox (may be under "Tool settings" or "Privacy" section)
|
||||
3. Click **"Select content"** button → This launches the MediaCMS media browser
|
||||
4. Browse and select media from MediaCMS (you can select multiple)
|
||||
5. Click **"Add to course"** → Returns to Moodle with selected media configured
|
||||
6. The activity will be automatically configured with the selected media's title and embed URL
|
||||
7. Students clicking this activity will go directly to the selected media
|
||||
|
||||
### D. Custom Parameters - Complete Examples
|
||||
|
||||
**Example 1: Link to a specific video titled "Lecture 1 - Introduction"**
|
||||
```
|
||||
Activity Name: Lecture 1 - Introduction
|
||||
Custom Parameters:
|
||||
media_friendly_token=a1b2c3d4e5
|
||||
```
|
||||
|
||||
**Example 2: Link to course-specific videos**
|
||||
```
|
||||
Activity Name: Course Videos
|
||||
Custom Parameters:
|
||||
redirect_path=/category/biology101
|
||||
```
|
||||
|
||||
**Example 3: Link to search results for "genetics"**
|
||||
```
|
||||
Activity Name: Genetics Videos
|
||||
Custom Parameters:
|
||||
redirect_path=/search/?q=genetics
|
||||
```
|
||||
|
||||
**Example 4: Link to featured content**
|
||||
```
|
||||
Activity Name: Featured Videos
|
||||
Custom Parameters:
|
||||
redirect_path=/featured
|
||||
```
|
||||
|
||||
**Where to find Custom Parameters in Moodle:**
|
||||
1. When creating/editing the External Tool activity
|
||||
2. Expand **"Privacy"** section, or look for **"Additional Settings"**
|
||||
3. Find the **"Custom parameters"** text field
|
||||
4. Enter one parameter per line in the format: `key=value`
|
||||
|
||||
## 4. Testing Checklist
|
||||
|
||||
- [ ] HTTPS is working on MediaCMS
|
||||
- [ ] `USE_LTI = True` in local_settings.py
|
||||
- [ ] LTIPlatform configured in Django admin
|
||||
- [ ] External tool registered in LMS
|
||||
- [ ] Launch from LMS creates new user in MediaCMS
|
||||
- [ ] Course is mapped to MediaCMS category
|
||||
- [ ] Users are added to RBAC group with correct roles
|
||||
- [ ] Media from course category is visible to course members
|
||||
- [ ] Public media is accessible
|
||||
- [ ] Private media from other courses is not accessible
|
||||
|
||||
## 5. Default Role Mappings
|
||||
|
||||
The system automatically maps LMS roles to MediaCMS:
|
||||
|
||||
- **Instructor/Teacher** → advancedUser (global) + manager (course group)
|
||||
- **Student/Learner** → user (global) + member (course group)
|
||||
- **Teaching Assistant** → user (global) + contributor (course group)
|
||||
- **Administrator** → manager (global) + manager (course group)
|
||||
|
||||
You can customize these in Django admin under **LTI Role Mappings**.
|
||||
|
||||
## 6. User Creation and Authentication
|
||||
|
||||
### User Creation via LTI
|
||||
|
||||
When a user launches MediaCMS from your LMS for the first time, a MediaCMS account is automatically created with:
|
||||
- **Username:** Generated from email (preferred) or name, or a unique ID if neither is available
|
||||
- **Email:** From LTI claim (if shared by LMS)
|
||||
- **Name:** From LTI given_name/family_name claims (if shared by LMS)
|
||||
- **Roles:** Mapped from LTI roles to MediaCMS permissions
|
||||
- **Course membership:** Automatically added to the RBAC group for the course
|
||||
|
||||
### Privacy Settings Are Critical
|
||||
|
||||
⚠️ **For proper user accounts, you must configure the LTI tool's privacy settings in Moodle:**
|
||||
|
||||
1. Edit the External Tool configuration in Moodle
|
||||
2. Go to the **Privacy** section
|
||||
3. Set **"Share launcher's name with tool"** to **Always**
|
||||
4. Set **"Share launcher's email with tool"** to **Always**
|
||||
|
||||
Without these settings:
|
||||
- Users will not have proper names in MediaCMS
|
||||
- Users will not have email addresses
|
||||
- Usernames will be generic hashes (e.g., `lti_user_abc123def`)
|
||||
|
||||
### Authentication
|
||||
|
||||
Users created through LTI integration do **not** have a password set. They can only access MediaCMS through LTI launches from your LMS. This is intentional for security.
|
||||
|
||||
If you need a user to have both LTI access and direct login capability, manually set a password using:
|
||||
```bash
|
||||
python manage.py changepassword <username>
|
||||
```
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter issues, check:
|
||||
- `/admin/lti/ltilaunchlog/` for launch attempt logs
|
||||
- Django logs for detailed error messages
|
||||
- Ensure HTTPS is properly configured (required for iframe cookies)
|
||||
- Verify all URLs are correct and accessible
|
||||
- Check that the Client ID and Deployment ID match between MediaCMS and your LMS
|
||||
@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
|
||||
|
||||
## Technology
|
||||
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, uWSGI, React, Fine Uploader, video.js, FFMPEG, Bento4
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, Gunicorn, React, Fine Uploader, video.js, FFMPEG, Bento4
|
||||
|
||||
|
||||
## Who is using it
|
||||
|
||||
@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
|
||||
@ -300,6 +300,7 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
@ -555,6 +556,7 @@ DJANGO_ADMIN_URL = "admin/"
|
||||
USE_SAML = False
|
||||
USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
USE_LTI = False # Enable LTI 1.3 integration
|
||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
|
||||
USE_ROUNDED_CORNERS = True
|
||||
@ -650,3 +652,19 @@ if USERS_NEEDS_TO_BE_APPROVED:
|
||||
)
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
||||
|
||||
|
||||
# LTI 1.3 Integration Settings
|
||||
if USE_LTI:
|
||||
# Session timeout for LTI launches (seconds)
|
||||
LTI_SESSION_TIMEOUT = 3600 # 1 hour
|
||||
|
||||
# Cookie settings required for iframe embedding from LMS
|
||||
# IMPORTANT: Requires HTTPS to be enabled
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SAMESITE = 'None'
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||
# Use cached_db for reliability - stores in both cache AND database
|
||||
# This prevents session loss during multiple simultaneous LTI launches
|
||||
|
||||
@ -25,6 +25,7 @@ urlpatterns = [
|
||||
re_path(r"^", include("files.urls")),
|
||||
re_path(r"^", include("users.urls")),
|
||||
re_path(r"^accounts/", include("allauth.urls")),
|
||||
re_path(r"^lti/", include("lti.urls")),
|
||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
|
||||
@ -1 +1 @@
|
||||
VERSION = "7.6"
|
||||
VERSION = "7.8124"
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
# Use existing X-Forwarded-Proto from reverse proxy if present, otherwise use $scheme
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 ;
|
||||
|
||||
@ -28,7 +34,10 @@ server {
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,6 @@ fi
|
||||
|
||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default
|
||||
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/default
|
||||
cp deploy/docker/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/docker/nginx.conf /etc/nginx/
|
||||
|
||||
#### Supervisord Configurations #####
|
||||
@ -45,12 +44,12 @@ cp deploy/docker/nginx.conf /etc/nginx/
|
||||
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
|
||||
|
||||
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
|
||||
echo "Enabling uwsgi app server"
|
||||
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
|
||||
echo "Enabling gunicorn app server"
|
||||
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
|
||||
fi
|
||||
|
||||
if [ X"$ENABLE_NGINX" = X"yes" ] ; then
|
||||
echo "Enabling nginx as uwsgi app proxy and media server"
|
||||
echo "Enabling nginx as gunicorn app proxy and media server"
|
||||
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
|
||||
fi
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ else
|
||||
echo "There is no script $PRE_START_PATH"
|
||||
fi
|
||||
|
||||
# Start Supervisor, with Nginx and uWSGI
|
||||
# Start Supervisor, with Nginx and Gunicorn
|
||||
echo "Starting server using supervisord..."
|
||||
|
||||
exec /usr/bin/supervisord
|
||||
|
||||
9
deploy/docker/supervisord/supervisord-gunicorn.conf
Normal file
9
deploy/docker/supervisord/supervisord-gunicorn.conf
Normal file
@ -0,0 +1,9 @@
|
||||
[program:gunicorn]
|
||||
command=/home/mediacms.io/bin/gunicorn cms.wsgi:application --workers=2 --threads=2 --worker-class=gthread --bind=127.0.0.1:9000 --user=www-data --group=www-data --timeout=120 --keep-alive=5 --max-requests=1000 --max-requests-jitter=50 --access-logfile=- --error-logfile=- --log-level=info --chdir=/home/mediacms.io/mediacms
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=100
|
||||
startinorder=true
|
||||
startsecs=0
|
||||
@ -1,9 +0,0 @@
|
||||
[program:uwsgi]
|
||||
command=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/docker/uwsgi.ini
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
priority=100
|
||||
startinorder=true
|
||||
startsecs=0
|
||||
@ -1,24 +0,0 @@
|
||||
[uwsgi]
|
||||
|
||||
chdir = /home/mediacms.io/mediacms/
|
||||
virtualenv = /home/mediacms.io
|
||||
module = cms.wsgi
|
||||
|
||||
uid=www-data
|
||||
gid=www-data
|
||||
|
||||
processes = 2
|
||||
threads = 2
|
||||
|
||||
master = true
|
||||
|
||||
socket = 127.0.0.1:9000
|
||||
|
||||
workers = 2
|
||||
|
||||
vacuum = true
|
||||
|
||||
hook-master-start = unix_signal:15 gracefully_kill_them_all
|
||||
need-app = true
|
||||
die-on-term = true
|
||||
buffer-size=32768
|
||||
@ -1,16 +0,0 @@
|
||||
uwsgi_param QUERY_STRING $query_string;
|
||||
uwsgi_param REQUEST_METHOD $request_method;
|
||||
uwsgi_param CONTENT_TYPE $content_type;
|
||||
uwsgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
uwsgi_param REQUEST_URI $request_uri;
|
||||
uwsgi_param PATH_INFO $document_uri;
|
||||
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||
uwsgi_param REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
||||
@ -1,22 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery beat
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/beat%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/beat%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms beat --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
|
||||
ExecStop=/bin/kill -s TERM $MAINPID
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery long queue
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERYD_NODES="long1"
|
||||
Environment=CELERY_QUEUE="long_tasks"
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
Environment=CELERYD_MULTI="multi"
|
||||
Environment=CELERYD_OPTS="-Ofair --prefetch-multiplier=1"
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
|
||||
|
||||
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS celery short queue
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/mediacms.io/mediacms
|
||||
Environment=CELERYD_NODES="short1 short2"
|
||||
Environment=CELERY_QUEUE="short_tasks"
|
||||
# Absolute or relative path to the 'celery' command:
|
||||
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
|
||||
# App instance to use
|
||||
# comment out this line if you don't use an app
|
||||
# or fully qualified:
|
||||
#CELERY_APP="proj.tasks:app"
|
||||
# How to call manage.py
|
||||
Environment=CELERYD_MULTI="multi"
|
||||
# Extra command-line arguments to the worker
|
||||
Environment=CELERYD_OPTS="--soft-time-limit=300 -c10"
|
||||
# - %n will be replaced with the first part of the nodename.
|
||||
# - %I will be replaced with the current child process index
|
||||
# and is important when using the prefork pool to avoid race conditions.
|
||||
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
|
||||
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
|
||||
Environment=CELERYD_LOG_LEVEL="INFO"
|
||||
|
||||
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
|
||||
|
||||
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
-----BEGIN DH PARAMETERS-----
|
||||
MIICCAKCAgEAo3MMiEY/fNbu+usIM0cDi6x8G3JBApv0Lswta4kiyedWT1WN51iQ
|
||||
9zhOFpmcu6517f/fR9MUdyhVKHxxSqWQTcmTEFtz4P3VLTS/W1N5VbKE2VEMLpIi
|
||||
wr350aGvV1Er0ujcp5n4O4h0I1tn4/fNyDe7+pHCdwM+hxe8hJ3T0/tKtad4fnIs
|
||||
WHDjl4f7m7KuFfheiK7Efb8MsT64HDDAYXn+INjtDZrbE5XPw20BqyWkrf07FcPx
|
||||
8o9GW50Ox7/FYq7jVMI/skEu0BRc8u6uUD9+UOuWUQpdeHeFcvLOgW53Z03XwWuX
|
||||
RXosUKzBPuGtUDAaKD/HsGW6xmGr2W9yRmu27jKpfYLUb/eWbbnRJwCw04LdzPqv
|
||||
jmtq02Gioo3lf5H5wYV9IYF6M8+q/slpbttsAcKERimD1273FBRt5VhSugkXWKjr
|
||||
XDhoXu6vZgj8Opei38qPa8pI1RUFoXHFlCe6WpZQmU8efL8gAMrJr9jUIY8eea1n
|
||||
u20t5B9ueb9JMjrNafcq6QkKhZLi6fRDDTUyeDvc0dN9R/3Yts97SXfdi1/lX7HS
|
||||
Ht4zXd5hEkvjo8GcnjsfZpAC39QfHWkDaeUGEqsl3jXjVMfkvoVY51OuokPWZzrJ
|
||||
M5+wyXNpfGbH67dPk7iHgN7VJvgX0SYscDPTtms50Vk7RwEzLeGuSHMCAQI=
|
||||
-----END DH PARAMETERS-----
|
||||
@ -1,84 +0,0 @@
|
||||
server {
|
||||
listen 80 ;
|
||||
server_name localhost;
|
||||
|
||||
gzip on;
|
||||
access_log /var/log/nginx/mediacms.io.access.log;
|
||||
|
||||
error_log /var/log/nginx/mediacms.io.error.log warn;
|
||||
|
||||
# # redirect to https if logged in
|
||||
# if ($http_cookie ~* "sessionid") {
|
||||
# rewrite ^/(.*)$ https://localhost/$1 permanent;
|
||||
# }
|
||||
|
||||
# # redirect basic forms to https
|
||||
# location ~ (login|login_form|register|mail_password_form)$ {
|
||||
# rewrite ^/(.*)$ https://localhost/$1 permanent;
|
||||
# }
|
||||
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /home/mediacms.io/mediacms/media_files ;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
|
||||
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_ecdh_curve secp521r1:secp384r1;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
gzip on;
|
||||
access_log /var/log/nginx/mediacms.io.access.log;
|
||||
|
||||
error_log /var/log/nginx/mediacms.io.error.log warn;
|
||||
|
||||
location /static {
|
||||
alias /home/mediacms.io/mediacms/static ;
|
||||
}
|
||||
|
||||
location /media/original {
|
||||
alias /home/mediacms.io/mediacms/media_files/original;
|
||||
#auth_basic "auth protected area";
|
||||
#auth_basic_user_file /home/mediacms.io/mediacms/deploy/local_install/.htpasswd;
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /home/mediacms.io/mediacms/media_files ;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFTjCCBDagAwIBAgISBNOUeDlerH9MkKmHLvZJeMYgMA0GCSqGSIb3DQEBCwUA
|
||||
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
|
||||
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDAzMTAxNzUxNDFaFw0y
|
||||
MDA2MDgxNzUxNDFaMBYxFDASBgNVBAMTC21lZGlhY21zLmlvMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAps5Jn18nW2tq/LYFDgQ1YZGLlpF/B2AAPvvH
|
||||
3yuD+AcT4skKdZouVL/a5pXrptuYL5lthO9dlcja2tuO2ltYrb7Dp01dAIFaJE8O
|
||||
DKd+Sv5wr8VWQZykqzMiMBgviml7TBvUHQjvCJg8UwmnN0XSUILCttd6u4qOzS7d
|
||||
lKMMsKpYzLhElBT0rzhhsWulDiy6aAZbMV95bfR74nIWsBJacy6jx3jvxAuvCtkB
|
||||
OVdOoVL6BPjDE3SNEk53bAZGIb5A9ri0O5jh/zBFT6tQSjUhAUTkmv9oZP547RnV
|
||||
fDj+rdvCVk/fE+Jno36mcT183Qd/Ty3fWuqFoM5g/luhnfvWEwIDAQABo4ICYDCC
|
||||
AlwwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
|
||||
AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTd5EZBt74zu5XxT1uXQs6oM8qOuDAf
|
||||
BgNVHSMEGDAWgBSoSmpjBH3duubRObemRWXv86jsoTBvBggrBgEFBQcBAQRjMGEw
|
||||
LgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwLmludC14My5sZXRzZW5jcnlwdC5vcmcw
|
||||
LwYIKwYBBQUHMAKGI2h0dHA6Ly9jZXJ0LmludC14My5sZXRzZW5jcnlwdC5vcmcv
|
||||
MBYGA1UdEQQPMA2CC21lZGlhY21zLmlvMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcG
|
||||
CysGAQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5
|
||||
cHQub3JnMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHYAXqdz+d9WwOe1Nkh90Eng
|
||||
MnqRmgyEoRIShBh1loFxRVgAAAFwxcnL+AAABAMARzBFAiAb3yeBuW3j9MxcRc0T
|
||||
icUBvEa/rH7Fv2eB0oQlnZ1exQIhAPf+CtTXmzxoeT/BBiivj4AmGDsq4xWhe/U6
|
||||
BytYrKLeAHYAB7dcG+V9aP/xsMYdIxXHuuZXfFeUt2ruvGE6GmnTohwAAAFwxcnM
|
||||
HAAABAMARzBFAiAuP5gKyyaT0LVXxwjYD9zhezvxf4Icx0P9pk75c5ao+AIhAK0+
|
||||
fSJv+WTXciMT6gA1sk/tuCHuDFAuexSA/6TcRXcVMA0GCSqGSIb3DQEBCwUAA4IB
|
||||
AQCPCYBU4Q/ro2MUkjDPKGmeqdxQycS4R9WvKTG/nmoahKNg30bnLaDPUcpyMU2k
|
||||
sPDemdZ7uTGLZ3ZrlIva8DbrnJmrTPf9BMwaM6j+ZV/QhxvKZVIWkLkZrwiVI57X
|
||||
Ba+rs5IEB4oWJ0EBaeIrzeKG5zLMkRcIdE4Hlhuwu3zGG56c+wmAPuvpIDlYoO6o
|
||||
W22xRdxoTIHBvkzwonpVYUaRcaIw+48xnllxh1dHO+X69DT45wlF4tKveOUi+L50
|
||||
4GWJ8Vjv7Fot/WNHEM4Mnmw0jHj9TPkIZKnPNRMdHmJ5CF/FJFDiptOeuzbfohG+
|
||||
mdvuInb8JDc0XBE99Gf/S4/y
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/
|
||||
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
|
||||
DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow
|
||||
SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT
|
||||
GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC
|
||||
AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF
|
||||
q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8
|
||||
SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0
|
||||
Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA
|
||||
a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj
|
||||
/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T
|
||||
AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG
|
||||
CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv
|
||||
bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k
|
||||
c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw
|
||||
VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC
|
||||
ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz
|
||||
MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu
|
||||
Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF
|
||||
AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo
|
||||
uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/
|
||||
wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu
|
||||
X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG
|
||||
PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6
|
||||
KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmzkmfXydba2r8
|
||||
tgUOBDVhkYuWkX8HYAA++8ffK4P4BxPiyQp1mi5Uv9rmleum25gvmW2E712VyNra
|
||||
247aW1itvsOnTV0AgVokTw4Mp35K/nCvxVZBnKSrMyIwGC+KaXtMG9QdCO8ImDxT
|
||||
Cac3RdJQgsK213q7io7NLt2UowywqljMuESUFPSvOGGxa6UOLLpoBlsxX3lt9Hvi
|
||||
chawElpzLqPHeO/EC68K2QE5V06hUvoE+MMTdI0STndsBkYhvkD2uLQ7mOH/MEVP
|
||||
q1BKNSEBROSa/2hk/njtGdV8OP6t28JWT98T4mejfqZxPXzdB39PLd9a6oWgzmD+
|
||||
W6Gd+9YTAgMBAAECggEADnEJuryYQbf5GUwBAAepP3tEZJLQNqk/HDTcRxwTXuPt
|
||||
+tKBD1F79WZu40vTjSyx7l0QOFQo/BDZsd0Ubx89fD1p3xA5nxOT5FTb2IifzIpe
|
||||
4zjokOGo+BGDQjq10vvy6tH1+VWOrGXRwzawvX5UCRhpFz9sptQGLQmDsZy0Oo9B
|
||||
LtavYVUqsbyqRWlzaclHgbythegIACWkqcalOzOtx+l6TGBRjej+c7URcwYBfr7t
|
||||
XTAzbP+vnpaJovZyZT1eekr0OLzMpnjx4HvRvzL+NxauRpn6KfabsTfZlk8nrs4I
|
||||
UdSjeukj1Iz8rGQilHdN/4dVJ3KzrlHVkVTBSjmMUQKBgQDaVXZnhAScfdiKeZbO
|
||||
rdUAWcnwfkDghtRuAmzHaRM/FhFBEoVhdSbBuu+OUyBnIw/Ra4o2ePuEBcKIUiQO
|
||||
w2tnE1CY5PPAcjw+OCSpvzy5xxjaqaRbm9BJp3FTeEYGLXERnchPpHg/NpexuF22
|
||||
QOJ+FrysPyNMxuQp47ZwO9WT3QKBgQDDlSGjq/eeWxemwf7ZqMVlRyqsdJsgnCew
|
||||
DkC62IGiYCBDfeEmndN+vcA/uzJHYV4iXiqS3aYJCWGaZFMhdIhIn5MgULvO1j5G
|
||||
u/MxuzaaNPz22FlNCWTLBw4T1HOOvyTL+nLtZDKJ/BHxgHCmur1kiGvvZWrcCthD
|
||||
afLEmseqrwKBgBuLZKCymxJTHhp6NHhmndSpfzyD8RNibzJhw+90ZiUzV4HqIEGn
|
||||
Ufhm6Qn/mrroRXqaIpm0saZ6Q4yHMF1cchRS73wahlXlE4yV8KopojOd1pjfhgi4
|
||||
o5JnOXjaV5s36GfcjATgLvtqm8CkDc6MaQaXP75LSNzKysYuIDoQkmVRAoGAAghF
|
||||
rja2Pv4BU+lGJarcSj4gEmSvy/nza5/qSka/qhlHnIvtUAJp1TJRkhf24MkBOmgy
|
||||
Fw6YkBV53ynVt05HsEGAPOC54t9VDFUdpNGmMpoEWuhKnUNQuc9b9RbLEJup3TjA
|
||||
Avl8kPR+lzzXbtQX7biBLp6mKp0uPB0YubRGCN8CgYA0JMxK0x38Q2x3AQVhOmZh
|
||||
YubtIa0JqVJhvpweOCFnkq3ebBpLsWYwiLTn86vuD0jupe5M3sxtefjkJmAKd8xY
|
||||
aBU7QWhjh1fX4mzmggnbjcrIFbkIHsxwMeg567U/4AGxOOUsv9QUn37mqycqRKEn
|
||||
YfUyYNLM6F3MmQAOs2kaHw==
|
||||
-----END PRIVATE KEY-----
|
||||
@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=MediaCMS uwsgi
|
||||
|
||||
[Service]
|
||||
ExecStart=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/local_install/uwsgi.ini
|
||||
ExecStop=/usr/bin/killall -9 uwsgi
|
||||
RestartSec=3
|
||||
#ExecRestart=killall -9 uwsgi; sleep 5; /home/sss/bin/uwsgi --ini /home/sss/wordgames/uwsgi.ini
|
||||
Restart=always
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,7 +0,0 @@
|
||||
/home/mediacms.io/mediacms/logs/*.log {
|
||||
weekly
|
||||
missingok
|
||||
rotate 7
|
||||
compress
|
||||
notifempty
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 10240;
|
||||
}
|
||||
|
||||
worker_rlimit_nofile 20000; #each connection needs a filehandle (or 2 if you are proxying)
|
||||
http {
|
||||
proxy_connect_timeout 75;
|
||||
proxy_read_timeout 12000;
|
||||
client_max_body_size 5800M;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 10;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
log_format compression '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent" "$gzip_ratio"';
|
||||
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
module selinux-mediacms 1.0;
|
||||
|
||||
require {
|
||||
type init_t;
|
||||
type var_t;
|
||||
type redis_port_t;
|
||||
type postgresql_port_t;
|
||||
type httpd_t;
|
||||
type httpd_sys_content_t;
|
||||
type httpd_sys_rw_content_t;
|
||||
class file { append create execute execute_no_trans getattr ioctl lock open read rename setattr unlink write };
|
||||
class dir { add_name remove_name rmdir };
|
||||
class tcp_socket name_connect;
|
||||
class lnk_file read;
|
||||
}
|
||||
|
||||
#============= httpd_t ==============
|
||||
|
||||
allow httpd_t var_t:file { getattr open read };
|
||||
|
||||
#============= init_t ==============
|
||||
allow init_t postgresql_port_t:tcp_socket name_connect;
|
||||
|
||||
allow init_t redis_port_t:tcp_socket name_connect;
|
||||
|
||||
allow init_t httpd_sys_content_t:dir rmdir;
|
||||
|
||||
allow init_t httpd_sys_content_t:file { append create execute execute_no_trans ioctl lock open read rename setattr unlink write };
|
||||
|
||||
allow init_t httpd_sys_content_t:lnk_file read;
|
||||
|
||||
allow init_t httpd_sys_rw_content_t:dir { add_name remove_name rmdir };
|
||||
|
||||
allow init_t httpd_sys_rw_content_t:file { create ioctl lock open read setattr unlink write };
|
||||
@ -1,27 +0,0 @@
|
||||
[uwsgi]
|
||||
|
||||
chdir = /home/mediacms.io/mediacms/
|
||||
virtualenv = /home/mediacms.io
|
||||
module = cms.wsgi
|
||||
|
||||
uid=www-data
|
||||
gid=www-data
|
||||
|
||||
processes = 2
|
||||
threads = 2
|
||||
|
||||
master = true
|
||||
|
||||
socket = 127.0.0.1:9000
|
||||
#socket = /home/mediacms.io/mediacms/deploy/uwsgi.sock
|
||||
|
||||
|
||||
workers = 2
|
||||
|
||||
|
||||
vacuum = true
|
||||
|
||||
logto = /home/mediacms.io/mediacms/logs/errorlog.txt
|
||||
|
||||
disable-logging = true
|
||||
buffer-size=32768
|
||||
@ -1,16 +0,0 @@
|
||||
uwsgi_param QUERY_STRING $query_string;
|
||||
uwsgi_param REQUEST_METHOD $request_method;
|
||||
uwsgi_param CONTENT_TYPE $content_type;
|
||||
uwsgi_param CONTENT_LENGTH $content_length;
|
||||
|
||||
uwsgi_param REQUEST_URI $request_uri;
|
||||
uwsgi_param PATH_INFO $document_uri;
|
||||
uwsgi_param DOCUMENT_ROOT $document_root;
|
||||
uwsgi_param SERVER_PROTOCOL $server_protocol;
|
||||
uwsgi_param REQUEST_SCHEME $scheme;
|
||||
uwsgi_param HTTPS $https if_not_empty;
|
||||
|
||||
uwsgi_param REMOTE_ADDR $remote_addr;
|
||||
uwsgi_param REMOTE_PORT $remote_port;
|
||||
uwsgi_param SERVER_PORT $server_port;
|
||||
uwsgi_param SERVER_NAME $server_name;
|
||||
@ -23,7 +23,7 @@ and will start all services required for MediaCMS, as Celery/Redis for asynchron
|
||||
For Django, the changes from the image produced by docker-compose.yaml are these:
|
||||
|
||||
* Django runs in debug mode, with `python manage.py runserver`
|
||||
* uwsgi and nginx are not run
|
||||
* gunicorn and nginx are not run
|
||||
* Django runs in Debug mode, with Debug Toolbar
|
||||
* Static files (js/css) are loaded from static/ folder
|
||||
* corsheaders is installed and configured to allow all origins
|
||||
|
||||
@ -65,6 +65,7 @@ class CategoryAdminForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
# LTI fields will be shown as read-only when USE_LTI is enabled
|
||||
fields = '__all__'
|
||||
|
||||
def clean(self):
|
||||
@ -135,7 +136,7 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "user", "add_date", "media_count"]
|
||||
list_filter = []
|
||||
ordering = ("-add_date",)
|
||||
readonly_fields = ("user", "media_count")
|
||||
readonly_fields = ("user", "media_count", "lti_platform", "lti_context_id")
|
||||
change_form_template = 'admin/files/category/change_form.html'
|
||||
|
||||
def get_list_filter(self, request):
|
||||
@ -167,6 +168,14 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
),
|
||||
]
|
||||
|
||||
additional_fieldsets = []
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_fieldset = [
|
||||
('LTI Integration', {'fields': ['lti_platform', 'lti_context_id'], 'classes': ['tab'], 'description': 'LTI/LMS integration settings (automatically managed by LTI provisioning)'}),
|
||||
]
|
||||
additional_fieldsets.extend(lti_fieldset)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_fieldset = [
|
||||
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
@ -177,9 +186,9 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||
]
|
||||
return basic_fieldset + rbac_fieldset
|
||||
else:
|
||||
return basic_fieldset
|
||||
additional_fieldsets.extend(rbac_fieldset)
|
||||
|
||||
return basic_fieldset + additional_fieldsets
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
|
||||
@ -64,4 +64,10 @@ def stuff(request):
|
||||
if request.user.is_superuser:
|
||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_session = request.session.get('lti_session')
|
||||
|
||||
if lti_session and request.user.is_authenticated:
|
||||
ret['lti_session'] = lti_session
|
||||
|
||||
return ret
|
||||
|
||||
@ -965,3 +965,13 @@ def get_alphanumeric_only(string):
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum()])
|
||||
return string.lower()
|
||||
|
||||
|
||||
def get_alphanumeric_and_spaces(string):
|
||||
"""Returns a query that contains only alphanumeric characters and spaces
|
||||
This include characters other than the English alphabet too
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum() or char.isspace()])
|
||||
# Replace multiple spaces with single space and strip
|
||||
string = " ".join(string.split())
|
||||
return string
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0014_alter_subtitle_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='is_lms_course',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Whether this category represents an LMS course'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_context_id',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
|
||||
),
|
||||
]
|
||||
21
files/migrations/0016_category_lti_platform.py
Normal file
21
files/migrations/0016_category_lti_platform.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0015_category_is_lms_course_category_lti_context_id'),
|
||||
('lti', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_platform',
|
||||
field=models.ForeignKey(
|
||||
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -47,6 +47,13 @@ class Category(models.Model):
|
||||
verbose_name='IDP Config Name',
|
||||
)
|
||||
|
||||
# LTI/LMS integration fields
|
||||
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
|
||||
|
||||
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
|
||||
|
||||
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@ -137,7 +144,7 @@ class Tag(models.Model):
|
||||
return True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.title = helpers.get_alphanumeric_only(self.title)
|
||||
self.title = helpers.get_alphanumeric_and_spaces(self.title)
|
||||
self.title = self.title[:100]
|
||||
super(Tag, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@ -352,20 +352,11 @@ class Media(models.Model):
|
||||
# first get anything interesting out of the media
|
||||
# that needs to be search able
|
||||
|
||||
a_tags = b_tags = ""
|
||||
a_tags = ""
|
||||
if self.id:
|
||||
a_tags = " ".join([tag.title for tag in self.tags.all()])
|
||||
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
|
||||
|
||||
items = [
|
||||
self.title,
|
||||
self.user.username,
|
||||
self.user.email,
|
||||
self.user.name,
|
||||
self.description,
|
||||
a_tags,
|
||||
b_tags,
|
||||
]
|
||||
items = [self.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags]
|
||||
|
||||
for subtitle in self.subtitles.all():
|
||||
items.append(subtitle.subtitle_text)
|
||||
|
||||
@ -80,6 +80,7 @@ urlpatterns = [
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/categories/contributor$", views.CategoryListContributor.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
re_path(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Import all views for backward compatibility
|
||||
|
||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||
from .categories import CategoryList, TagList # noqa: F401
|
||||
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
||||
from .comments import CommentDetail, CommentList # noqa: F401
|
||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
||||
from .media import MediaActions # noqa: F401
|
||||
|
||||
@ -43,6 +43,40 @@ class CategoryList(APIView):
|
||||
return Response(ret)
|
||||
|
||||
|
||||
class CategoryListContributor(APIView):
|
||||
"""List categories where user has contributor access"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[],
|
||||
tags=['Categories'],
|
||||
operation_summary='Lists Categories for Contributors',
|
||||
operation_description='Lists all categories where the user has contributor access',
|
||||
responses={
|
||||
200: openapi.Response('response description', CategorySerializer),
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
if not request.user.is_authenticated:
|
||||
return Response([])
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Get global/public categories (non-RBAC)
|
||||
public_categories = Category.objects.filter(is_rbac_category=False).prefetch_related("user")
|
||||
|
||||
# Get RBAC categories where user has contributor access
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_contributor()
|
||||
categories = public_categories.union(rbac_categories)
|
||||
else:
|
||||
categories = public_categories
|
||||
|
||||
categories = categories.order_by("title")
|
||||
|
||||
serializer = CategorySerializer(categories, many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TagList(APIView):
|
||||
"""List tags"""
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ from ..forms import (
|
||||
WhisperSubtitlesForm,
|
||||
)
|
||||
from ..frontend_translations import translate_string
|
||||
from ..helpers import get_alphanumeric_only
|
||||
from ..helpers import get_alphanumeric_and_spaces
|
||||
from ..methods import (
|
||||
can_transcribe_video,
|
||||
create_video_trim_request,
|
||||
@ -310,8 +310,8 @@ def edit_media(request):
|
||||
media.tags.remove(tag)
|
||||
if form.cleaned_data.get("new_tags"):
|
||||
for tag in form.cleaned_data.get("new_tags").split(","):
|
||||
tag = get_alphanumeric_only(tag)
|
||||
tag = tag[:99]
|
||||
tag = get_alphanumeric_and_spaces(tag)
|
||||
tag = tag[:100]
|
||||
if tag:
|
||||
try:
|
||||
tag = Tag.objects.get(title=tag)
|
||||
|
||||
@ -785,6 +785,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
.edit-media-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.popup-fullscreen-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popup-main {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
|
||||
.dark_theme & {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-options {
|
||||
.navigation-menu-list {
|
||||
padding: 8px 0;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
.dark_theme & {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 153, 51, 0.1);
|
||||
|
||||
.dark_theme & {
|
||||
background-color: rgba(102, 187, 102, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
margin-right: 12px;
|
||||
font-size: 20px;
|
||||
color: rgba(0, 153, 51, 0.9);
|
||||
|
||||
.dark_theme & {
|
||||
color: rgba(102, 187, 102, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remove-media-icon {
|
||||
background-color: rgba(220, 53, 69, 0.9);
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { useUser, usePopup } from '../../utils/hooks/';
|
||||
import { PageStore, MediaPageStore } from '../../utils/stores/';
|
||||
import { PageActions, MediaPageActions } from '../../utils/actions/';
|
||||
import { formatInnerLink, publishedOnDate } from '../../utils/helpers/';
|
||||
import { PopupMain } from '../_shared/';
|
||||
import { PopupMain, CircleIconButton, MaterialIcon, NavigationMenuList, NavigationContentApp } from '../_shared/';
|
||||
import CommentsList from '../comments/Comments';
|
||||
import { replaceString } from '../../utils/helpers/';
|
||||
import { translateString } from '../../utils/helpers/';
|
||||
@ -72,17 +72,103 @@ function MediaMetaField(props) {
|
||||
);
|
||||
}
|
||||
|
||||
function EditMediaButton(props) {
|
||||
let link = props.link;
|
||||
function getEditMenuItems() {
|
||||
const items = [];
|
||||
const friendlyToken = window.MediaCMS.mediaId;
|
||||
const mediaData = MediaPageStore.get('media-data');
|
||||
const mediaType = mediaData ? mediaData.media_type : null;
|
||||
const isVideoOrAudio = mediaType === 'video' || mediaType === 'audio';
|
||||
const allowMediaReplacement = window.MediaCMS.features.media.actions.allowMediaReplacement;
|
||||
|
||||
if (window.MediaCMS.site.devEnv) {
|
||||
link = '/edit-media.html';
|
||||
// Edit metadata - always available
|
||||
items.push({
|
||||
itemType: 'link',
|
||||
link: `/edit?m=${friendlyToken}`,
|
||||
text: translateString('Edit metadata'),
|
||||
icon: 'edit',
|
||||
});
|
||||
|
||||
// Trim - only for video/audio
|
||||
if (isVideoOrAudio) {
|
||||
items.push({
|
||||
itemType: 'link',
|
||||
link: `/edit_video?m=${friendlyToken}`,
|
||||
text: translateString('Trim'),
|
||||
icon: 'content_cut',
|
||||
});
|
||||
}
|
||||
|
||||
// Captions - only for video/audio
|
||||
if (isVideoOrAudio) {
|
||||
items.push({
|
||||
itemType: 'link',
|
||||
link: `/add_subtitle?m=${friendlyToken}`,
|
||||
text: translateString('Captions'),
|
||||
icon: 'closed_caption',
|
||||
});
|
||||
}
|
||||
|
||||
// Chapters - only for video/audio
|
||||
if (isVideoOrAudio) {
|
||||
items.push({
|
||||
itemType: 'link',
|
||||
link: `/edit_chapters?m=${friendlyToken}`,
|
||||
text: 'Chapters',
|
||||
icon: 'menu_book',
|
||||
});
|
||||
}
|
||||
|
||||
// Publish - always available
|
||||
items.push({
|
||||
itemType: 'link',
|
||||
link: `/publish?m=${friendlyToken}`,
|
||||
text: translateString('Publish'),
|
||||
icon: 'publish',
|
||||
});
|
||||
|
||||
// Replace - only if enabled
|
||||
if (allowMediaReplacement) {
|
||||
items.push({
|
||||
itemType: 'link',
|
||||
link: `/replace_media?m=${friendlyToken}`,
|
||||
text: translateString('Replace'),
|
||||
icon: 'swap_horiz',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function EditMediaButton(props) {
|
||||
const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
|
||||
|
||||
const menuItems = getEditMenuItems();
|
||||
|
||||
const popupPages = {
|
||||
main: (
|
||||
<div className="edit-options">
|
||||
<PopupMain>
|
||||
<NavigationMenuList items={menuItems} />
|
||||
</PopupMain>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
|
||||
<i className="material-icons">edit</i>
|
||||
</a>
|
||||
<div className="edit-media-dropdown">
|
||||
<PopupTrigger contentRef={popupContentRef}>
|
||||
<button className="edit-media-icon" title={translateString('Edit media')}>
|
||||
<i className="material-icons">edit</i>
|
||||
</button>
|
||||
</PopupTrigger>
|
||||
<PopupContent contentRef={popupContentRef}>
|
||||
<NavigationContentApp
|
||||
initPage="main"
|
||||
focusFirstItemOnPageChange={false}
|
||||
pages={popupPages}
|
||||
/>
|
||||
</PopupContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -219,7 +305,7 @@ export default function ViewerInfoContent(props) {
|
||||
|
||||
{userCan.editMedia ? (
|
||||
<div className="media-author-actions">
|
||||
{userCan.editMedia ? <EditMediaButton link={MediaPageStore.get('media-data').edit_url} /> : null}
|
||||
{userCan.editMedia ? <EditMediaButton /> : null}
|
||||
|
||||
{userCan.deleteMedia ? (
|
||||
<PopupTrigger contentRef={popupContentRef}>
|
||||
|
||||
302
install-rhel.sh
302
install-rhel.sh
@ -1,302 +0,0 @@
|
||||
#!/bin/bash
|
||||
# should be run as root on a rhel8-like system
|
||||
|
||||
function update_permissions
|
||||
{
|
||||
# fix permissions of /srv/mediacms directory
|
||||
chown -R nginx:root $1
|
||||
}
|
||||
|
||||
echo "Welcome to the MediacMS installation!";
|
||||
|
||||
if [ `id -u` -ne 0 ]; then
|
||||
echo "Please run as root user"
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update, install required dependencies, and configure PostgreSQL, NGINX, Redis and a few other utilities.
|
||||
It is expected to run on a new system **with no running instances of any these services**. Make sure you check the script before you continue. Then enter y or n
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
[Nn]* ) echo "Have a great day"; exit;;
|
||||
* ) echo "Please answer y or n.";;
|
||||
esac
|
||||
done
|
||||
|
||||
# update configuration files
|
||||
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms\/Bento4-SDK-1-6-0-637\.x86_64-unknown-linux\/bin\/mp4hls/\/srv\/mediacms\/bento4\/bin\/mp4hls/g' cms/settings.py
|
||||
sed -i 's/www-data/nginx/g;s/\/home\/mediacms\.io\/mediacms\/logs/\/var\/log\/mediacms/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g;s/\/home\/mediacms\.io\/bin/\/srv\/mediacms\/virtualenv\/bin/g' deploy/local_install/celery_*.service
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g' deploy/local_install/mediacms.io
|
||||
sed -i 's/\/home\/mediacms\.io\/bin/\/srv\/mediacms\/virtualenv\/bin/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g' deploy/local_install/mediacms.service
|
||||
sed -i 's/\/home\/mediacms\.io\/mediacms/\/var\/log\/mediacms/g' deploy/local_install/mediacms_logrorate
|
||||
sed -i 's/www-data/nginx/g' deploy/local_install/nginx.conf
|
||||
sed -i 's/www-data/nginx/g;s/\/home\/mediacms\.io\/mediacms\/logs/\/var\/log\/mediacms/g;s/\/home\/mediacms\.io\/mediacms/\/srv\/mediacms/g;s/\/home\/mediacms\.io/\/srv\/mediacms\/virtualenv/g' deploy/local_install/uwsgi.ini
|
||||
|
||||
osVersion=
|
||||
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
osVersion=$(grep ^ID /etc/os-release)
|
||||
fi
|
||||
|
||||
if [[ $osVersion == *"fedora"* ]] || [[ $osVersion == *"rhel"* ]] || [[ $osVersion == *"centos"* ]] || [[ *"rocky"* ]]; then
|
||||
dnf install -y epel-release https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-8.noarch.rpm yum-utils
|
||||
yum-config-manager --enable powertools
|
||||
dnf install -y python3-virtualenv python39-devel redis postgresql postgresql-server nginx git gcc vim unzip ImageMagick python3-certbot-nginx certbot wget xz ffmpeg policycoreutils-devel cmake gcc gcc-c++ wget git bsdtar
|
||||
else
|
||||
echo "unsupported or unknown os"
|
||||
exit -1
|
||||
fi
|
||||
|
||||
# fix permissions of /srv/mediacms directory
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
read -p "Enter portal URL, or press enter for localhost : " FRONTEND_HOST
|
||||
read -p "Enter portal name, or press enter for 'MediaCMS : " PORTAL_NAME
|
||||
|
||||
[ -z "$PORTAL_NAME" ] && PORTAL_NAME='MediaCMS'
|
||||
[ -z "$FRONTEND_HOST" ] && FRONTEND_HOST='localhost'
|
||||
|
||||
echo "Configuring postgres"
|
||||
if [ ! command -v postgresql-setup > /dev/null 2>&1 ]; then
|
||||
echo "Something went wrong, the command 'postgresql-setup' was not found in the system path."
|
||||
exit -1
|
||||
fi
|
||||
|
||||
postgresql-setup --initdb
|
||||
|
||||
# set authentication method for mediacms user to scram-sha-256
|
||||
sed -i 's/.*password_encryption.*/password_encryption = scram-sha-256/' /var/lib/pgsql/data/postgresql.conf
|
||||
sed -i '/# IPv4 local connections:/a host\tmediacms\tmediacms\t127.0.0.1/32\tscram-sha-256' /var/lib/pgsql/data/pg_hba.conf
|
||||
|
||||
systemctl enable postgresql.service --now
|
||||
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /srv/mediacms/virtualenv/'
|
||||
|
||||
mkdir /srv/mediacms/virtualenv/
|
||||
cd /srv/mediacms/virtualenv/
|
||||
virtualenv . --python=python3
|
||||
source /srv/mediacms/virtualenv/bin/activate
|
||||
cd /srv/mediacms/
|
||||
pip install -r requirements.txt
|
||||
|
||||
systemctl enable redis.service --now
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
# remove http or https prefix
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/http:\/\///g'`
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/https:\/\///g'`
|
||||
|
||||
FRONTEND_HOST_HTTP_PREFIX='http://'$FRONTEND_HOST
|
||||
|
||||
echo 'FRONTEND_HOST='\'"$FRONTEND_HOST_HTTP_PREFIX"\' >> cms/local_settings.py
|
||||
echo 'PORTAL_NAME='\'"$PORTAL_NAME"\' >> cms/local_settings.py
|
||||
echo "SSL_FRONTEND_HOST = FRONTEND_HOST.replace('http', 'https')" >> cms/local_settings.py
|
||||
|
||||
echo 'SECRET_KEY='\'"$SECRET_KEY"\' >> cms/local_settings.py
|
||||
echo "LOCAL_INSTALL = True" >> cms/local_settings.py
|
||||
|
||||
mkdir /var/log/mediacms/
|
||||
mkdir pids
|
||||
|
||||
update_permissions /var/log/mediacms/
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata fixtures/encoding_profiles.json
|
||||
python manage.py loaddata fixtures/categories.json
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
ADMIN_PASS=`python -c "import secrets;chars = 'abcdefghijklmnopqrstuvwxyz0123456789';print(''.join(secrets.choice(chars) for i in range(10)))"`
|
||||
echo "from users.models import User; User.objects.create_superuser('admin', 'admin@example.com', '$ADMIN_PASS')" | python manage.py shell
|
||||
|
||||
echo "from django.contrib.sites.models import Site; Site.objects.update(name='$FRONTEND_HOST', domain='$FRONTEND_HOST')" | python manage.py shell
|
||||
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service
|
||||
cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service
|
||||
cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service
|
||||
cp deploy/local_install/mediacms.service /etc/systemd/system/mediacms.service
|
||||
|
||||
mkdir -p /etc/letsencrypt/live/$FRONTEND_HOST
|
||||
mkdir -p /etc/nginx/sites-enabled
|
||||
mkdir -p /etc/nginx/sites-available
|
||||
mkdir -p /etc/nginx/dhparams/
|
||||
rm -rf /etc/nginx/conf.d/default.conf
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
cp deploy/local_install/mediacms.io_fullchain.pem /etc/letsencrypt/live/$FRONTEND_HOST/fullchain.pem
|
||||
cp deploy/local_install/mediacms.io_privkey.pem /etc/letsencrypt/live/$FRONTEND_HOST/privkey.pem
|
||||
cp deploy/local_install/mediacms.io /etc/nginx/sites-available/mediacms.io
|
||||
ln -s /etc/nginx/sites-available/mediacms.io /etc/nginx/sites-enabled/mediacms.io
|
||||
cp deploy/local_install/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/local_install/nginx.conf /etc/nginx/
|
||||
|
||||
# attempt to get a valid certificate for specified domain
|
||||
while true ; do
|
||||
echo "Would you like to run [c]ertbot, or [s]kip?"
|
||||
read -p " : " certbotConfig
|
||||
|
||||
case $certbotConfig in
|
||||
[cC*] )
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
systemctl start
|
||||
echo 'attempt to get a valid certificate for specified url $FRONTEND_HOST'
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
# unfortunately for some reason it needs to be run two times in order to create the entries
|
||||
# and directory structure!!!
|
||||
systemctl stop nginx
|
||||
|
||||
# Generate individual DH params
|
||||
openssl dhparam -out /etc/nginx/dhparams/dhparams.pem 4096
|
||||
fi
|
||||
|
||||
break
|
||||
;;
|
||||
[sS*] )
|
||||
echo "will not call certbot utility to update ssl certificate for url 'localhost', using default ssl certificate"
|
||||
cp deploy/local_install/dhparams.pem /etc/nginx/dhparams/dhparams.pem
|
||||
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $certbotConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# configure bento4 utility installation, for HLS
|
||||
while true ; do
|
||||
echo "Configuring Bento4"
|
||||
echo "Would you like to [d]ownload a pre-compiled bento4 binary, or [b]uild it now?"
|
||||
read -p "b/d : " bentoConfig
|
||||
|
||||
case $bentoConfig in
|
||||
[bB*] )
|
||||
echo "Building bento4 from source"
|
||||
git clone -b v1.6.0-640 https://github.com/axiomatic-systems/Bento4 /srv/mediacms/bento4
|
||||
cd /srv/mediacms/bento4/
|
||||
mkdir bin
|
||||
cd /srv/mediacms/bento4/bin/
|
||||
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||
make -j$(nproc)
|
||||
|
||||
chmod +x ../Source/Python/utils/mp4-hls.py
|
||||
|
||||
echo -e '#!/bin/bash' >> mp4hls
|
||||
echo -e 'BASEDIR=$(pwd)' >> mp4hls
|
||||
echo -e 'exec python3 "$BASEDIR/../Source/Python/utils/mp4-hls.py"' >> mp4hls
|
||||
|
||||
chmod +x mp4hls
|
||||
|
||||
break
|
||||
;;
|
||||
[dD*] )
|
||||
cd /srv/mediacms/
|
||||
wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
bsdtar -xf Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -s '/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bento4/'
|
||||
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $bentoConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir /srv/mediacms/media_files/hls
|
||||
|
||||
# update permissions
|
||||
|
||||
update_permissions /srv/mediacms/
|
||||
|
||||
# configure selinux
|
||||
|
||||
while true ; do
|
||||
echo "Configuring SELinux"
|
||||
echo "Would you like to [d]isable SELinux until next reboot, [c]onfigure our SELinux module, or [s]kip and not do any SELinux confgiguration?"
|
||||
read -p "d/c/s : " seConfig
|
||||
|
||||
case $seConfig in
|
||||
[Dd]* )
|
||||
echo "Disabling SELinux until next reboot"
|
||||
break
|
||||
;;
|
||||
[Cc]* )
|
||||
echo "Configuring custom mediacms selinux module"
|
||||
|
||||
semanage fcontext -a -t bin_t /srv/mediacms/virtualenv/bin/
|
||||
semanage fcontext -a -t httpd_sys_content_t "/srv/mediacms(/.*)?"
|
||||
restorecon -FRv /srv/mediacms/
|
||||
|
||||
sebools=(httpd_can_network_connect httpd_graceful_shutdown httpd_can_network_relay nis_enabled httpd_setrlimit domain_can_mmap_files)
|
||||
|
||||
for bool in "${sebools[@]}"
|
||||
do
|
||||
setsebool -P $bool 1
|
||||
done
|
||||
|
||||
cd /srv/mediacms/deploy/local_install/
|
||||
make -f /usr/share/selinux/devel/Makefile selinux-mediacms.pp
|
||||
semodule -i selinux-mediacms.pp
|
||||
|
||||
break
|
||||
;;
|
||||
[Ss]* )
|
||||
echo "Skipping SELinux configuration"
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $seConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# configure firewall
|
||||
if command -v firewall-cmd > /dev/null 2>&1 ; then
|
||||
while true ; do
|
||||
echo "Configuring firewall"
|
||||
echo "Would you like to configure http, https, or skip and not do any firewall configuration?"
|
||||
read -p "http/https/skip : " fwConfig
|
||||
|
||||
case $fwConfig in
|
||||
http )
|
||||
echo "Opening port 80 until next reboot"
|
||||
firewall-cmd --add-port=80/tcp
|
||||
break
|
||||
;;
|
||||
https )
|
||||
echo "Opening port 443 permanently"
|
||||
firewall-cmd --add-port=443/tcp --permanent
|
||||
firewall-cmd --reload
|
||||
break
|
||||
;;
|
||||
skip )
|
||||
echo "Skipping firewall configuration"
|
||||
break
|
||||
;;
|
||||
* )
|
||||
echo "Unknown option: $fwConfig"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl start celery_long.service
|
||||
systemctl start celery_short.service
|
||||
systemctl start celery_beat.service
|
||||
systemctl start mediacms.service
|
||||
systemctl start nginx.service
|
||||
|
||||
echo 'MediaCMS installation completed, open browser on http://'"$FRONTEND_HOST"' and login with user admin and password '"$ADMIN_PASS"''
|
||||
140
install.sh
140
install.sh
@ -1,140 +0,0 @@
|
||||
#!/bin/bash
|
||||
# should be run as root and only on Ubuntu 20/22, Debian 10/11 (Buster/Bullseye) versions!
|
||||
echo "Welcome to the MediacMS installation!";
|
||||
|
||||
if [ `id -u` -ne 0 ]
|
||||
then echo "Please run as root"
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
while true; do
|
||||
read -p "
|
||||
This script will attempt to perform a system update and install services including PostgreSQL, nginx and Django.
|
||||
It is expected to run on a new system **with no running instances of any these services**.
|
||||
This has been tested only in Ubuntu Linux 22 and 24. Make sure you check the script before you continue. Then enter yes or no
|
||||
" yn
|
||||
case $yn in
|
||||
[Yy]* ) echo "OK!"; break;;
|
||||
[Nn]* ) echo "Have a great day"; exit;;
|
||||
* ) echo "Please answer yes or no.";;
|
||||
esac
|
||||
done
|
||||
|
||||
apt-get update && apt-get -y upgrade && apt-get install pkg-config python3-venv python3-dev virtualenv redis-server postgresql nginx git gcc vim unzip imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl python3-certbot-nginx certbot wget xz-utils -y
|
||||
|
||||
# install ffmpeg
|
||||
echo "Downloading and installing ffmpeg"
|
||||
wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
mkdir -p tmp
|
||||
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C tmp
|
||||
cp -v tmp/{ffmpeg,ffprobe,qt-faststart} /usr/local/bin
|
||||
rm -rf tmp ffmpeg-release-amd64-static.tar.xz
|
||||
echo "ffmpeg installed to /usr/local/bin"
|
||||
|
||||
read -p "Enter portal URL, or press enter for localhost : " FRONTEND_HOST
|
||||
read -p "Enter portal name, or press enter for 'MediaCMS : " PORTAL_NAME
|
||||
|
||||
[ -z "$PORTAL_NAME" ] && PORTAL_NAME='MediaCMS'
|
||||
[ -z "$FRONTEND_HOST" ] && FRONTEND_HOST='localhost'
|
||||
|
||||
echo 'Creating database to be used in MediaCMS'
|
||||
|
||||
su -c "psql -c \"CREATE DATABASE mediacms\"" postgres
|
||||
su -c "psql -c \"CREATE USER mediacms WITH ENCRYPTED PASSWORD 'mediacms'\"" postgres
|
||||
su -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE mediacms TO mediacms\"" postgres
|
||||
su -c "psql -d mediacms -c \"GRANT CREATE, USAGE ON SCHEMA public TO mediacms\"" postgres
|
||||
|
||||
echo 'Creating python virtualenv on /home/mediacms.io'
|
||||
|
||||
cd /home/mediacms.io
|
||||
virtualenv . --python=python3
|
||||
source /home/mediacms.io/bin/activate
|
||||
cd mediacms
|
||||
pip install --no-binary lxml,xmlsec -r requirements.txt
|
||||
|
||||
SECRET_KEY=`python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'`
|
||||
|
||||
# remove http or https prefix
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/http:\/\///g'`
|
||||
FRONTEND_HOST=`echo "$FRONTEND_HOST" | sed -r 's/https:\/\///g'`
|
||||
|
||||
sed -i s/localhost/$FRONTEND_HOST/g deploy/local_install/mediacms.io
|
||||
|
||||
FRONTEND_HOST_HTTP_PREFIX='http://'$FRONTEND_HOST
|
||||
|
||||
echo 'FRONTEND_HOST='\'"$FRONTEND_HOST_HTTP_PREFIX"\' >> cms/local_settings.py
|
||||
echo 'PORTAL_NAME='\'"$PORTAL_NAME"\' >> cms/local_settings.py
|
||||
echo "SSL_FRONTEND_HOST = FRONTEND_HOST.replace('http', 'https')" >> cms/local_settings.py
|
||||
|
||||
echo 'SECRET_KEY='\'"$SECRET_KEY"\' >> cms/local_settings.py
|
||||
echo "LOCAL_INSTALL = True" >> cms/local_settings.py
|
||||
|
||||
mkdir logs
|
||||
mkdir pids
|
||||
python manage.py migrate
|
||||
python manage.py loaddata fixtures/encoding_profiles.json
|
||||
python manage.py loaddata fixtures/categories.json
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
ADMIN_PASS=`python -c "import secrets;chars = 'abcdefghijklmnopqrstuvwxyz0123456789';print(''.join(secrets.choice(chars) for i in range(10)))"`
|
||||
echo "from users.models import User; User.objects.create_superuser('admin', 'admin@example.com', '$ADMIN_PASS')" | python manage.py shell
|
||||
|
||||
echo "from django.contrib.sites.models import Site; Site.objects.update(name='$FRONTEND_HOST', domain='$FRONTEND_HOST')" | python manage.py shell
|
||||
|
||||
chown -R www-data. /home/mediacms.io/
|
||||
cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service && systemctl enable celery_long && systemctl start celery_long
|
||||
cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service && systemctl enable celery_short && systemctl start celery_short
|
||||
cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service && systemctl enable celery_beat &&systemctl start celery_beat
|
||||
cp deploy/local_install/mediacms.service /etc/systemd/system/mediacms.service && systemctl enable mediacms.service && systemctl start mediacms.service
|
||||
|
||||
mkdir -p /etc/letsencrypt/live/mediacms.io/
|
||||
mkdir -p /etc/letsencrypt/live/$FRONTEND_HOST
|
||||
mkdir -p /etc/nginx/sites-enabled
|
||||
mkdir -p /etc/nginx/sites-available
|
||||
mkdir -p /etc/nginx/dhparams/
|
||||
rm -rf /etc/nginx/conf.d/default.conf
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
cp deploy/local_install/mediacms.io_fullchain.pem /etc/letsencrypt/live/$FRONTEND_HOST/fullchain.pem
|
||||
cp deploy/local_install/mediacms.io_privkey.pem /etc/letsencrypt/live/$FRONTEND_HOST/privkey.pem
|
||||
cp deploy/local_install/dhparams.pem /etc/nginx/dhparams/dhparams.pem
|
||||
cp deploy/local_install/mediacms.io /etc/nginx/sites-available/mediacms.io
|
||||
ln -s /etc/nginx/sites-available/mediacms.io /etc/nginx/sites-enabled/mediacms.io
|
||||
cp deploy/local_install/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/local_install/nginx.conf /etc/nginx/
|
||||
systemctl stop nginx
|
||||
systemctl start nginx
|
||||
|
||||
# attempt to get a valid certificate for specified domain
|
||||
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
echo 'attempt to get a valid certificate for specified url $FRONTEND_HOST'
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
certbot --nginx -n --agree-tos --register-unsafely-without-email -d $FRONTEND_HOST
|
||||
# unfortunately for some reason it needs to be run two times in order to create the entries
|
||||
# and directory structure!!!
|
||||
systemctl restart nginx
|
||||
else
|
||||
echo "will not call certbot utility to update ssl certificate for url 'localhost', using default ssl certificate"
|
||||
fi
|
||||
|
||||
# Generate individual DH params
|
||||
if [ "$FRONTEND_HOST" != "localhost" ]; then
|
||||
# Only generate new DH params when using "real" certificates.
|
||||
openssl dhparam -out /etc/nginx/dhparams/dhparams.pem 4096
|
||||
systemctl restart nginx
|
||||
else
|
||||
echo "will not generate new DH params for url 'localhost', using default DH params"
|
||||
fi
|
||||
|
||||
# Bento4 utility installation, for HLS
|
||||
|
||||
cd /home/mediacms.io/mediacms
|
||||
wget http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
mkdir /home/mediacms.io/mediacms/media_files/hls
|
||||
|
||||
# last, set default owner
|
||||
chown -R www-data. /home/mediacms.io/
|
||||
|
||||
echo 'MediaCMS installation completed, open browser on http://'"$FRONTEND_HOST"' and login with user admin and password '"$ADMIN_PASS"''
|
||||
6
lti/__init__.py
Normal file
6
lti/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""
|
||||
LTI 1.3 Integration for MediaCMS
|
||||
Enables integration with Learning Management Systems like Moodle
|
||||
"""
|
||||
|
||||
default_app_config = 'lti.apps.LtiConfig'
|
||||
461
lti/adapters.py
Normal file
461
lti/adapters.py
Normal file
@ -0,0 +1,461 @@
|
||||
"""
|
||||
PyLTI1p3 Django adapters for MediaCMS
|
||||
|
||||
Provides Django-specific implementations for PyLTI1p3 interfaces
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from django.core.cache import cache
|
||||
from jwcrypto import jwk
|
||||
from pylti1p3.message_launch import MessageLaunch
|
||||
from pylti1p3.oidc_login import OIDCLogin
|
||||
from pylti1p3.registration import Registration
|
||||
from pylti1p3.request import Request
|
||||
from pylti1p3.service_connector import ServiceConnector
|
||||
from pylti1p3.tool_config import ToolConfAbstract
|
||||
|
||||
from .models import LTIPlatform, LTIToolKeys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DjangoRequest(Request):
|
||||
"""Django request adapter for PyLTI1p3"""
|
||||
|
||||
def __init__(self, request):
|
||||
super().__init__()
|
||||
self._request = request
|
||||
self._cookies = request.COOKIES
|
||||
self._session = request.session
|
||||
|
||||
def get_param(self, key):
|
||||
"""Get parameter from GET or POST"""
|
||||
value = self._request.POST.get(key) or self._request.GET.get(key)
|
||||
return value
|
||||
|
||||
def get_cookie(self, key):
|
||||
"""Get cookie value"""
|
||||
return self._cookies.get(key)
|
||||
|
||||
def is_secure(self):
|
||||
"""Check if request is secure (HTTPS)"""
|
||||
return self._request.is_secure()
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""Get session"""
|
||||
return self._session
|
||||
|
||||
def _get_request_param(self, key):
|
||||
"""Internal method for PyLTI1p3 compatibility"""
|
||||
return self.get_param(key)
|
||||
|
||||
|
||||
class DjangoOIDCLogin:
|
||||
"""Handles OIDC login initiation"""
|
||||
|
||||
def __init__(self, request, tool_config, launch_data_storage=None):
|
||||
self.request = request
|
||||
self.lti_request = DjangoRequest(request)
|
||||
self.tool_config = tool_config
|
||||
self.launch_data_storage = launch_data_storage or DjangoSessionService(request)
|
||||
|
||||
def get_redirect(self, redirect_url):
|
||||
"""Get the redirect object for OIDC login"""
|
||||
oidc_login = OIDCLogin(self.lti_request, self.tool_config, session_service=self.launch_data_storage, cookie_service=self.launch_data_storage)
|
||||
|
||||
return oidc_login.enable_check_cookies().redirect(redirect_url)
|
||||
|
||||
|
||||
class DjangoMessageLaunch:
|
||||
"""Handles LTI message launch validation"""
|
||||
|
||||
def __init__(self, request, tool_config, launch_data_storage=None):
|
||||
self.request = request
|
||||
self.lti_request = DjangoRequest(request)
|
||||
self.tool_config = tool_config
|
||||
self.launch_data_storage = launch_data_storage or DjangoSessionService(request)
|
||||
|
||||
def validate(self):
|
||||
"""Validate the LTI launch message"""
|
||||
|
||||
class CustomMessageLaunch(MessageLaunch):
|
||||
def _get_request_param(self, key):
|
||||
"""Override to properly get request parameters"""
|
||||
return self._request.get_param(key)
|
||||
|
||||
message_launch = CustomMessageLaunch(self.lti_request, self.tool_config, session_service=self.launch_data_storage, cookie_service=self.launch_data_storage)
|
||||
|
||||
return message_launch
|
||||
|
||||
|
||||
class DjangoSessionService:
|
||||
"""
|
||||
Launch data storage using Django cache for state/nonce (to avoid race conditions)
|
||||
and Django sessions for other data
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
self._session_key_prefix = 'lti1p3_'
|
||||
self._cache_prefix = 'lti1p3_cache_'
|
||||
|
||||
def _use_cache_for_key(self, key):
|
||||
"""Determine if this key should use cache (for concurrent access safety)"""
|
||||
# Use cache for state and nonce to avoid race conditions in concurrent launches
|
||||
return key.startswith('state-') or key.startswith('nonce-')
|
||||
|
||||
def get_launch_data(self, key):
|
||||
"""Get launch data from cache or session depending on key type"""
|
||||
if self._use_cache_for_key(key):
|
||||
# Get from cache (atomic, no race condition)
|
||||
cache_key = self._cache_prefix + key
|
||||
data = cache.get(cache_key)
|
||||
else:
|
||||
# Get from session (for non-concurrent data)
|
||||
session_key = self._session_key_prefix + key
|
||||
data = self.request.session.get(session_key)
|
||||
|
||||
return json.loads(data) if data else None
|
||||
|
||||
def save_launch_data(self, key, data):
|
||||
"""Save launch data to cache or session depending on key type"""
|
||||
if self._use_cache_for_key(key):
|
||||
# Save to cache with 10 minute expiration (atomic operation, no race condition)
|
||||
cache_key = self._cache_prefix + key
|
||||
cache.set(cache_key, json.dumps(data), timeout=600)
|
||||
else:
|
||||
# Save to session (for non-concurrent data)
|
||||
session_key = self._session_key_prefix + key
|
||||
self.request.session[session_key] = json.dumps(data)
|
||||
self.request.session.modified = True
|
||||
|
||||
return True
|
||||
|
||||
def check_launch_data_storage_exists(self, key):
|
||||
"""Check if launch data exists in cache or session"""
|
||||
if self._use_cache_for_key(key):
|
||||
# Check cache
|
||||
cache_key = self._cache_prefix + key
|
||||
return cache.get(cache_key) is not None
|
||||
else:
|
||||
# Check session
|
||||
session_key = self._session_key_prefix + key
|
||||
return session_key in self.request.session
|
||||
|
||||
def check_state_is_valid(self, state, nonce):
|
||||
"""Check if state is valid - state is for CSRF protection, nonce is validated separately by JWT"""
|
||||
state_key = f'state-{state}'
|
||||
|
||||
state_data = self.get_launch_data(state_key)
|
||||
|
||||
if not state_data:
|
||||
return False
|
||||
|
||||
# State exists - that's sufficient for CSRF protection
|
||||
# Nonce validation is handled by PyLTI1p3 through JWT signature and claims validation
|
||||
return True
|
||||
|
||||
def check_nonce(self, nonce):
|
||||
"""Check if nonce is valid (not used before) and mark it as used"""
|
||||
nonce_key = f'nonce-{nonce}'
|
||||
|
||||
# Check if nonce was already used
|
||||
if self.check_launch_data_storage_exists(nonce_key):
|
||||
return False
|
||||
|
||||
# Mark nonce as used
|
||||
self.save_launch_data(nonce_key, {'used': True})
|
||||
return True
|
||||
|
||||
def set_state_valid(self, state, id_token_hash):
|
||||
"""Mark state as valid and associate it with the id_token_hash"""
|
||||
state_key = f'state-{state}'
|
||||
self.save_launch_data(state_key, {'valid': True, 'id_token_hash': id_token_hash})
|
||||
return True
|
||||
|
||||
def get_cookie(self, key):
|
||||
"""Get cookie value (for cookie service compatibility)"""
|
||||
return self.request.COOKIES.get(key)
|
||||
|
||||
def set_cookie(self, key, value, exp=3600):
|
||||
"""Set cookie value (for cookie service compatibility)"""
|
||||
# Note: Actual cookie setting happens in the response, not here
|
||||
# This is just for interface compatibility
|
||||
return True
|
||||
|
||||
|
||||
class DjangoCacheDataStorage:
|
||||
"""Key/value storage using Django cache"""
|
||||
|
||||
def __init__(self, cache_name='default', **kwargs):
|
||||
self._cache = cache
|
||||
self._prefix = 'lti1p3_cache_'
|
||||
|
||||
def get_value(self, key):
|
||||
"""Get value from cache"""
|
||||
cache_key = self._prefix + key
|
||||
return self._cache.get(cache_key)
|
||||
|
||||
def set_value(self, key, value, exp=3600):
|
||||
"""Set value in cache with expiration"""
|
||||
cache_key = self._prefix + key
|
||||
return self._cache.set(cache_key, value, timeout=exp)
|
||||
|
||||
def check_value(self, key):
|
||||
"""Check if value exists in cache"""
|
||||
cache_key = self._prefix + key
|
||||
return cache_key in self._cache
|
||||
|
||||
|
||||
class DjangoServiceConnector(ServiceConnector):
|
||||
def __init__(self, registration):
|
||||
super().__init__(registration)
|
||||
self._registration = registration
|
||||
self._access_token = None
|
||||
self._access_token_expires = 0
|
||||
|
||||
def get_access_token(self, scopes):
|
||||
if self._access_token and time.time() < self._access_token_expires:
|
||||
return self._access_token
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
jwk_obj = jwk.JWK(**key_obj.private_key_jwk)
|
||||
pem_bytes = jwk_obj.export_to_pem(private_key=True, password=None)
|
||||
private_key = serialization.load_pem_private_key(pem_bytes, password=None, backend=default_backend())
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
'iss': self._registration.get_client_id(),
|
||||
'sub': self._registration.get_client_id(),
|
||||
'aud': self._registration.get_auth_token_url(),
|
||||
'iat': now,
|
||||
'exp': now + 300,
|
||||
'jti': str(time.time()),
|
||||
}
|
||||
|
||||
client_assertion = jwt.encode(payload, private_key, algorithm='RS256', headers={'kid': key_obj.private_key_jwk['kid']})
|
||||
|
||||
token_url = self._registration.get_auth_token_url()
|
||||
data = {
|
||||
'grant_type': 'client_credentials',
|
||||
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
'client_assertion': client_assertion,
|
||||
'scope': ' '.join(scopes),
|
||||
}
|
||||
|
||||
response = requests.post(token_url, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data['access_token']
|
||||
expires_in = token_data.get('expires_in', 3600)
|
||||
self._access_token_expires = time.time() + expires_in - 10
|
||||
|
||||
return self._access_token
|
||||
|
||||
def make_service_request(self, scopes, url, is_post=False, data=None, **kwargs):
|
||||
access_token = self.get_access_token(scopes)
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
}
|
||||
|
||||
if 'accept' in kwargs:
|
||||
headers['Accept'] = kwargs['accept']
|
||||
|
||||
if is_post:
|
||||
response = requests.post(url, json=data, headers=headers, timeout=10)
|
||||
else:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
response_body = response.json()
|
||||
except ValueError:
|
||||
raise ValueError(f"NRPS endpoint returned non-JSON response. Status: {response.status_code}, Content-Type: {response.headers.get('Content-Type')}, Body: {response.text[:500]}")
|
||||
|
||||
next_page_url = None
|
||||
link_header = response.headers.get('Link')
|
||||
if link_header:
|
||||
for link in link_header.split(','):
|
||||
if 'rel="next"' in link:
|
||||
next_page_url = link.split(';')[0].strip('<> ')
|
||||
|
||||
return {
|
||||
'body': response_body,
|
||||
'status_code': response.status_code,
|
||||
'headers': dict(response.headers),
|
||||
'next_page_url': next_page_url,
|
||||
}
|
||||
|
||||
|
||||
class DjangoToolConfig(ToolConfAbstract):
|
||||
"""Tool configuration from Django models"""
|
||||
|
||||
def __init__(self, platforms_dict: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Initialize with platforms configuration
|
||||
|
||||
Args:
|
||||
platforms_dict: Dictionary mapping platform_id to config
|
||||
{
|
||||
'https://moodle.example.com': {
|
||||
'client_id': '...',
|
||||
'auth_login_url': '...',
|
||||
'auth_token_url': '...',
|
||||
'key_set_url': '...',
|
||||
'deployment_ids': [...],
|
||||
}
|
||||
}
|
||||
"""
|
||||
super().__init__()
|
||||
self._config = platforms_dict or {}
|
||||
|
||||
def check_iss_has_one_client(self, iss):
|
||||
"""Check if issuer has exactly one client"""
|
||||
return iss in self._config and len([self._config[iss]]) == 1
|
||||
|
||||
def check_iss_has_many_clients(self, iss):
|
||||
"""Check if issuer has multiple clients"""
|
||||
return False
|
||||
|
||||
def find_registration_by_issuer(self, iss, *args, **kwargs):
|
||||
"""Find registration by issuer"""
|
||||
if iss not in self._config:
|
||||
return None
|
||||
config = self._config[iss]
|
||||
|
||||
registration = Registration()
|
||||
registration.set_issuer(iss)
|
||||
registration.set_client_id(config.get('client_id'))
|
||||
registration.set_auth_login_url(config.get('auth_login_url'))
|
||||
registration.set_auth_token_url(config.get('auth_token_url'))
|
||||
if config.get('auth_audience'):
|
||||
registration.set_auth_audience(config.get('auth_audience'))
|
||||
registration.set_key_set_url(config.get('key_set_url'))
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
jwk_obj = jwk.JWK(**key_obj.private_key_jwk)
|
||||
pem_bytes = jwk_obj.export_to_pem(private_key=True, password=None)
|
||||
|
||||
registration._tool_private_key = pem_bytes.decode('utf-8')
|
||||
registration._tool_private_key_kid = key_obj.private_key_jwk['kid']
|
||||
|
||||
return registration
|
||||
|
||||
def find_registration_by_params(self, iss, client_id, *args, **kwargs):
|
||||
"""Find registration by issuer and client ID"""
|
||||
if iss not in self._config:
|
||||
return None
|
||||
|
||||
config = self._config[iss]
|
||||
if config.get('client_id') != client_id:
|
||||
return None
|
||||
|
||||
registration = Registration()
|
||||
registration.set_issuer(iss)
|
||||
registration.set_client_id(config.get('client_id'))
|
||||
registration.set_auth_login_url(config.get('auth_login_url'))
|
||||
registration.set_auth_token_url(config.get('auth_token_url'))
|
||||
if config.get('auth_audience'):
|
||||
registration.set_auth_audience(config.get('auth_audience'))
|
||||
registration.set_key_set_url(config.get('key_set_url'))
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
jwk_obj = jwk.JWK(**key_obj.private_key_jwk)
|
||||
pem_bytes = jwk_obj.export_to_pem(private_key=True, password=None)
|
||||
|
||||
registration._tool_private_key = pem_bytes.decode('utf-8')
|
||||
registration._tool_private_key_kid = key_obj.private_key_jwk['kid']
|
||||
|
||||
return registration
|
||||
|
||||
def find_deployment(self, iss, deployment_id):
|
||||
"""Find deployment by issuer and deployment ID"""
|
||||
if iss not in self._config:
|
||||
return None
|
||||
|
||||
config_dict = self._config[iss]
|
||||
deployment_ids = config_dict.get('deployment_ids', [])
|
||||
if deployment_id not in deployment_ids:
|
||||
return None
|
||||
|
||||
return self.find_registration_by_issuer(iss)
|
||||
|
||||
def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs):
|
||||
"""Find deployment by parameters"""
|
||||
if iss not in self._config:
|
||||
return None
|
||||
|
||||
config_dict = self._config[iss]
|
||||
if config_dict.get('client_id') != client_id:
|
||||
return None
|
||||
|
||||
deployment_ids = config_dict.get('deployment_ids', [])
|
||||
if deployment_id not in deployment_ids:
|
||||
return None
|
||||
|
||||
return self.find_registration_by_params(iss, client_id)
|
||||
|
||||
def get_jwks(self, iss, client_id=None):
|
||||
"""Get JWKS from configuration - returns None to fetch from URL"""
|
||||
return None
|
||||
|
||||
def get_iss(self):
|
||||
"""Get all issuers"""
|
||||
return list(self._config.keys())
|
||||
|
||||
def get_jwk(self, iss=None, client_id=None):
|
||||
"""
|
||||
Get private key for signing Deep Linking responses
|
||||
|
||||
PyLTI1p3 calls this to get the tool's private key for signing
|
||||
Returns a cryptography RSA key object that PyJWT can use directly
|
||||
"""
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
jwk_obj = jwk.JWK(**key_obj.private_key_jwk)
|
||||
|
||||
pem_bytes = jwk_obj.export_to_pem(private_key=True, password=None)
|
||||
|
||||
private_key = serialization.load_pem_private_key(pem_bytes, password=None, backend=default_backend())
|
||||
|
||||
return private_key
|
||||
|
||||
def get_kid(self, iss=None, client_id=None):
|
||||
"""
|
||||
Get key ID for JWT header
|
||||
|
||||
PyLTI1p3 calls this to get the kid to include in JWT headers
|
||||
"""
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
return key_obj.private_key_jwk.get('kid')
|
||||
|
||||
@classmethod
|
||||
def from_platform(cls, platform):
|
||||
"""Create ToolConfig from LTIPlatform model instance"""
|
||||
if isinstance(platform, LTIPlatform):
|
||||
config = {platform.platform_id: platform.get_lti_config()}
|
||||
return cls(config)
|
||||
|
||||
raise ValueError("Must provide LTIPlatform instance")
|
||||
|
||||
@classmethod
|
||||
def from_all_platforms(cls):
|
||||
"""Create ToolConfig with all platforms"""
|
||||
platforms = LTIPlatform.objects.filter()
|
||||
config = {}
|
||||
|
||||
for platform in platforms:
|
||||
config[platform.platform_id] = platform.get_lti_config()
|
||||
|
||||
return cls(config)
|
||||
239
lti/admin.py
Normal file
239
lti/admin.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""
|
||||
Django Admin for LTI models
|
||||
"""
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import (
|
||||
LTILaunchLog,
|
||||
LTIPlatform,
|
||||
LTIResourceLink,
|
||||
LTIRoleMapping,
|
||||
LTIToolKeys,
|
||||
LTIUserMapping,
|
||||
)
|
||||
from .services import LTINRPSClient
|
||||
|
||||
|
||||
@admin.register(LTIPlatform)
|
||||
class LTIPlatformAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Platforms (Moodle instances)"""
|
||||
|
||||
list_display = ['name', 'platform_id', 'client_id', 'nrps_enabled', 'deep_linking_enabled', 'created_at']
|
||||
list_filter = ['enable_nrps', 'enable_deep_linking', 'created_at']
|
||||
search_fields = ['name', 'platform_id', 'client_id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {'fields': ('name', 'platform_id', 'client_id')}),
|
||||
('OIDC Endpoints', {'fields': ('auth_login_url', 'auth_token_url', 'auth_audience')}),
|
||||
('JWK Configuration', {'fields': ('key_set_url',), 'classes': ('collapse',)}),
|
||||
('Deployment & Features', {'fields': ('deployment_ids', 'enable_nrps', 'enable_deep_linking')}),
|
||||
('Auto-Provisioning Settings', {'fields': ('remove_from_groups_on_unenroll',)}),
|
||||
('Timestamps', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}),
|
||||
)
|
||||
|
||||
def nrps_enabled(self, obj):
|
||||
return '✓' if obj.enable_nrps else '✗'
|
||||
|
||||
nrps_enabled.short_description = 'NRPS'
|
||||
|
||||
def deep_linking_enabled(self, obj):
|
||||
return '✓' if obj.enable_deep_linking else '✗'
|
||||
|
||||
deep_linking_enabled.short_description = 'Deep Link'
|
||||
|
||||
|
||||
@admin.register(LTIResourceLink)
|
||||
class LTIResourceLinkAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Resource Links"""
|
||||
|
||||
list_display = ['context_title', 'platform', 'category_link', 'rbac_group_link']
|
||||
list_filter = ['platform']
|
||||
search_fields = ['context_id', 'context_title', 'resource_link_id']
|
||||
actions = ['sync_course_members']
|
||||
|
||||
fieldsets = (
|
||||
('Platform', {'fields': ('platform',)}),
|
||||
('Context (Course)', {'fields': ('context_id', 'context_title', 'context_label')}),
|
||||
('Resource Link', {'fields': ('resource_link_id', 'resource_link_title')}),
|
||||
('MediaCMS Mappings', {'fields': ('category', 'rbac_group')}),
|
||||
)
|
||||
|
||||
def category_link(self, obj):
|
||||
if obj.category:
|
||||
return format_html('<a href="/admin/files/category/{}/change/">{}</a>', obj.category.id, obj.category.title)
|
||||
return '-'
|
||||
|
||||
category_link.short_description = 'Category'
|
||||
|
||||
def rbac_group_link(self, obj):
|
||||
if obj.rbac_group:
|
||||
return format_html('<a href="/admin/rbac/rbacgroup/{}/change/">{}</a>', obj.rbac_group.id, obj.rbac_group.name)
|
||||
return '-'
|
||||
|
||||
rbac_group_link.short_description = 'RBAC Group'
|
||||
|
||||
def sync_course_members(self, request, queryset):
|
||||
"""Sync course members from LMS using NRPS"""
|
||||
synced_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for resource_link in queryset:
|
||||
try:
|
||||
# Check if NRPS is enabled
|
||||
if not resource_link.platform.enable_nrps:
|
||||
messages.warning(request, f'NRPS is disabled for platform: {resource_link.platform.name}')
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Check if RBAC group exists
|
||||
if not resource_link.rbac_group:
|
||||
messages.warning(request, f'No RBAC group for: {resource_link.context_title}')
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Get last successful launch for NRPS endpoint
|
||||
last_launch = LTILaunchLog.objects.filter(platform=resource_link.platform, resource_link=resource_link, success=True).order_by('-created_at').first()
|
||||
|
||||
if not last_launch:
|
||||
messages.warning(request, f'No launch data for: {resource_link.context_title}')
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Perform NRPS sync
|
||||
nrps_client = LTINRPSClient(resource_link.platform, last_launch.claims)
|
||||
result = nrps_client.sync_members_to_rbac_group(resource_link.rbac_group)
|
||||
synced_count += result.get('synced', 0)
|
||||
messages.success(request, f'Synced {result.get("synced", 0)} members for: {resource_link.context_title}')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Error syncing {resource_link.context_title}: {str(e)}')
|
||||
failed_count += 1
|
||||
|
||||
# Summary message
|
||||
if synced_count > 0:
|
||||
self.message_user(request, f'Successfully synced members from {queryset.count() - failed_count} course(s). Total members: {synced_count}', messages.SUCCESS)
|
||||
if failed_count > 0:
|
||||
self.message_user(request, f'{failed_count} course(s) failed to sync', messages.WARNING)
|
||||
|
||||
sync_course_members.short_description = 'Sync course members from LMS (NRPS)'
|
||||
|
||||
|
||||
@admin.register(LTIUserMapping)
|
||||
class LTIUserMappingAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI User Mappings"""
|
||||
|
||||
list_display = ['user_link', 'lti_user_id', 'platform', 'user_email', 'last_login']
|
||||
list_filter = ['platform', 'created_at', 'last_login']
|
||||
search_fields = ['lti_user_id', 'user__username', 'user__email']
|
||||
readonly_fields = ['created_at', 'last_login']
|
||||
|
||||
fieldsets = (
|
||||
('Mapping', {'fields': ('platform', 'lti_user_id', 'user')}),
|
||||
('Timestamps', {'fields': ('created_at', 'last_login')}),
|
||||
)
|
||||
|
||||
def user_link(self, obj):
|
||||
return format_html('<a href="/admin/users/user/{}/change/">{}</a>', obj.user.id, obj.user.username)
|
||||
|
||||
user_link.short_description = 'MediaCMS User'
|
||||
|
||||
def user_email(self, obj):
|
||||
return obj.user.email
|
||||
|
||||
user_email.short_description = 'User Email'
|
||||
|
||||
|
||||
@admin.register(LTIRoleMapping)
|
||||
class LTIRoleMappingAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Role Mappings"""
|
||||
|
||||
list_display = ['lti_role', 'platform', 'global_role', 'group_role']
|
||||
list_filter = ['platform', 'global_role', 'group_role']
|
||||
search_fields = ['lti_role']
|
||||
|
||||
fieldsets = (
|
||||
('LTI Role', {'fields': ('platform', 'lti_role')}),
|
||||
('MediaCMS Roles', {'fields': ('global_role', 'group_role'), 'description': 'Map this LTI role to MediaCMS global and group roles'}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(LTILaunchLog)
|
||||
class LTILaunchLogAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Launch Logs"""
|
||||
|
||||
list_display = ['created_at', 'platform', 'user_link', 'launch_type', 'success_badge']
|
||||
list_filter = ['success', 'launch_type', 'platform', 'created_at']
|
||||
search_fields = ['user__username', 'error_message']
|
||||
readonly_fields = ['created_at', 'claims']
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
fieldsets = (
|
||||
('Launch Info', {'fields': ('platform', 'user', 'resource_link', 'launch_type', 'success', 'created_at')}),
|
||||
('Error Details', {'fields': ('error_message',), 'classes': ('collapse',)}),
|
||||
('Claims Data', {'fields': ('claims',), 'classes': ('collapse',)}),
|
||||
)
|
||||
|
||||
def success_badge(self, obj):
|
||||
if obj.success:
|
||||
return format_html('<span style="color: green;">✓ Success</span>')
|
||||
return format_html('<span style="color: red;">✗ Failed</span>')
|
||||
|
||||
success_badge.short_description = 'Status'
|
||||
|
||||
def user_link(self, obj):
|
||||
if obj.user:
|
||||
return format_html('<a href="/admin/users/user/{}/change/">{}</a>', obj.user.id, obj.user.username)
|
||||
return '-'
|
||||
|
||||
user_link.short_description = 'User'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of launch logs"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Make launch logs read-only"""
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(LTIToolKeys)
|
||||
class LTIToolKeysAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Tool RSA Keys"""
|
||||
|
||||
list_display = ['key_id', 'created_at', 'updated_at']
|
||||
readonly_fields = ['key_id', 'created_at', 'updated_at', 'public_key_display']
|
||||
|
||||
fieldsets = (
|
||||
('Key Information', {'fields': ('key_id', 'created_at', 'updated_at')}),
|
||||
('Public Key (for JWKS)', {'fields': ('public_key_display',)}),
|
||||
('Private Key (Keep Secure!)', {'fields': ('private_key_jwk',), 'classes': ('collapse',), 'description': '⚠️ This is your private signing key. Do not share it!'}),
|
||||
)
|
||||
|
||||
actions = ['regenerate_keys']
|
||||
|
||||
def public_key_display(self, obj):
|
||||
"""Display public key in readable format"""
|
||||
import json
|
||||
|
||||
return format_html('<pre>{}</pre>', json.dumps(obj.public_key_jwk, indent=2))
|
||||
|
||||
public_key_display.short_description = 'Public Key (JWK)'
|
||||
|
||||
def regenerate_keys(self, request, queryset):
|
||||
"""Regenerate keys for selected instances"""
|
||||
for key_obj in queryset:
|
||||
key_obj.generate_keys()
|
||||
self.message_user(request, f"Keys regenerated for {key_obj.key_id}", messages.SUCCESS)
|
||||
|
||||
regenerate_keys.short_description = 'Regenerate RSA keys'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one key pair - disable manual add if exists"""
|
||||
return not LTIToolKeys.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Prevent accidental deletion of keys"""
|
||||
return False
|
||||
16
lti/apps.py
Normal file
16
lti/apps.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from .keys import ensure_keys_exist
|
||||
|
||||
|
||||
class LtiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'lti'
|
||||
verbose_name = 'LTI 1.3 Integration'
|
||||
|
||||
def ready(self):
|
||||
"""Initialize LTI app - ensure keys exist"""
|
||||
try:
|
||||
ensure_keys_exist()
|
||||
except Exception:
|
||||
pass
|
||||
217
lti/deep_linking.py
Normal file
217
lti/deep_linking.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""
|
||||
LTI Deep Linking 2.0 for MediaCMS
|
||||
|
||||
Allows instructors to select media from MediaCMS library and embed in Moodle courses
|
||||
"""
|
||||
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from jwcrypto import jwk
|
||||
|
||||
from files.models import Media
|
||||
from files.views.media import MediaList
|
||||
|
||||
from .models import LTIPlatform, LTIToolKeys
|
||||
|
||||
|
||||
@method_decorator(login_required, name='dispatch')
|
||||
class SelectMediaView(View):
|
||||
"""
|
||||
UI for instructors to select media for deep linking
|
||||
|
||||
Flow: Instructor clicks "Add MediaCMS" in Moodle → Deep link launch →
|
||||
This view → Instructor selects media → Return to Moodle
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Display media selection interface"""
|
||||
|
||||
# Check if this is a TinyMCE request (no deep linking session required)
|
||||
is_tinymce = request.GET.get('mode') == 'tinymce'
|
||||
|
||||
if not is_tinymce:
|
||||
# Get deep link session data for regular deep linking flow
|
||||
deep_link_data = request.session.get('lti_deep_link')
|
||||
if not deep_link_data:
|
||||
return JsonResponse({'error': 'No deep linking session data found'}, status=400)
|
||||
|
||||
# Reuse MediaList logic to get media with proper permissions
|
||||
media_list_view = MediaList()
|
||||
|
||||
# Get base queryset with all permission/RBAC logic applied
|
||||
media_queryset = media_list_view._get_media_queryset(request)
|
||||
|
||||
# Apply filtering based on query params
|
||||
show_my_media_only = request.GET.get('my_media_only', 'false').lower() == 'true'
|
||||
if show_my_media_only:
|
||||
media_queryset = media_queryset.filter(user=request.user)
|
||||
|
||||
# Order by recent
|
||||
media_queryset = media_queryset.order_by('-add_date')
|
||||
|
||||
# TinyMCE mode: Use pagination
|
||||
if is_tinymce:
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
paginator = Paginator(media_queryset, 24) # 24 items per page
|
||||
page_number = request.GET.get('page', 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
context = {
|
||||
'media_list': page_obj,
|
||||
'page_obj': page_obj,
|
||||
}
|
||||
return render(request, 'lti/tinymce_select_media.html', context)
|
||||
|
||||
# Deep linking mode: Limit for performance
|
||||
media_list = media_queryset[:100]
|
||||
|
||||
context = {
|
||||
'media_list': media_list,
|
||||
'show_my_media_only': show_my_media_only,
|
||||
'deep_link_data': deep_link_data,
|
||||
}
|
||||
|
||||
return render(request, 'lti/select_media.html', context)
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def post(self, request):
|
||||
"""Return selected media as deep linking content items"""
|
||||
|
||||
deep_link_data = request.session.get('lti_deep_link')
|
||||
|
||||
if not deep_link_data:
|
||||
return JsonResponse({'error': 'Invalid session'}, status=400)
|
||||
|
||||
selected_ids = request.POST.getlist('media_ids[]')
|
||||
|
||||
if not selected_ids:
|
||||
return JsonResponse({'error': 'No media selected'}, status=400)
|
||||
|
||||
content_items = []
|
||||
|
||||
for media_id in selected_ids:
|
||||
try:
|
||||
media = Media.objects.get(id=media_id)
|
||||
|
||||
# Build launch URL (must be an LTI launch endpoint that handles POST with id_token)
|
||||
# The /lti/launch/ endpoint will use the custom parameter to redirect to the correct media
|
||||
launch_url = request.build_absolute_uri(reverse('lti:launch'))
|
||||
|
||||
content_item = {
|
||||
'type': 'ltiResourceLink',
|
||||
'title': media.title,
|
||||
'url': launch_url,
|
||||
'custom': {
|
||||
'media_friendly_token': media.friendly_token,
|
||||
},
|
||||
}
|
||||
|
||||
if media.thumbnail_url:
|
||||
thumbnail_url = media.thumbnail_url
|
||||
if not thumbnail_url.startswith('http'):
|
||||
thumbnail_url = request.build_absolute_uri(thumbnail_url)
|
||||
content_item['thumbnail'] = {'url': thumbnail_url, 'width': 344, 'height': 194}
|
||||
|
||||
content_item['iframe'] = {'width': 960, 'height': 540}
|
||||
|
||||
content_items.append(content_item)
|
||||
|
||||
except Media.DoesNotExist:
|
||||
continue
|
||||
|
||||
if not content_items:
|
||||
return JsonResponse({'error': 'No valid media found'}, status=400)
|
||||
|
||||
# Full implementation would use PyLTI1p3's DeepLink response builder
|
||||
jwt_response = self.create_deep_link_jwt(deep_link_data, content_items, request)
|
||||
|
||||
context = {
|
||||
'return_url': deep_link_data['deep_link_return_url'],
|
||||
'jwt': jwt_response,
|
||||
}
|
||||
|
||||
return render(request, 'lti/deep_link_return.html', context)
|
||||
|
||||
def create_deep_link_jwt(self, deep_link_data, content_items, request):
|
||||
"""
|
||||
Create JWT response for deep linking - manual implementation
|
||||
"""
|
||||
try:
|
||||
platform_id = deep_link_data['platform_id']
|
||||
platform = LTIPlatform.objects.get(id=platform_id)
|
||||
deployment_id = deep_link_data['deployment_id']
|
||||
message_launch_data = deep_link_data['message_launch_data']
|
||||
|
||||
deep_linking_settings = message_launch_data.get('https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings', {})
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
jwk_obj = jwk.JWK(**key_obj.private_key_jwk)
|
||||
pem_bytes = jwk_obj.export_to_pem(private_key=True, password=None)
|
||||
private_key = serialization.load_pem_private_key(pem_bytes, password=None, backend=default_backend())
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
lti_content_items = []
|
||||
for item in content_items:
|
||||
lti_item = {
|
||||
'type': item['type'],
|
||||
'title': item['title'],
|
||||
'url': item['url'],
|
||||
}
|
||||
|
||||
if item.get('custom'):
|
||||
lti_item['custom'] = item['custom']
|
||||
|
||||
if item.get('thumbnail'):
|
||||
lti_item['thumbnail'] = item['thumbnail']
|
||||
|
||||
if item.get('iframe'):
|
||||
lti_item['iframe'] = item['iframe']
|
||||
|
||||
lti_content_items.append(lti_item)
|
||||
|
||||
tool_issuer = platform.client_id
|
||||
|
||||
audience = platform.platform_id
|
||||
|
||||
sub = message_launch_data.get('sub')
|
||||
|
||||
payload = {
|
||||
'iss': tool_issuer,
|
||||
'aud': audience,
|
||||
'exp': now + 3600,
|
||||
'iat': now,
|
||||
'nonce': str(uuid.uuid4()),
|
||||
'https://purl.imsglobal.org/spec/lti/claim/message_type': 'LtiDeepLinkingResponse',
|
||||
'https://purl.imsglobal.org/spec/lti/claim/version': '1.3.0',
|
||||
'https://purl.imsglobal.org/spec/lti/claim/deployment_id': deployment_id,
|
||||
'https://purl.imsglobal.org/spec/lti-dl/claim/content_items': lti_content_items,
|
||||
}
|
||||
|
||||
if sub:
|
||||
payload['sub'] = sub
|
||||
|
||||
if 'data' in deep_linking_settings:
|
||||
payload['https://purl.imsglobal.org/spec/lti-dl/claim/data'] = deep_linking_settings['data']
|
||||
|
||||
kid = key_obj.private_key_jwk['kid']
|
||||
response_jwt = jwt.encode(payload, private_key, algorithm='RS256', headers={'kid': kid})
|
||||
|
||||
return response_jwt
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise ValueError(f"Failed to create Deep Linking JWT: {str(e)}")
|
||||
102
lti/filter_embed.py
Normal file
102
lti/filter_embed.py
Normal file
@ -0,0 +1,102 @@
|
||||
# TODO JUST AN F EXAMPLEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
|
||||
"""
|
||||
Filter Embed Token API for MediaCMS
|
||||
|
||||
Provides signed embed tokens for Moodle filter-based embeds
|
||||
without requiring full LTI launch flow
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from files.models import Media
|
||||
|
||||
from .models import LTIPlatform
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class FilterEmbedTokenView(View):
|
||||
"""
|
||||
Generate a signed embed token for Moodle filter embeds
|
||||
|
||||
This bypasses the full LTI launch flow which doesn't work for filters
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
"""Handle token request from Moodle filter"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
media_token = data.get('media_token')
|
||||
user_id = data.get('user_id') # noqa: F841
|
||||
# user_email and user_name reserved for future RBAC implementation
|
||||
client_id = data.get('client_id')
|
||||
timestamp = data.get('timestamp')
|
||||
signature = data.get('signature')
|
||||
|
||||
if not all([media_token, user_id, client_id, signature, timestamp]):
|
||||
return JsonResponse({'error': 'Missing required parameters'}, status=400)
|
||||
|
||||
# Check timestamp is recent (within 5 minutes)
|
||||
if abs(time.time() - timestamp) > 300:
|
||||
return JsonResponse({'error': 'Request expired'}, status=400)
|
||||
|
||||
# Verify platform exists
|
||||
try:
|
||||
LTIPlatform.objects.get(client_id=client_id)
|
||||
except LTIPlatform.DoesNotExist:
|
||||
return JsonResponse({'error': 'Invalid client'}, status=403)
|
||||
|
||||
# Get shared secret from platform or settings
|
||||
# Option 1: Store it in LTIPlatform model (add a field)
|
||||
# Option 2: Use Django settings
|
||||
from django.conf import settings
|
||||
|
||||
shared_secret = getattr(settings, 'FILTER_EMBED_SHARED_SECRET', None)
|
||||
|
||||
if not shared_secret:
|
||||
return JsonResponse({'error': 'Server not configured for filter embeds'}, status=500)
|
||||
|
||||
# Verify signature
|
||||
payload_copy = data.copy()
|
||||
del payload_copy['signature']
|
||||
expected_sig = hmac.new(shared_secret.encode(), json.dumps(payload_copy).encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(signature, expected_sig):
|
||||
return JsonResponse({'error': 'Invalid signature'}, status=403)
|
||||
|
||||
# Get media
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=media_token)
|
||||
except Media.DoesNotExist:
|
||||
return JsonResponse({'error': 'Media not found'}, status=404)
|
||||
|
||||
# Check if media is public/unlisted (allow) or private (would need RBAC check)
|
||||
# For now, allow public and unlisted
|
||||
if media.state not in ['public', 'unlisted']:
|
||||
# TODO: Implement RBAC check here based on cmid/course context
|
||||
return JsonResponse({'error': 'Access denied'}, status=403)
|
||||
|
||||
# Generate embed URL (simple embed, no auth needed for public/unlisted)
|
||||
embed_url = request.build_absolute_uri(reverse('get_embed') + f'?m={media_token}')
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'embed_url': embed_url,
|
||||
'media_token': media_token,
|
||||
'title': media.title,
|
||||
}
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
347
lti/handlers.py
Normal file
347
lti/handlers.py
Normal file
@ -0,0 +1,347 @@
|
||||
"""
|
||||
LTI Launch Handlers for User and Context Provisioning
|
||||
|
||||
Provides functions to:
|
||||
- Create/update MediaCMS users from LTI launches
|
||||
- Create/update categories and RBAC groups for courses
|
||||
- Apply role mappings from LTI to MediaCMS
|
||||
- Create and manage LTI sessions
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login
|
||||
from django.utils import timezone
|
||||
|
||||
from files.models import Category
|
||||
from rbac.models import RBACGroup, RBACMembership
|
||||
from users.models import User
|
||||
|
||||
from .models import LTIResourceLink, LTIRoleMapping, LTIUserMapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_LTI_ROLE_MAPPINGS = {
|
||||
'Instructor': {'global_role': '', 'group_role': 'manager'},
|
||||
'TeachingAssistant': {'global_role': '', 'group_role': 'contributor'},
|
||||
'Learner': {'global_role': '', 'group_role': 'member'},
|
||||
'Student': {'global_role': '', 'group_role': 'member'},
|
||||
'Administrator': {'global_role': '', 'group_role': 'manager'},
|
||||
'Faculty': {'global_role': '', 'group_role': 'manager'},
|
||||
}
|
||||
|
||||
|
||||
def provision_lti_user(platform, claims):
|
||||
"""
|
||||
Provision MediaCMS user from LTI launch claims
|
||||
|
||||
Args:
|
||||
platform: LTIPlatform instance
|
||||
claims: Dict of LTI launch claims
|
||||
|
||||
Returns:
|
||||
User instance
|
||||
|
||||
Pattern: Similar to saml_auth.adapter.perform_user_actions()
|
||||
"""
|
||||
lti_user_id = claims.get('sub')
|
||||
if not lti_user_id:
|
||||
raise ValueError("Missing 'sub' claim in LTI launch")
|
||||
|
||||
email = claims.get('email', '')
|
||||
given_name = claims.get('given_name', '')
|
||||
family_name = claims.get('family_name', '')
|
||||
name = claims.get('name', f"{given_name} {family_name}").strip()
|
||||
|
||||
mapping = LTIUserMapping.objects.filter(platform=platform, lti_user_id=lti_user_id).select_related('user').first()
|
||||
|
||||
if mapping:
|
||||
user = mapping.user
|
||||
update_fields = []
|
||||
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
update_fields.append('email')
|
||||
|
||||
if given_name and user.first_name != given_name:
|
||||
user.first_name = given_name
|
||||
update_fields.append('first_name')
|
||||
|
||||
if family_name and user.last_name != family_name:
|
||||
user.last_name = family_name
|
||||
update_fields.append('last_name')
|
||||
|
||||
if name and user.name != name:
|
||||
user.name = name
|
||||
update_fields.append('name')
|
||||
|
||||
if update_fields:
|
||||
user.save(update_fields=update_fields)
|
||||
|
||||
else:
|
||||
username = generate_username_from_lti(lti_user_id, email, given_name, family_name)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
username = f"{username}_{hashlib.md5(lti_user_id.encode()).hexdigest()[:6]}"
|
||||
|
||||
user = User.objects.create_user(username=username, email=email or '', first_name=given_name, last_name=family_name, name=name or username, is_active=True)
|
||||
|
||||
if email:
|
||||
try:
|
||||
EmailAddress.objects.create(user=user, email=email, verified=True, primary=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
LTIUserMapping.objects.create(platform=platform, lti_user_id=lti_user_id, user=user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def generate_username_from_lti(lti_user_id, email, given_name, family_name):
|
||||
"""Generate a username from LTI user info"""
|
||||
|
||||
if email and '@' in email:
|
||||
username = email.split('@')[0]
|
||||
username = ''.join(c if c.isalnum() or c in '_-' else '_' for c in username)
|
||||
if len(username) >= 4:
|
||||
return username[:30] # Max 30 chars
|
||||
|
||||
if given_name and family_name:
|
||||
username = f"{given_name}.{family_name}".lower()
|
||||
username = ''.join(c if c.isalnum() or c in '_-.' else '_' for c in username)
|
||||
if len(username) >= 4:
|
||||
return username[:30]
|
||||
|
||||
user_hash = hashlib.md5(lti_user_id.encode()).hexdigest()[:10]
|
||||
return f"lti_user_{user_hash}"
|
||||
|
||||
|
||||
def provision_lti_context(platform, claims, resource_link_id):
|
||||
"""
|
||||
Provision MediaCMS category and RBAC group for LTI context (course)
|
||||
|
||||
Args:
|
||||
platform: LTIPlatform instance
|
||||
claims: Dict of LTI launch claims
|
||||
resource_link_id: Resource link ID
|
||||
|
||||
Returns:
|
||||
Tuple of (category, rbac_group, resource_link)
|
||||
|
||||
Pattern: Integrates with existing Category and RBACGroup models
|
||||
"""
|
||||
context = claims.get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
||||
context_id = context.get('id')
|
||||
if not context_id:
|
||||
raise ValueError("Missing context ID in LTI launch")
|
||||
|
||||
context_title = context.get('title', '')
|
||||
context_label = context.get('label', '')
|
||||
|
||||
resource_link = LTIResourceLink.objects.filter(
|
||||
platform=platform,
|
||||
context_id=context_id,
|
||||
).first()
|
||||
|
||||
if resource_link:
|
||||
category = resource_link.category
|
||||
rbac_group = resource_link.rbac_group
|
||||
|
||||
update_fields = []
|
||||
if context_title and resource_link.context_title != context_title:
|
||||
resource_link.context_title = context_title
|
||||
update_fields.append('context_title')
|
||||
if context_label and resource_link.context_label != context_label:
|
||||
resource_link.context_label = context_label
|
||||
update_fields.append('context_label')
|
||||
# TODO / TOCHECK: consider whether we need to update this or not
|
||||
if resource_link.resource_link_id != resource_link_id:
|
||||
resource_link.resource_link_id = resource_link_id
|
||||
update_fields.append('resource_link_id')
|
||||
|
||||
if update_fields:
|
||||
resource_link.save(update_fields=update_fields)
|
||||
|
||||
if context_title and category and category.title != context_title:
|
||||
category.title = context_title
|
||||
category.save(update_fields=['title'])
|
||||
|
||||
else:
|
||||
category = Category.objects.create(
|
||||
title=context_title or context_label or f"Course {context_id}",
|
||||
description=f"Auto-created from {platform.name}: {context_title}",
|
||||
is_global=False,
|
||||
is_rbac_category=True,
|
||||
is_lms_course=True,
|
||||
lti_platform=platform,
|
||||
lti_context_id=context_id,
|
||||
)
|
||||
|
||||
rbac_group = RBACGroup.objects.create(
|
||||
name=f"{context_title or context_label} ({platform.name})",
|
||||
description=f"LTI course group from {platform.name}",
|
||||
)
|
||||
|
||||
rbac_group.categories.add(category)
|
||||
|
||||
resource_link = LTIResourceLink.objects.create(
|
||||
platform=platform,
|
||||
context_id=context_id,
|
||||
resource_link_id=resource_link_id,
|
||||
context_title=context_title,
|
||||
context_label=context_label,
|
||||
category=category,
|
||||
rbac_group=rbac_group,
|
||||
)
|
||||
|
||||
return category, rbac_group, resource_link
|
||||
|
||||
|
||||
def apply_lti_roles(user, platform, lti_roles, rbac_group):
|
||||
"""
|
||||
Apply role mappings from LTI to MediaCMS
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
platform: LTIPlatform instance
|
||||
lti_roles: List of LTI role URIs
|
||||
rbac_group: RBACGroup instance for course
|
||||
|
||||
Pattern: Similar to saml_auth.adapter.handle_role_mapping()
|
||||
"""
|
||||
if not lti_roles:
|
||||
lti_roles = []
|
||||
|
||||
short_roles = []
|
||||
for role in lti_roles:
|
||||
if '#' in role:
|
||||
short_roles.append(role.split('#')[-1])
|
||||
elif '/' in role:
|
||||
short_roles.append(role.split('/')[-1])
|
||||
else:
|
||||
short_roles.append(role)
|
||||
|
||||
custom_mappings = {}
|
||||
for mapping in LTIRoleMapping.objects.filter(platform=platform):
|
||||
custom_mappings[mapping.lti_role] = {
|
||||
'global_role': mapping.global_role,
|
||||
'group_role': mapping.group_role,
|
||||
}
|
||||
|
||||
all_mappings = {**DEFAULT_LTI_ROLE_MAPPINGS, **custom_mappings}
|
||||
|
||||
global_role = 'user'
|
||||
for role in short_roles:
|
||||
if role in all_mappings:
|
||||
role_global = all_mappings[role].get('global_role')
|
||||
if role_global:
|
||||
global_role = get_higher_privilege_global(global_role, role_global)
|
||||
|
||||
user.set_role_from_mapping(global_role)
|
||||
|
||||
group_role = 'member'
|
||||
for role in short_roles:
|
||||
if role in all_mappings:
|
||||
role_group = all_mappings[role].get('group_role')
|
||||
if role_group:
|
||||
group_role = get_higher_privilege_group(group_role, role_group)
|
||||
|
||||
memberships = RBACMembership.objects.filter(user=user, rbac_group=rbac_group)
|
||||
|
||||
if memberships.exists():
|
||||
if not memberships.filter(role=group_role).exists():
|
||||
first_membership = memberships.first()
|
||||
first_membership.role = group_role
|
||||
try:
|
||||
first_membership.save()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
RBACMembership.objects.create(user=user, rbac_group=rbac_group, role=group_role)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return global_role, group_role
|
||||
|
||||
|
||||
def get_higher_privilege_global(role1, role2):
|
||||
"""Return the higher privilege global role"""
|
||||
privilege_order = ['user', 'advancedUser', 'editor', 'manager', 'admin']
|
||||
try:
|
||||
index1 = privilege_order.index(role1)
|
||||
index2 = privilege_order.index(role2)
|
||||
return privilege_order[max(index1, index2)]
|
||||
except ValueError:
|
||||
return role2 # Default to role2 if role1 is unknown
|
||||
|
||||
|
||||
def get_higher_privilege_group(role1, role2):
|
||||
"""Return the higher privilege group role"""
|
||||
privilege_order = ['member', 'contributor', 'manager']
|
||||
try:
|
||||
index1 = privilege_order.index(role1)
|
||||
index2 = privilege_order.index(role2)
|
||||
return privilege_order[max(index1, index2)]
|
||||
except ValueError:
|
||||
return role2 # Default to role2 if role1 is unknown
|
||||
|
||||
|
||||
def create_lti_session(request, user, launch_data, platform):
|
||||
"""
|
||||
Create MediaCMS session from LTI launch
|
||||
|
||||
Args:
|
||||
request: Django request
|
||||
user: User instance
|
||||
launch_data: Dict of validated LTI launch data
|
||||
platform: LTIPlatform instance
|
||||
|
||||
Pattern: Uses Django's session framework
|
||||
"""
|
||||
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
|
||||
|
||||
context = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/context', {})
|
||||
resource_link = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
|
||||
roles = launch_data.get_launch_data().get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
|
||||
|
||||
request.session['lti_session'] = {
|
||||
'platform_id': platform.id,
|
||||
'platform_name': platform.name,
|
||||
'context_id': context.get('id'),
|
||||
'context_title': context.get('title'),
|
||||
'resource_link_id': resource_link.get('id'),
|
||||
'roles': roles,
|
||||
'launch_time': timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
timeout = getattr(settings, 'LTI_SESSION_TIMEOUT', 3600)
|
||||
request.session.set_expiry(timeout)
|
||||
|
||||
# CRITICAL: Explicitly save session before redirect (for cross-site contexts)
|
||||
request.session.modified = True
|
||||
request.session.save()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_lti_session(request):
|
||||
"""
|
||||
Validate that an LTI session exists and is valid
|
||||
|
||||
Returns:
|
||||
Dict of LTI session data or None
|
||||
"""
|
||||
|
||||
lti_session = request.session.get('lti_session')
|
||||
|
||||
if not lti_session:
|
||||
return None
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
return lti_session
|
||||
45
lti/keys.py
Normal file
45
lti/keys.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""
|
||||
LTI Key Management for MediaCMS
|
||||
|
||||
Manages RSA keys for signing Deep Linking responses (stored in database)
|
||||
"""
|
||||
|
||||
from jwcrypto import jwk
|
||||
|
||||
|
||||
def load_private_key():
|
||||
"""Load private key from database and convert to PEM format for PyJWT"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
|
||||
# Convert JWK dict to PEM string (PyJWT needs PEM format)
|
||||
jwk_obj = jwk.JWK(**key_obj.private_key_jwk)
|
||||
pem_bytes = jwk_obj.export_to_pem(private_key=True, password=None)
|
||||
|
||||
return pem_bytes.decode('utf-8')
|
||||
|
||||
|
||||
def load_public_key():
|
||||
"""Load public key from database"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
return key_obj.public_key_jwk
|
||||
|
||||
|
||||
def get_jwks():
|
||||
"""
|
||||
Get JWKS (JSON Web Key Set) for public keys
|
||||
|
||||
Returns public keys in JWKS format for the /lti/jwks/ endpoint
|
||||
"""
|
||||
public_key = load_public_key()
|
||||
return {'keys': [public_key]}
|
||||
|
||||
|
||||
def ensure_keys_exist():
|
||||
"""Ensure key pair exists in database, generate if not"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
LTIToolKeys.get_or_create_keys()
|
||||
190
lti/migrations/0001_initial.py
Normal file
190
lti/migrations/0001_initial.py
Normal file
@ -0,0 +1,190 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('files', '0015_category_is_lms_course_category_lti_context_id'),
|
||||
('rbac', '0003_alter_rbacgroup_members'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LTIToolKeys',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key_id', models.CharField(default='mediacms-lti-key', help_text='Key identifier', max_length=255, unique=True)),
|
||||
('private_key_jwk', models.JSONField(help_text='Private key in JWK format (for signing)')),
|
||||
('public_key_jwk', models.JSONField(help_text='Public key in JWK format (for JWKS endpoint)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Tool Keys',
|
||||
'verbose_name_plural': 'LTI Tool Keys',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LTIPlatform',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Platform name (e.g., 'Moodle Production')", max_length=255, unique=True)),
|
||||
('platform_id', models.URLField(help_text="Platform's issuer URL (iss claim, e.g., https://moodle.example.com)")),
|
||||
('client_id', models.CharField(help_text='Client ID provided by the platform', max_length=255)),
|
||||
('auth_login_url', models.URLField(help_text='OIDC authentication endpoint URL')),
|
||||
('auth_token_url', models.URLField(help_text='OAuth2 token endpoint URL')),
|
||||
('auth_audience', models.URLField(blank=True, help_text='OAuth2 audience (optional)', null=True)),
|
||||
('key_set_url', models.URLField(help_text="Platform's public JWK Set URL")),
|
||||
('deployment_ids', models.JSONField(default=list, help_text='List of deployment IDs for this platform')),
|
||||
('enable_nrps', models.BooleanField(default=True, help_text='Enable Names and Role Provisioning Service')),
|
||||
('enable_deep_linking', models.BooleanField(default=True, help_text='Enable Deep Linking 2.0')),
|
||||
('remove_from_groups_on_unenroll', models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Platform',
|
||||
'verbose_name_plural': 'LTI Platforms',
|
||||
'unique_together': {('platform_id', 'client_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LTIResourceLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('context_id', models.CharField(db_index=True, help_text='LTI context ID (typically course ID)', max_length=255)),
|
||||
('context_title', models.CharField(blank=True, help_text='Course title', max_length=255)),
|
||||
('context_label', models.CharField(blank=True, help_text='Course short name/code', max_length=100)),
|
||||
('resource_link_id', models.CharField(db_index=True, help_text='LTI resource link ID', max_length=255)),
|
||||
('resource_link_title', models.CharField(blank=True, help_text='Resource link title', max_length=255)),
|
||||
(
|
||||
'category',
|
||||
models.ForeignKey(
|
||||
blank=True, help_text='Mapped MediaCMS category', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='files.category'
|
||||
),
|
||||
),
|
||||
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resource_links', to='lti.ltiplatform')),
|
||||
(
|
||||
'rbac_group',
|
||||
models.ForeignKey(
|
||||
blank=True, help_text='RBAC group for course members', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lti_resource_links', to='rbac.rbacgroup'
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Resource Link',
|
||||
'verbose_name_plural': 'LTI Resource Links',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LTILaunchLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('launch_type', models.CharField(choices=[('resource_link', 'Resource Link Launch'), ('deep_linking', 'Deep Linking')], default='resource_link', max_length=50)),
|
||||
('success', models.BooleanField(db_index=True, default=True, help_text='Whether the launch was successful')),
|
||||
('error_message', models.TextField(blank=True, help_text='Error message if launch failed')),
|
||||
('claims', models.JSONField(help_text='Sanitized LTI claims from the launch')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='MediaCMS user (null if launch failed before user creation)',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='lti_launch_logs',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='launch_logs', to='lti.ltiplatform')),
|
||||
('resource_link', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='launch_logs', to='lti.ltiresourcelink')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Launch Log',
|
||||
'verbose_name_plural': 'LTI Launch Logs',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LTIRoleMapping',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('lti_role', models.CharField(help_text="LTI role URI or short name (e.g., 'Instructor', 'Learner')", max_length=255)),
|
||||
(
|
||||
'global_role',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')],
|
||||
help_text='MediaCMS global role to assign',
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'group_role',
|
||||
models.CharField(blank=True, choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], help_text='RBAC group role to assign', max_length=20),
|
||||
),
|
||||
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_mappings', to='lti.ltiplatform')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Role Mapping',
|
||||
'verbose_name_plural': 'LTI Role Mappings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LTIUserMapping',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('lti_user_id', models.CharField(db_index=True, help_text="LTI 'sub' claim (unique user identifier from platform)", max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_login', models.DateTimeField(auto_now=True)),
|
||||
('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_mappings', to='lti.ltiplatform')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lti_mappings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI User Mapping',
|
||||
'verbose_name_plural': 'LTI User Mappings',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ltiresourcelink',
|
||||
index=models.Index(fields=['platform', 'context_id'], name='lti_ltireso_platfor_4a3f27_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ltiresourcelink',
|
||||
index=models.Index(fields=['context_id'], name='lti_ltireso_context_c6f9e2_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='ltiresourcelink',
|
||||
unique_together={('platform', 'context_id', 'resource_link_id')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ltilaunchlog',
|
||||
index=models.Index(fields=['-created_at'], name='lti_ltilaun_created_94c574_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ltilaunchlog',
|
||||
index=models.Index(fields=['platform', 'user'], name='lti_ltilaun_platfor_5240bf_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='ltirolemapping',
|
||||
unique_together={('platform', 'lti_role')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ltiusermapping',
|
||||
index=models.Index(fields=['platform', 'lti_user_id'], name='lti_ltiuser_platfor_9c70bb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ltiusermapping',
|
||||
index=models.Index(fields=['user'], name='lti_ltiuser_user_id_b06d01_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='ltiusermapping',
|
||||
unique_together={('platform', 'lti_user_id')},
|
||||
),
|
||||
]
|
||||
0
lti/migrations/__init__.py
Normal file
0
lti/migrations/__init__.py
Normal file
218
lti/models.py
Executable file
218
lti/models.py
Executable file
@ -0,0 +1,218 @@
|
||||
import json
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from django.db import models
|
||||
from jwcrypto import jwk
|
||||
|
||||
|
||||
class LTIPlatform(models.Model):
|
||||
"""LTI 1.3 Platform (Moodle instance) configuration"""
|
||||
|
||||
name = models.CharField(max_length=255, unique=True, help_text="Platform name (e.g., 'Moodle Production')")
|
||||
platform_id = models.URLField(help_text="Platform's issuer URL (iss claim, e.g., https://moodle.example.com)")
|
||||
client_id = models.CharField(max_length=255, help_text="Client ID provided by the platform")
|
||||
|
||||
auth_login_url = models.URLField(help_text="OIDC authentication endpoint URL")
|
||||
auth_token_url = models.URLField(help_text="OAuth2 token endpoint URL")
|
||||
auth_audience = models.URLField(blank=True, null=True, help_text="OAuth2 audience (optional)")
|
||||
|
||||
key_set_url = models.URLField(help_text="Platform's public JWK Set URL")
|
||||
|
||||
deployment_ids = models.JSONField(default=list, help_text="List of deployment IDs for this platform")
|
||||
enable_nrps = models.BooleanField(default=True, help_text="Enable Names and Role Provisioning Service")
|
||||
enable_deep_linking = models.BooleanField(default=True, help_text="Enable Deep Linking 2.0")
|
||||
|
||||
remove_from_groups_on_unenroll = models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Platform'
|
||||
verbose_name_plural = 'LTI Platforms'
|
||||
unique_together = [['platform_id', 'client_id']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.platform_id})"
|
||||
|
||||
def get_lti_config(self):
|
||||
"""Return configuration dict for PyLTI1p3"""
|
||||
return {
|
||||
'platform_id': self.platform_id,
|
||||
'client_id': self.client_id,
|
||||
'auth_login_url': self.auth_login_url,
|
||||
'auth_token_url': self.auth_token_url,
|
||||
'auth_audience': self.auth_audience,
|
||||
'key_set_url': self.key_set_url,
|
||||
'deployment_ids': self.deployment_ids,
|
||||
}
|
||||
|
||||
|
||||
class LTIResourceLink(models.Model):
|
||||
"""Specific LTI resource link (e.g., MediaCMS in a Moodle course)"""
|
||||
|
||||
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='resource_links')
|
||||
|
||||
context_id = models.CharField(max_length=255, db_index=True, help_text="LTI context ID (typically course ID)")
|
||||
context_title = models.CharField(max_length=255, blank=True, help_text="Course title")
|
||||
context_label = models.CharField(max_length=100, blank=True, help_text="Course short name/code")
|
||||
|
||||
resource_link_id = models.CharField(max_length=255, db_index=True, help_text="LTI resource link ID")
|
||||
resource_link_title = models.CharField(max_length=255, blank=True, help_text="Resource link title")
|
||||
|
||||
category = models.ForeignKey('files.Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="Mapped MediaCMS category")
|
||||
rbac_group = models.ForeignKey('rbac.RBACGroup', on_delete=models.SET_NULL, null=True, blank=True, related_name='lti_resource_links', help_text="RBAC group for course members")
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Resource Link'
|
||||
verbose_name_plural = 'LTI Resource Links'
|
||||
unique_together = [['platform', 'context_id', 'resource_link_id']]
|
||||
indexes = [
|
||||
models.Index(fields=['platform', 'context_id']),
|
||||
models.Index(fields=['context_id']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.context_title or self.context_id} - {self.resource_link_title or self.resource_link_id}"
|
||||
|
||||
|
||||
class LTIUserMapping(models.Model):
|
||||
"""Maps LTI user identities (sub claim) to MediaCMS users"""
|
||||
|
||||
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='user_mappings')
|
||||
lti_user_id = models.CharField(max_length=255, db_index=True, help_text="LTI 'sub' claim (unique user identifier from platform)")
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='lti_mappings')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_login = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI User Mapping'
|
||||
verbose_name_plural = 'LTI User Mappings'
|
||||
unique_together = [['platform', 'lti_user_id']]
|
||||
indexes = [
|
||||
models.Index(fields=['platform', 'lti_user_id']),
|
||||
models.Index(fields=['user']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} ({self.platform.name})"
|
||||
|
||||
|
||||
class LTIRoleMapping(models.Model):
|
||||
"""Maps LTI institutional roles to MediaCMS roles"""
|
||||
|
||||
GLOBAL_ROLE_CHOICES = [('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')]
|
||||
|
||||
GROUP_ROLE_CHOICES = [('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')]
|
||||
|
||||
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='role_mappings')
|
||||
lti_role = models.CharField(max_length=255, help_text="LTI role URI or short name (e.g., 'Instructor', 'Learner')")
|
||||
|
||||
global_role = models.CharField(max_length=20, blank=True, choices=GLOBAL_ROLE_CHOICES, help_text="MediaCMS global role to assign")
|
||||
|
||||
group_role = models.CharField(max_length=20, blank=True, choices=GROUP_ROLE_CHOICES, help_text="RBAC group role to assign")
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Role Mapping'
|
||||
verbose_name_plural = 'LTI Role Mappings'
|
||||
unique_together = [['platform', 'lti_role']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.lti_role} → {self.global_role or 'none'}/{self.group_role or 'none'} ({self.platform.name})"
|
||||
|
||||
|
||||
class LTILaunchLog(models.Model):
|
||||
"""Audit log for LTI launches"""
|
||||
|
||||
LAUNCH_TYPE_CHOICES = [
|
||||
('resource_link', 'Resource Link Launch'),
|
||||
('deep_linking', 'Deep Linking'),
|
||||
]
|
||||
|
||||
platform = models.ForeignKey(LTIPlatform, on_delete=models.CASCADE, related_name='launch_logs')
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, null=True, blank=True, related_name='lti_launch_logs', help_text="MediaCMS user (null if launch failed before user creation)")
|
||||
resource_link = models.ForeignKey(LTIResourceLink, on_delete=models.SET_NULL, null=True, blank=True, related_name='launch_logs')
|
||||
|
||||
launch_type = models.CharField(max_length=50, choices=LAUNCH_TYPE_CHOICES, default='resource_link')
|
||||
|
||||
success = models.BooleanField(default=True, db_index=True, help_text="Whether the launch was successful")
|
||||
error_message = models.TextField(blank=True, help_text="Error message if launch failed")
|
||||
claims = models.JSONField(help_text="Sanitized LTI claims from the launch")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Launch Log'
|
||||
verbose_name_plural = 'LTI Launch Logs'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['-created_at']),
|
||||
models.Index(fields=['platform', 'user']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
status = "✓" if self.success else "✗"
|
||||
user_str = self.user.username if self.user else "Unknown"
|
||||
return f"{status} {user_str} @ {self.platform.name} ({self.created_at.strftime('%Y-%m-%d %H:%M')})"
|
||||
|
||||
|
||||
class LTIToolKeys(models.Model):
|
||||
"""
|
||||
Stores MediaCMS's RSA key pair for signing LTI responses (e.g., Deep Linking)
|
||||
|
||||
Only one instance should exist (singleton pattern)
|
||||
"""
|
||||
|
||||
key_id = models.CharField(max_length=255, unique=True, default='mediacms-lti-key', help_text='Key identifier')
|
||||
|
||||
private_key_jwk = models.JSONField(help_text='Private key in JWK format (for signing)')
|
||||
public_key_jwk = models.JSONField(help_text='Public key in JWK format (for JWKS endpoint)')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Tool Keys'
|
||||
verbose_name_plural = 'LTI Tool Keys'
|
||||
|
||||
def __str__(self):
|
||||
return f"LTI Keys ({self.key_id})"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_keys(cls):
|
||||
"""Get or create the default key pair"""
|
||||
key_obj, created = cls.objects.get_or_create(
|
||||
key_id='mediacms-lti-key',
|
||||
defaults={'private_key_jwk': {}, 'public_key_jwk': {}}, # Will be populated by save()
|
||||
)
|
||||
|
||||
if created or not key_obj.private_key_jwk or not key_obj.public_key_jwk:
|
||||
key_obj.generate_keys()
|
||||
|
||||
return key_obj
|
||||
|
||||
def generate_keys(self):
|
||||
"""Generate new RSA key pair"""
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend())
|
||||
public_key = private_key.public_key()
|
||||
private_pem = private_key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())
|
||||
public_pem = public_key.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
private_jwk = jwk.JWK.from_pem(private_pem)
|
||||
public_jwk = jwk.JWK.from_pem(public_pem)
|
||||
private_jwk_dict = json.loads(private_jwk.export())
|
||||
private_jwk_dict['kid'] = self.key_id
|
||||
private_jwk_dict['alg'] = 'RS256'
|
||||
private_jwk_dict['use'] = 'sig'
|
||||
public_jwk_dict = json.loads(public_jwk.export_public())
|
||||
public_jwk_dict['kid'] = self.key_id
|
||||
public_jwk_dict['alg'] = 'RS256'
|
||||
public_jwk_dict['use'] = 'sig'
|
||||
|
||||
self.private_key_jwk = private_jwk_dict
|
||||
self.public_key_jwk = public_jwk_dict
|
||||
self.save()
|
||||
|
||||
return private_jwk_dict, public_jwk_dict
|
||||
178
lti/services.py
Normal file
178
lti/services.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""
|
||||
LTI Names and Role Provisioning Service (NRPS) Client
|
||||
|
||||
Fetches course membership from Moodle via NRPS and syncs to MediaCMS RBAC groups
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django.utils import timezone
|
||||
from pylti1p3.names_roles import NamesRolesProvisioningService
|
||||
|
||||
from rbac.models import RBACMembership
|
||||
from users.models import User
|
||||
|
||||
from .adapters import DjangoServiceConnector, DjangoToolConfig
|
||||
from .handlers import apply_lti_roles, generate_username_from_lti
|
||||
from .models import LTIUserMapping
|
||||
|
||||
|
||||
class LTINRPSClient:
|
||||
"""Client for Names and Role Provisioning Service"""
|
||||
|
||||
def __init__(self, platform, launch_claims):
|
||||
"""
|
||||
Initialize NRPS client
|
||||
|
||||
Args:
|
||||
platform: LTIPlatform instance
|
||||
launch_claims: Dict of LTI launch claims containing NRPS endpoint
|
||||
"""
|
||||
self.platform = platform
|
||||
self.launch_claims = launch_claims
|
||||
|
||||
self.nrps_claim = launch_claims.get('https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice')
|
||||
|
||||
def can_sync(self):
|
||||
"""Check if NRPS sync is available"""
|
||||
if not self.platform.enable_nrps:
|
||||
return False
|
||||
|
||||
if not self.nrps_claim:
|
||||
return False
|
||||
|
||||
service_url = self.nrps_claim.get('context_memberships_url')
|
||||
if not service_url:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def fetch_members(self):
|
||||
if not self.can_sync():
|
||||
return []
|
||||
|
||||
try:
|
||||
tool_config = DjangoToolConfig.from_platform(self.platform)
|
||||
registration = tool_config.find_registration_by_issuer(self.platform.platform_id)
|
||||
|
||||
if not registration:
|
||||
return []
|
||||
|
||||
service_connector = DjangoServiceConnector(registration)
|
||||
nrps = NamesRolesProvisioningService(service_connector, self.nrps_claim)
|
||||
members = nrps.get_members()
|
||||
|
||||
return members
|
||||
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def sync_members_to_rbac_group(self, rbac_group):
|
||||
"""
|
||||
Sync NRPS members to MediaCMS RBAC group
|
||||
|
||||
Args:
|
||||
rbac_group: RBACGroup instance
|
||||
|
||||
Returns:
|
||||
Dict with sync results
|
||||
"""
|
||||
members = self.fetch_members()
|
||||
|
||||
if not members:
|
||||
return {'synced': 0, 'removed': 0, 'synced_at': timezone.now().isoformat()}
|
||||
|
||||
processed_users = set()
|
||||
synced_count = 0
|
||||
|
||||
for member in members:
|
||||
try:
|
||||
user = self._get_or_create_user_from_nrps(member)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
processed_users.add(user.id)
|
||||
|
||||
roles = member.get('roles', [])
|
||||
|
||||
apply_lti_roles(user, self.platform, roles, rbac_group)
|
||||
|
||||
synced_count += 1
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
removed_count = 0
|
||||
if self.platform.remove_from_groups_on_unenroll:
|
||||
removed = RBACMembership.objects.filter(rbac_group=rbac_group).exclude(user_id__in=processed_users)
|
||||
|
||||
removed_count = removed.count()
|
||||
removed.delete()
|
||||
|
||||
result = {'synced': synced_count, 'removed': removed_count, 'synced_at': timezone.now().isoformat()}
|
||||
|
||||
return result
|
||||
|
||||
def _get_or_create_user_from_nrps(self, member):
|
||||
"""
|
||||
Get or create MediaCMS user from NRPS member data
|
||||
|
||||
Args:
|
||||
member: Dict of member data from NRPS
|
||||
|
||||
Returns:
|
||||
User instance or None
|
||||
"""
|
||||
user_id = member.get('user_id')
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
name = member.get('name', '')
|
||||
email = member.get('email', '')
|
||||
given_name = member.get('given_name', '')
|
||||
family_name = member.get('family_name', '')
|
||||
|
||||
mapping = LTIUserMapping.objects.filter(platform=self.platform, lti_user_id=user_id).select_related('user').first()
|
||||
|
||||
if mapping:
|
||||
user = mapping.user
|
||||
update_fields = []
|
||||
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
update_fields.append('email')
|
||||
|
||||
if given_name and user.first_name != given_name:
|
||||
user.first_name = given_name
|
||||
update_fields.append('first_name')
|
||||
|
||||
if family_name and user.last_name != family_name:
|
||||
user.last_name = family_name
|
||||
update_fields.append('last_name')
|
||||
|
||||
if name and user.name != name:
|
||||
user.name = name
|
||||
update_fields.append('name')
|
||||
|
||||
if update_fields:
|
||||
user.save(update_fields=update_fields)
|
||||
|
||||
return user
|
||||
|
||||
username = generate_username_from_lti(user_id, email, given_name, family_name)
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
username = f"{username}_{hashlib.md5(user_id.encode()).hexdigest()[:6]}"
|
||||
|
||||
user = User.objects.create_user(username=username, email=email or '', first_name=given_name, last_name=family_name, name=name or username, is_active=True)
|
||||
|
||||
LTIUserMapping.objects.create(platform=self.platform, lti_user_id=user_id, user=user)
|
||||
|
||||
if email:
|
||||
try:
|
||||
EmailAddress.objects.create(user=user, email=email, verified=True, primary=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user
|
||||
28
lti/urls.py
Normal file
28
lti/urls.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""
|
||||
LTI 1.3 URL Configuration for MediaCMS
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import deep_linking, filter_embed, views
|
||||
|
||||
app_name = 'lti'
|
||||
|
||||
urlpatterns = [
|
||||
# LTI 1.3 Launch Flow
|
||||
path('oidc/login/', views.OIDCLoginView.as_view(), name='oidc_login'),
|
||||
path('launch/', views.LaunchView.as_view(), name='launch'),
|
||||
path('jwks/', views.JWKSView.as_view(), name='jwks'),
|
||||
path('public-key/', views.PublicKeyPEMView.as_view(), name='public_key_pem'),
|
||||
# Deep Linking
|
||||
path('select-media/', deep_linking.SelectMediaView.as_view(), name='select_media'),
|
||||
# LTI-authenticated pages
|
||||
path('my-media/', views.MyMediaLTIView.as_view(), name='my_media'),
|
||||
path('embed/<str:friendly_token>/', views.EmbedMediaLTIView.as_view(), name='embed_media'),
|
||||
# Manual sync
|
||||
path('sync/<int:platform_id>/<str:context_id>/', views.ManualSyncView.as_view(), name='manual_sync'),
|
||||
# TinyMCE integration (reuses select-media with mode=tinymce parameter)
|
||||
path('tinymce-embed/<str:friendly_token>/', views.TinyMCEGetEmbedView.as_view(), name='tinymce_embed'),
|
||||
# Filter embed token API
|
||||
path('api/v1/get-filter-embed-token/', filter_embed.FilterEmbedTokenView.as_view(), name='filter_embed_token'),
|
||||
]
|
||||
704
lti/views.py
Normal file
704
lti/views.py
Normal file
@ -0,0 +1,704 @@
|
||||
"""
|
||||
LTI 1.3 Views for MediaCMS
|
||||
|
||||
Implements the LTI 1.3 / LTI Advantage flow:
|
||||
- OIDC Login Initiation
|
||||
- LTI Launch (JWT validation and processing)
|
||||
- JWKS endpoint (public keys)
|
||||
- My Media view (iframe-compatible)
|
||||
- Embed Media view (LTI-authenticated)
|
||||
- Manual NRPS Sync
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from jwcrypto import jwk
|
||||
from pylti1p3.exception import LtiException
|
||||
from pylti1p3.message_launch import MessageLaunch
|
||||
from pylti1p3.oidc_login import OIDCLogin
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from files.models import Media
|
||||
from rbac.models import RBACMembership
|
||||
|
||||
from .adapters import DjangoRequest, DjangoSessionService, DjangoToolConfig
|
||||
from .handlers import (
|
||||
apply_lti_roles,
|
||||
create_lti_session,
|
||||
provision_lti_context,
|
||||
provision_lti_user,
|
||||
validate_lti_session,
|
||||
)
|
||||
from .keys import get_jwks
|
||||
from .models import LTILaunchLog, LTIPlatform, LTIResourceLink, LTIToolKeys
|
||||
from .services import LTINRPSClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
"""Get client IP address from request"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class OIDCLoginView(View):
|
||||
"""
|
||||
OIDC Login Initiation - Step 1 of LTI 1.3 launch
|
||||
|
||||
Flow: Moodle → This endpoint → Redirect to Moodle auth endpoint
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
return self.handle_oidc_login(request)
|
||||
|
||||
def post(self, request):
|
||||
return self.handle_oidc_login(request)
|
||||
|
||||
def handle_oidc_login(self, request):
|
||||
"""Handle OIDC login initiation"""
|
||||
try:
|
||||
target_link_uri = request.GET.get('target_link_uri') or request.POST.get('target_link_uri')
|
||||
iss = request.GET.get('iss') or request.POST.get('iss')
|
||||
client_id = request.GET.get('client_id') or request.POST.get('client_id')
|
||||
login_hint = request.GET.get('login_hint') or request.POST.get('login_hint')
|
||||
lti_message_hint = request.GET.get('lti_message_hint') or request.POST.get('lti_message_hint')
|
||||
cmid = request.GET.get('cmid') or request.POST.get('cmid')
|
||||
media_token = request.GET.get('media_token') or request.POST.get('media_token')
|
||||
|
||||
if not all([target_link_uri, iss, client_id]):
|
||||
return JsonResponse({'error': 'Missing required OIDC parameters'}, status=400)
|
||||
|
||||
try:
|
||||
platform = LTIPlatform.objects.get(platform_id=iss, client_id=client_id)
|
||||
except LTIPlatform.DoesNotExist:
|
||||
return JsonResponse({'error': 'Platform not found'}, status=404)
|
||||
|
||||
tool_config = DjangoToolConfig.from_platform(platform)
|
||||
|
||||
lti_request = DjangoRequest(request)
|
||||
|
||||
session_service = DjangoSessionService(request)
|
||||
cookie_service = DjangoSessionService(request) # Using same service for cookies
|
||||
|
||||
oidc_login = OIDCLogin(lti_request, tool_config, session_service=session_service, cookie_service=cookie_service)
|
||||
try:
|
||||
oidc_with_cookies = oidc_login.enable_check_cookies()
|
||||
redirect_url = oidc_with_cookies.redirect(target_link_uri)
|
||||
|
||||
if not redirect_url:
|
||||
# Generate base state UUID
|
||||
state_uuid = str(uuid.uuid4())
|
||||
nonce = str(uuid.uuid4())
|
||||
|
||||
# Encode lti_message_hint IN the state parameter for retry reliability
|
||||
# This survives session/cookie issues since it's passed through URLs
|
||||
state_data = {'uuid': state_uuid}
|
||||
if lti_message_hint:
|
||||
state_data['hint'] = lti_message_hint
|
||||
if media_token:
|
||||
state_data['media_token'] = media_token
|
||||
|
||||
# Encode as base64 URL-safe string
|
||||
state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode().rstrip('=')
|
||||
|
||||
launch_data = {'target_link_uri': target_link_uri, 'nonce': nonce}
|
||||
# Store cmid if provided (including 0 for filter-based launches)
|
||||
if cmid is not None:
|
||||
launch_data['cmid'] = cmid
|
||||
# Store lti_message_hint for retry mechanism
|
||||
if lti_message_hint:
|
||||
launch_data['lti_message_hint'] = lti_message_hint
|
||||
|
||||
# CRITICAL: Store using the FULL encoded state, not just the UUID
|
||||
# PyLTI1p3 looks for the full state value during validation
|
||||
session_service.save_launch_data(f'state-{state}', launch_data)
|
||||
|
||||
# Also store lti_message_hint in regular session for retry mechanism
|
||||
# (state-specific storage might be lost due to cookie issues)
|
||||
if lti_message_hint:
|
||||
request.session['lti_last_message_hint'] = lti_message_hint
|
||||
request.session.modified = True
|
||||
|
||||
params = {
|
||||
'response_type': 'id_token',
|
||||
'redirect_uri': target_link_uri,
|
||||
'state': state,
|
||||
'client_id': client_id,
|
||||
'login_hint': login_hint,
|
||||
'scope': 'openid',
|
||||
'response_mode': 'form_post',
|
||||
'prompt': 'none',
|
||||
'nonce': nonce,
|
||||
}
|
||||
|
||||
if lti_message_hint:
|
||||
params['lti_message_hint'] = lti_message_hint
|
||||
|
||||
redirect_url = f"{platform.auth_login_url}?{urlencode(params)}"
|
||||
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
except LtiException as e:
|
||||
traceback.print_exc()
|
||||
return render(request, 'lti/launch_error.html', {'error': 'OIDC Login Failed', 'message': str(e)}, status=400)
|
||||
except Exception as e: # noqa
|
||||
traceback.print_exc()
|
||||
return JsonResponse({'error': 'Internal server error during OIDC login'}, status=500)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
@method_decorator(xframe_options_exempt, name='dispatch')
|
||||
class LaunchView(View):
|
||||
"""
|
||||
LTI Launch Handler - Step 3 of LTI 1.3 launch
|
||||
|
||||
Flow: Moodle → This endpoint (with JWT) → Validate → Provision → Session → Redirect
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
"""Handle LTI launch with JWT validation"""
|
||||
platform = None
|
||||
user = None
|
||||
error_message = ''
|
||||
claims = {}
|
||||
|
||||
# Extract media_token from state parameter if present (for filter launches)
|
||||
media_token_from_state = None
|
||||
state = request.POST.get('state')
|
||||
if state:
|
||||
try:
|
||||
# Add padding if needed for base64 decode
|
||||
padding = 4 - (len(state) % 4)
|
||||
if padding and padding != 4:
|
||||
state_padded = state + ('=' * padding)
|
||||
else:
|
||||
state_padded = state
|
||||
|
||||
state_decoded = base64.urlsafe_b64decode(state_padded.encode()).decode()
|
||||
state_data = json.loads(state_decoded)
|
||||
media_token_from_state = state_data.get('media_token')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
id_token = request.POST.get('id_token')
|
||||
if not id_token:
|
||||
raise ValueError("Missing id_token in launch request")
|
||||
|
||||
unverified = jwt.decode(id_token, options={"verify_signature": False})
|
||||
iss = unverified.get('iss')
|
||||
aud = unverified.get('aud')
|
||||
try:
|
||||
platform = LTIPlatform.objects.get(platform_id=iss, client_id=aud)
|
||||
except LTIPlatform.DoesNotExist:
|
||||
raise
|
||||
|
||||
tool_config = DjangoToolConfig.from_platform(platform)
|
||||
|
||||
lti_request = DjangoRequest(request)
|
||||
|
||||
session_service = DjangoSessionService(request)
|
||||
cookie_service = DjangoSessionService(request)
|
||||
|
||||
class CustomMessageLaunch(MessageLaunch):
|
||||
def _get_request_param(self, key):
|
||||
"""Override to properly get request parameters"""
|
||||
return self._request.get_param(key)
|
||||
|
||||
message_launch = CustomMessageLaunch(lti_request, tool_config, session_service=session_service, cookie_service=cookie_service)
|
||||
|
||||
launch_data = message_launch.get_launch_data()
|
||||
claims = self.sanitize_claims(launch_data)
|
||||
|
||||
# Extract custom claims and inject media_token from state if present
|
||||
try:
|
||||
custom_claims = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/custom', {})
|
||||
|
||||
# Inject media_token from state if present (for filter launches)
|
||||
if media_token_from_state and not custom_claims.get('media_friendly_token'):
|
||||
custom_claims['media_friendly_token'] = media_token_from_state
|
||||
# Update launch_data with the modified custom claims
|
||||
launch_data['https://purl.imsglobal.org/spec/lti/claim/custom'] = custom_claims
|
||||
|
||||
except Exception:
|
||||
custom_claims = {}
|
||||
|
||||
resource_link = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
|
||||
resource_link_id = resource_link.get('id', 'default')
|
||||
roles = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
|
||||
|
||||
# IMPORTANT: Provision user and create session BEFORE handling deep linking
|
||||
# This ensures filter launches (which are deep linking) have authenticated user
|
||||
user = provision_lti_user(platform, launch_data)
|
||||
|
||||
if 'https://purl.imsglobal.org/spec/lti/claim/context' in launch_data:
|
||||
category, rbac_group, resource_link_obj = provision_lti_context(platform, launch_data, resource_link_id)
|
||||
|
||||
apply_lti_roles(user, platform, roles, rbac_group)
|
||||
else:
|
||||
resource_link_obj = None
|
||||
|
||||
create_lti_session(request, user, message_launch, platform)
|
||||
|
||||
message_type = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/message_type')
|
||||
|
||||
if message_type == 'LtiDeepLinkingRequest':
|
||||
return self.handle_deep_linking_launch(request, message_launch, platform, launch_data)
|
||||
|
||||
# Clear retry counter on successful launch
|
||||
if 'lti_retry_count' in request.session:
|
||||
del request.session['lti_retry_count']
|
||||
|
||||
LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims)
|
||||
|
||||
redirect_url = self.determine_redirect(launch_data, resource_link_obj)
|
||||
|
||||
# Use HTML meta refresh instead of HTTP redirect to ensure session cookie is sent
|
||||
# In cross-site/iframe contexts, HTTP 302 redirects may not preserve session cookies
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;url={redirect_url}">
|
||||
<title>Loading...</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}}
|
||||
.loader {{
|
||||
text-align: center;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loader">
|
||||
<p>Loading MediaCMS...</p>
|
||||
<p><small>If you are not redirected, <a href="{redirect_url}">click here</a></small></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
response = HttpResponse(html_content, content_type='text/html')
|
||||
# Ensure session cookie is set in this response
|
||||
request.session.modified = True
|
||||
return response
|
||||
|
||||
except LtiException as e: # noqa
|
||||
error_message = str(e)
|
||||
traceback.print_exc()
|
||||
|
||||
# Attempt automatic retry for state errors (handles concurrent launches and session issues)
|
||||
if "State not found" in error_message or "state not found" in error_message.lower():
|
||||
return self.handle_state_not_found(request, platform)
|
||||
except Exception as e: # noqa
|
||||
traceback.print_exc()
|
||||
|
||||
if platform:
|
||||
LTILaunchLog.objects.create(platform=platform, user=user, launch_type='resource_link', success=False, error_message=error_message, claims=claims)
|
||||
|
||||
return render(request, 'lti/launch_error.html', {'error': 'LTI Launch Failed', 'message': error_message}, status=400)
|
||||
|
||||
def sanitize_claims(self, claims):
|
||||
"""Remove sensitive data from claims before logging"""
|
||||
safe_claims = claims.copy()
|
||||
return safe_claims
|
||||
|
||||
def determine_redirect(self, launch_data, resource_link):
|
||||
"""Determine where to redirect after successful launch"""
|
||||
|
||||
custom = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/custom', {})
|
||||
|
||||
custom_path = custom.get('redirect_path')
|
||||
|
||||
if custom_path:
|
||||
if not custom_path.startswith('/'):
|
||||
custom_path = '/' + custom_path
|
||||
return custom_path
|
||||
|
||||
# Check custom claims for media token (from both deep linking and filter launches)
|
||||
media_id = custom.get('media_id') or custom.get('media_friendly_token')
|
||||
|
||||
if media_id:
|
||||
try:
|
||||
media = Media.objects.get(friendly_token=media_id)
|
||||
return reverse('lti:embed_media', args=[media.friendly_token])
|
||||
except Media.DoesNotExist:
|
||||
pass
|
||||
|
||||
return reverse('lti:my_media')
|
||||
|
||||
def handle_state_not_found(self, request, platform=None):
|
||||
"""
|
||||
Handle state not found errors by attempting to restart the OIDC flow.
|
||||
|
||||
This can happen when:
|
||||
- Cookies are blocked/deleted
|
||||
- Session expired
|
||||
- Browser privacy settings interfere
|
||||
"""
|
||||
try:
|
||||
# Check retry count to prevent infinite loops
|
||||
retry_count = request.session.get('lti_retry_count', 0)
|
||||
MAX_RETRIES = 5 # Increased for concurrent launches (e.g., multiple videos on same page)
|
||||
|
||||
if retry_count >= MAX_RETRIES:
|
||||
return render(
|
||||
request,
|
||||
'lti/launch_error.html',
|
||||
{
|
||||
'error': 'Authentication Failed',
|
||||
'message': (
|
||||
'Unable to establish a secure session after multiple attempts. '
|
||||
'This may be due to browser cookie settings or privacy features. Please try:\n\n'
|
||||
'1. Enabling cookies for this site\n'
|
||||
'2. Disabling tracking protection for this site\n'
|
||||
'3. Using a different browser\n'
|
||||
'4. Contacting your administrator if the issue persists'
|
||||
),
|
||||
'is_cookie_error': True,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Extract launch parameters from the POST request
|
||||
id_token = request.POST.get('id_token')
|
||||
state = request.POST.get('state')
|
||||
|
||||
if not id_token:
|
||||
raise ValueError("No id_token available for retry")
|
||||
|
||||
# Decode state to extract media_token (encoded during OIDC login)
|
||||
media_token_from_retry = None
|
||||
try:
|
||||
# Add padding if needed for base64 decode
|
||||
padding = 4 - (len(state) % 4)
|
||||
if padding and padding != 4:
|
||||
state_padded = state + ('=' * padding)
|
||||
else:
|
||||
state_padded = state
|
||||
|
||||
state_decoded = base64.urlsafe_b64decode(state_padded.encode()).decode()
|
||||
state_data = json.loads(state_decoded)
|
||||
media_token_from_retry = state_data.get('media_token')
|
||||
except Exception:
|
||||
# State might be a plain UUID from older code, that's OK
|
||||
pass
|
||||
|
||||
# Decode JWT to extract issuer and target info (no verification needed for this)
|
||||
unverified = jwt.decode(id_token, options={"verify_signature": False})
|
||||
|
||||
iss = unverified.get('iss')
|
||||
aud = unverified.get('aud') # This is the client_id
|
||||
target_link_uri = unverified.get('https://purl.imsglobal.org/spec/lti/claim/target_link_uri')
|
||||
|
||||
# Get login_hint and lti_message_hint if available
|
||||
login_hint = request.POST.get('login_hint') or unverified.get('sub')
|
||||
|
||||
if not all([iss, aud, target_link_uri]):
|
||||
raise ValueError("Missing required parameters for OIDC retry")
|
||||
|
||||
# Try to identify platform
|
||||
if not platform:
|
||||
try:
|
||||
platform = LTIPlatform.objects.get(platform_id=iss, client_id=aud)
|
||||
except LTIPlatform.DoesNotExist:
|
||||
raise ValueError(f"Platform not found: {iss}/{aud}")
|
||||
|
||||
# Increment retry counter
|
||||
request.session['lti_retry_count'] = retry_count + 1
|
||||
request.session.modified = True
|
||||
|
||||
# Build OIDC login URL with all parameters
|
||||
oidc_login_url = request.build_absolute_uri(reverse('lti:oidc_login'))
|
||||
|
||||
params = {
|
||||
'iss': iss,
|
||||
'client_id': aud,
|
||||
'target_link_uri': target_link_uri,
|
||||
'login_hint': login_hint,
|
||||
}
|
||||
|
||||
# DON'T pass lti_message_hint in retry - it's single-use and causes Moodle 404
|
||||
# The launchid in lti_message_hint is only valid for one authentication flow
|
||||
# Moodle will handle the retry without the hint
|
||||
|
||||
# Pass media_token in retry for filter launches (our custom parameter, not Moodle's)
|
||||
if media_token_from_retry:
|
||||
params['media_token'] = media_token_from_retry
|
||||
|
||||
# Add retry indicator
|
||||
params['retry'] = retry_count + 1
|
||||
|
||||
redirect_url = f"{oidc_login_url}?{urlencode(params)}"
|
||||
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
||||
except Exception as retry_error:
|
||||
traceback.print_exc()
|
||||
|
||||
return render(
|
||||
request,
|
||||
'lti/launch_error.html',
|
||||
{
|
||||
'error': 'LTI Launch Failed',
|
||||
'message': f'State validation failed and automatic retry was unsuccessful: {str(retry_error)}',
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
def handle_deep_linking_launch(self, request, message_launch, platform, launch_data):
|
||||
"""Handle deep linking request"""
|
||||
# Clear retry counter on successful launch
|
||||
if 'lti_retry_count' in request.session:
|
||||
del request.session['lti_retry_count']
|
||||
|
||||
deep_linking_settings = launch_data.get('https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings', {})
|
||||
|
||||
if not deep_linking_settings:
|
||||
raise ValueError("Missing deep linking settings in launch data")
|
||||
|
||||
deep_link_return_url = deep_linking_settings.get('deep_link_return_url')
|
||||
|
||||
if not deep_link_return_url:
|
||||
raise ValueError("Missing deep_link_return_url in deep linking settings")
|
||||
|
||||
request.session['lti_deep_link'] = {
|
||||
'deep_link_return_url': deep_link_return_url,
|
||||
'deployment_id': launch_data.get('https://purl.imsglobal.org/spec/lti/claim/deployment_id'),
|
||||
'platform_id': platform.id,
|
||||
'message_launch_data': launch_data, # Store full launch data for JWT creation
|
||||
}
|
||||
|
||||
# Check if we have a media_friendly_token from filter launches
|
||||
custom_claims = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/custom', {})
|
||||
media_token = custom_claims.get('media_friendly_token')
|
||||
|
||||
if media_token:
|
||||
redirect_url = reverse('lti:embed_media', args=[media_token])
|
||||
else:
|
||||
redirect_url = reverse('lti:select_media')
|
||||
|
||||
# Use HTML meta refresh to ensure session cookie is preserved in cross-site contexts
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;url={redirect_url}">
|
||||
<title>Loading...</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Loading...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
request.session.modified = True
|
||||
return HttpResponse(html_content, content_type='text/html')
|
||||
|
||||
|
||||
class JWKSView(View):
|
||||
"""
|
||||
JWKS Endpoint - Provides tool's public keys
|
||||
|
||||
Used by Moodle to validate signatures from MediaCMS (e.g., Deep Linking responses)
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Return tool's public JWK Set"""
|
||||
jwks = get_jwks()
|
||||
|
||||
return JsonResponse(jwks, content_type='application/json')
|
||||
|
||||
|
||||
class PublicKeyPEMView(View):
|
||||
"""
|
||||
Display public key in PEM format for easy copy/paste into Moodle
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Return public key in PEM format"""
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
|
||||
jwk_obj = jwk.JWK(**key_obj.public_key_jwk)
|
||||
pem_bytes = jwk_obj.export_to_pem()
|
||||
pem_string = pem_bytes.decode('utf-8')
|
||||
|
||||
return HttpResponse(
|
||||
f"MediaCMS LTI Public Key (PEM Format)\n"
|
||||
f"{'=' * 80}\n\n"
|
||||
f"{pem_string}\n"
|
||||
f"{'=' * 80}\n\n"
|
||||
f"Instructions:\n"
|
||||
f"1. Copy the entire key above (including BEGIN/END lines)\n"
|
||||
f"2. In Moodle LTI tool configuration, change 'Public key type' to 'Public key'\n"
|
||||
f"3. Paste the key into the 'Public key' field\n"
|
||||
f"4. Save and try Deep Linking again\n",
|
||||
content_type='text/plain',
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(xframe_options_exempt, name='dispatch')
|
||||
class MyMediaLTIView(View):
|
||||
"""
|
||||
My Media page for LTI-authenticated users
|
||||
|
||||
Shows user's media profile in iframe
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Display my media page"""
|
||||
lti_session = validate_lti_session(request)
|
||||
|
||||
if not lti_session:
|
||||
return JsonResponse({'error': 'Not authenticated via LTI'}, status=403)
|
||||
|
||||
profile_url = f"/user/{request.user.username}"
|
||||
return HttpResponseRedirect(profile_url)
|
||||
|
||||
|
||||
@method_decorator(xframe_options_exempt, name='dispatch')
|
||||
class EmbedMediaLTIView(View):
|
||||
"""
|
||||
Embed media with LTI authentication
|
||||
|
||||
Pattern: Extends existing /embed functionality
|
||||
"""
|
||||
|
||||
def get(self, request, friendly_token):
|
||||
"""Display embedded media"""
|
||||
media = get_object_or_404(Media, friendly_token=friendly_token)
|
||||
|
||||
lti_session = validate_lti_session(request)
|
||||
can_view = False
|
||||
|
||||
if lti_session and request.user.is_authenticated:
|
||||
if request.user.has_member_access_to_media(media):
|
||||
can_view = True
|
||||
|
||||
if media.state in ["public", "unlisted"]:
|
||||
can_view = True
|
||||
|
||||
if not can_view:
|
||||
return JsonResponse({'error': 'Access denied', 'message': 'You do not have permission to view this media'}, status=403)
|
||||
|
||||
return HttpResponseRedirect(f"/embed?m={friendly_token}")
|
||||
|
||||
|
||||
class ManualSyncView(APIView):
|
||||
"""
|
||||
Manual NRPS sync for course members/roles
|
||||
|
||||
Endpoint: POST /lti/sync/<platform_id>/<context_id>/
|
||||
Requires: User must be manager in the course RBAC group
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, platform_id, context_id):
|
||||
"""Manually trigger NRPS sync"""
|
||||
try:
|
||||
platform = get_object_or_404(LTIPlatform, id=platform_id)
|
||||
|
||||
resource_link = LTIResourceLink.objects.filter(platform=platform, context_id=context_id).first()
|
||||
|
||||
if not resource_link:
|
||||
return Response({'error': 'Context not found', 'message': f'No resource link found for context {context_id}'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
rbac_group = resource_link.rbac_group
|
||||
if not rbac_group:
|
||||
return Response({'error': 'No RBAC group', 'message': 'This context does not have an associated RBAC group'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
is_manager = RBACMembership.objects.filter(user=request.user, rbac_group=rbac_group, role='manager').exists()
|
||||
|
||||
if not is_manager:
|
||||
return Response({'error': 'Insufficient permissions', 'message': 'You must be a course manager to sync members'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not platform.enable_nrps:
|
||||
return Response({'error': 'NRPS disabled', 'message': 'Names and Role Provisioning Service is disabled for this platform'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
last_launch = LTILaunchLog.objects.filter(platform=platform, resource_link=resource_link, success=True).order_by('-created_at').first()
|
||||
|
||||
if not last_launch:
|
||||
return Response({'error': 'No launch data', 'message': 'No successful launch data found for NRPS'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
nrps_client = LTINRPSClient(platform, last_launch.claims)
|
||||
result = nrps_client.sync_members_to_rbac_group(rbac_group)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'status': 'success',
|
||||
'message': f'Successfully synced {result["synced"]} members',
|
||||
'synced_count': result['synced'],
|
||||
'removed_count': result.get('removed', 0),
|
||||
'synced_at': result['synced_at'],
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': 'Sync failed', 'message': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@method_decorator(xframe_options_exempt, name='dispatch')
|
||||
class TinyMCEGetEmbedView(View):
|
||||
"""
|
||||
API endpoint to get embed code for a specific media item (for TinyMCE integration).
|
||||
|
||||
Returns JSON with the embed code for the requested media.
|
||||
Requires: User must be logged in (via LTI session)
|
||||
"""
|
||||
|
||||
def get(self, request, friendly_token):
|
||||
"""Get embed code for the specified media."""
|
||||
# Verify user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'Authentication required'}, status=401)
|
||||
|
||||
# Verify media exists
|
||||
media = Media.objects.filter(friendly_token=friendly_token).first()
|
||||
|
||||
if not media:
|
||||
return JsonResponse({'error': 'Media not found'}, status=404)
|
||||
|
||||
# Build embed URL
|
||||
embed_url = request.build_absolute_uri(reverse('get_embed') + f'?m={friendly_token}')
|
||||
|
||||
# Generate iframe embed code
|
||||
embed_code = f'<iframe src="{embed_url}" ' f'width="960" height="540" ' f'frameborder="0" ' f'allowfullscreen ' f'title="{media.title}">' f'</iframe>'
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'embedCode': embed_code,
|
||||
'title': media.title,
|
||||
'thumbnail': media.thumbnail_url if hasattr(media, 'thumbnail_url') else '',
|
||||
}
|
||||
)
|
||||
46
moodle-plugins/README.md
Normal file
46
moodle-plugins/README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Moodle Plugins for MediaCMS
|
||||
|
||||
This directory contains plugins for integrating MediaCMS with Moodle.
|
||||
|
||||
## Available Plugins
|
||||
|
||||
### tiny_mediacms - TinyMCE MediaCMS Plugin
|
||||
|
||||
A TinyMCE editor plugin that allows users to insert MediaCMS content directly from the Moodle text editor.
|
||||
|
||||
**Features:**
|
||||
- Insert MediaCMS content with a single click
|
||||
- Visual media selection interface
|
||||
- Respects RBAC permissions
|
||||
- Supports multiple media selection
|
||||
- Works with LTI 1.3 authentication
|
||||
|
||||
**Installation:**
|
||||
See [TINYMCE_PLUGIN_INSTALLATION.md](../TINYMCE_PLUGIN_INSTALLATION.md) for detailed installation instructions.
|
||||
|
||||
**Quick Install:**
|
||||
```bash
|
||||
cp -r tiny_mediacms /path/to/moodle/lib/editor/tiny/plugins/
|
||||
```
|
||||
|
||||
Then visit Moodle's Site administration → Notifications to complete the installation.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Moodle 5.0 or later
|
||||
- MediaCMS with LTI integration configured
|
||||
- Active LTI 1.3 connection between Moodle and MediaCMS
|
||||
|
||||
## Documentation
|
||||
|
||||
- [TinyMCE Plugin Installation Guide](../TINYMCE_PLUGIN_INSTALLATION.md)
|
||||
- [LTI Setup Guide](../LTI_README.md)
|
||||
- [LTI Configuration](../LTI_README2.md)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, please refer to the MediaCMS documentation or open an issue in the repository.
|
||||
|
||||
## License
|
||||
|
||||
These plugins are part of MediaCMS and are licensed under the GNU General Public License v3.0 or later.
|
||||
54
moodle-plugins/filter_mediacmslti/CHANGELOG.md
Normal file
54
moodle-plugins/filter_mediacmslti/CHANGELOG.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the MediaCMS LTI Filter plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] - 2026-01-23
|
||||
|
||||
### Added
|
||||
- Initial release of MediaCMS LTI Filter
|
||||
- Automatic detection of MediaCMS video URLs in Moodle content
|
||||
- Transparent LTI 1.3 authentication for embedded videos
|
||||
- Support for `/view?m=TOKEN` and `/embed?m=TOKEN` URL patterns
|
||||
- Configurable MediaCMS URL setting
|
||||
- Configurable LTI tool selection from Moodle's LTI tools
|
||||
- Configurable iframe dimensions (width/height)
|
||||
- Auto-submit form mechanism for seamless LTI launch
|
||||
- Support for Moodle 5.0+
|
||||
- Privacy provider implementation for GDPR compliance
|
||||
- Comprehensive documentation (README, INSTALLATION guide)
|
||||
- Multi-language support framework (English strings included)
|
||||
|
||||
### Security
|
||||
- Only processes content for logged-in users (no guest access)
|
||||
- Uses Moodle's LTI 1.3 security framework
|
||||
- Passes user context via secure `login_hint` parameter
|
||||
- All URLs properly escaped and sanitized
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Planned Features
|
||||
- Support for additional MediaCMS URL patterns
|
||||
- Customizable iframe styling options
|
||||
- Cache optimization for LTI configuration
|
||||
- Support for playlist URLs
|
||||
- Admin interface to preview filter behavior
|
||||
- Bulk URL conversion tool
|
||||
- Statistics/usage tracking
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
- **1.0.0** (2026-01-23) - Initial release
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Upgrading to 1.0.0
|
||||
- First release, no upgrade path needed
|
||||
|
||||
---
|
||||
|
||||
For detailed information about each release, see the Git commit history.
|
||||
223
moodle-plugins/filter_mediacmslti/EXAMPLE_OUTPUT.md
Normal file
223
moodle-plugins/filter_mediacmslti/EXAMPLE_OUTPUT.md
Normal file
@ -0,0 +1,223 @@
|
||||
# Example Output - How the Filter Works
|
||||
|
||||
## Input (What users paste)
|
||||
|
||||
```
|
||||
https://deic.mediacms.io/view?m=KmITliaUC
|
||||
```
|
||||
|
||||
## Output (What gets rendered)
|
||||
|
||||
### HTML Generated by Filter
|
||||
|
||||
```html
|
||||
<div class="mediacms-lti-embed">
|
||||
<!-- The iframe where the video will load -->
|
||||
<iframe
|
||||
id="mediacms_lti_65b8f9a3e4c21"
|
||||
width="960"
|
||||
height="540"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
style="max-width: 100%;">
|
||||
</iframe>
|
||||
|
||||
<!-- Auto-submit form that initiates LTI authentication -->
|
||||
<form
|
||||
id="mediacms_lti_65b8f9a3e4c21_form"
|
||||
action="https://deic.mediacms.io/lti/oidc/login/"
|
||||
method="POST"
|
||||
target="mediacms_lti_65b8f9a3e4c21"
|
||||
style="display: none;">
|
||||
|
||||
<!-- LTI Platform Issuer (your Moodle URL) -->
|
||||
<input type="hidden" name="iss" value="https://your-moodle-site.com" />
|
||||
|
||||
<!-- LTI Client ID from tool configuration -->
|
||||
<input type="hidden" name="client_id" value="ABC123XYZ" />
|
||||
|
||||
<!-- Current user's Moodle ID -->
|
||||
<input type="hidden" name="login_hint" value="42" />
|
||||
|
||||
<!-- Where to go after authentication, with video token -->
|
||||
<input type="hidden" name="target_link_uri" value="https://deic.mediacms.io/lti/launch/?media_friendly_token=KmITliaUC" />
|
||||
</form>
|
||||
|
||||
<!-- JavaScript to auto-submit the form immediately -->
|
||||
<script>
|
||||
document.getElementById('mediacms_lti_65b8f9a3e4c21_form').submit();
|
||||
</script>
|
||||
</div>
|
||||
```
|
||||
|
||||
## What Happens Step-by-Step
|
||||
|
||||
### 1. User Views Page
|
||||
```
|
||||
User opens Moodle page containing the MediaCMS URL
|
||||
↓
|
||||
Filter detects: https://deic.mediacms.io/view?m=KmITliaUC
|
||||
↓
|
||||
Extracts video token: KmITliaUC
|
||||
```
|
||||
|
||||
### 2. HTML Generation
|
||||
```
|
||||
Filter generates:
|
||||
- Iframe with unique ID
|
||||
- Hidden form with LTI parameters
|
||||
- JavaScript to auto-submit
|
||||
```
|
||||
|
||||
### 3. LTI Authentication Flow
|
||||
```
|
||||
Form submits to MediaCMS OIDC endpoint
|
||||
↓
|
||||
MediaCMS receives:
|
||||
- iss: https://your-moodle-site.com
|
||||
- client_id: ABC123XYZ
|
||||
- login_hint: 42 (user's Moodle ID)
|
||||
- target_link_uri: https://deic.mediacms.io/lti/launch/?media_friendly_token=KmITliaUC
|
||||
↓
|
||||
MediaCMS redirects to Moodle's auth endpoint
|
||||
↓
|
||||
Moodle validates and creates JWT token
|
||||
↓
|
||||
Moodle POSTs JWT back to MediaCMS
|
||||
↓
|
||||
MediaCMS validates JWT and creates session
|
||||
↓
|
||||
MediaCMS redirects to /lti/embed/KmITliaUC/
|
||||
↓
|
||||
MediaCMS checks permissions and redirects to /view?m=KmITliaUC
|
||||
↓
|
||||
Video loads in iframe!
|
||||
```
|
||||
|
||||
### 4. User Experience
|
||||
```
|
||||
User sees:
|
||||
1. Page loads
|
||||
2. Empty iframe appears briefly (< 1 second)
|
||||
3. Video player loads inside iframe
|
||||
4. Video starts playing
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Example 1: Course Page
|
||||
|
||||
**Input:**
|
||||
```html
|
||||
<p>Welcome to the course! Watch this introduction:</p>
|
||||
<p>https://deic.mediacms.io/view?m=KmITliaUC</p>
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```html
|
||||
<p>Welcome to the course! Watch this introduction:</p>
|
||||
<div class="mediacms-lti-embed">
|
||||
<iframe id="mediacms_lti_..." width="960" height="540" ...></iframe>
|
||||
<form id="mediacms_lti_..._form" ...>...</form>
|
||||
<script>...</script>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Example 2: Multiple Videos
|
||||
|
||||
**Input:**
|
||||
```html
|
||||
<h2>Week 1 Videos</h2>
|
||||
<p>Lecture 1: https://deic.mediacms.io/view?m=ABC123</p>
|
||||
<p>Lecture 2: https://deic.mediacms.io/view?m=XYZ789</p>
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```html
|
||||
<h2>Week 1 Videos</h2>
|
||||
<p>Lecture 1:
|
||||
<div class="mediacms-lti-embed">
|
||||
<iframe id="mediacms_lti_abc_..." ...></iframe>
|
||||
...
|
||||
</div>
|
||||
</p>
|
||||
<p>Lecture 2:
|
||||
<div class="mediacms-lti-embed">
|
||||
<iframe id="mediacms_lti_xyz_..." ...></iframe>
|
||||
...
|
||||
</div>
|
||||
</p>
|
||||
```
|
||||
|
||||
Each video gets its own iframe with its own LTI authentication!
|
||||
|
||||
### Example 3: Mixed Content
|
||||
|
||||
**Input:**
|
||||
```html
|
||||
<p>Check out this resource:</p>
|
||||
<p><a href="https://example.com">Regular link</a></p>
|
||||
<p>https://deic.mediacms.io/view?m=KmITliaUC</p>
|
||||
<p>Another link: https://google.com</p>
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```html
|
||||
<p>Check out this resource:</p>
|
||||
<p><a href="https://example.com">Regular link</a></p>
|
||||
<div class="mediacms-lti-embed">
|
||||
<iframe id="mediacms_lti_..." ...></iframe>
|
||||
...
|
||||
</div>
|
||||
<p>Another link: https://google.com</p>
|
||||
```
|
||||
|
||||
Only MediaCMS URLs are converted! Other URLs remain unchanged.
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Fast Detection**: Regex-based URL matching is extremely fast
|
||||
- **No Database Queries**: Configuration is cached
|
||||
- **Lazy Loading**: Videos only load when iframe initiates LTI flow
|
||||
- **Minimal Overhead**: Each conversion adds ~500 bytes of HTML
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **User Context**: Each iframe uses the current user's Moodle ID
|
||||
- **CSRF Protected**: LTI flow includes state/nonce validation
|
||||
- **Domain Restricted**: Only configured MediaCMS domain is processed
|
||||
- **Guest Users**: Filter doesn't run for guest users (returns original text)
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
Works in all modern browsers:
|
||||
- ✅ Chrome/Edge (Chromium)
|
||||
- ✅ Firefox
|
||||
- ✅ Safari
|
||||
- ✅ Mobile browsers (iOS Safari, Chrome Mobile)
|
||||
|
||||
## Troubleshooting Examples
|
||||
|
||||
### If URL Doesn't Convert
|
||||
|
||||
**Check these patterns:**
|
||||
```
|
||||
✅ https://deic.mediacms.io/view?m=KmITliaUC
|
||||
✅ https://deic.mediacms.io/embed?m=KmITliaUC
|
||||
❌ https://deic.mediacms.io/view/KmITliaUC (no ?m= parameter)
|
||||
❌ http://other-domain.com/view?m=KmITliaUC (wrong domain)
|
||||
```
|
||||
|
||||
### If Video Doesn't Load
|
||||
|
||||
**Check browser console:**
|
||||
```javascript
|
||||
// Expected: No errors
|
||||
// If you see CORS errors: Check MediaCMS iframe settings
|
||||
// If you see 403 Forbidden: Check LTI configuration
|
||||
// If you see 404: Check MediaCMS URL in settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**This is what makes the transparent LTI authentication possible!**
|
||||
105
moodle-plugins/filter_mediacmslti/INSTALLATION.md
Normal file
105
moodle-plugins/filter_mediacmslti/INSTALLATION.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Quick Installation Guide - MediaCMS LTI Filter
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Moodle 5.0 or later
|
||||
- MediaCMS instance with LTI 1.3 support
|
||||
- LTI External Tool already configured in Moodle for MediaCMS
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Install the Plugin
|
||||
|
||||
**Option A: Upload via Moodle UI**
|
||||
```
|
||||
1. Log in as Moodle admin
|
||||
2. Site administration → Plugins → Install plugins
|
||||
3. Upload the filter_mediacmslti.zip file
|
||||
4. Click "Install plugin from the ZIP file"
|
||||
5. Complete the installation wizard
|
||||
```
|
||||
|
||||
**Option B: Manual Installation**
|
||||
```bash
|
||||
# Copy plugin to Moodle filter directory
|
||||
cp -r filter_mediacmslti /path/to/moodle/filter/
|
||||
|
||||
# Set permissions
|
||||
chown -R www-data:www-data /path/to/moodle/filter/filter_mediacmslti
|
||||
|
||||
# Visit Moodle notifications page to complete installation
|
||||
```
|
||||
|
||||
### 2. Configure the Filter
|
||||
|
||||
```
|
||||
1. Site administration → Plugins → Filters → MediaCMS LTI Filter
|
||||
2. Set "MediaCMS URL" to your MediaCMS instance (e.g., https://deic.mediacms.io)
|
||||
3. Select your LTI tool from the "LTI External Tool" dropdown
|
||||
4. Optionally adjust iframe width/height (defaults: 960x540)
|
||||
5. Click "Save changes"
|
||||
```
|
||||
|
||||
### 3. Enable the Filter
|
||||
|
||||
```
|
||||
1. Site administration → Plugins → Filters → Manage filters
|
||||
2. Find "MediaCMS LTI Embed" in the list
|
||||
3. Change from "Disabled" to "On"
|
||||
4. Click "Save changes"
|
||||
```
|
||||
|
||||
### 4. Test It!
|
||||
|
||||
```
|
||||
1. Create a Page resource in any course
|
||||
2. Paste a MediaCMS URL: https://deic.mediacms.io/view?m=KmITliaUC
|
||||
3. Save the page
|
||||
4. View the page - video should embed automatically!
|
||||
```
|
||||
|
||||
## Configuration Quick Reference
|
||||
|
||||
| Setting | Example Value | Description |
|
||||
|---------|---------------|-------------|
|
||||
| MediaCMS URL | `https://deic.mediacms.io` | Your MediaCMS instance (no trailing slash) |
|
||||
| LTI External Tool | MediaCMS | Select from dropdown |
|
||||
| Iframe Width | 960 | Width in pixels |
|
||||
| Iframe Height | 540 | Height in pixels |
|
||||
|
||||
## Troubleshooting Quick Fixes
|
||||
|
||||
**URLs not converting?**
|
||||
- Check filter is "On" in Filters → Manage filters
|
||||
- Verify MediaCMS URL matches the URLs you're pasting
|
||||
- Ensure user is logged in (not guest)
|
||||
|
||||
**Video not loading?**
|
||||
- Check LTI tool is configured correctly
|
||||
- Verify client_id and issuer match between Moodle and MediaCMS
|
||||
- Check browser console for errors
|
||||
|
||||
**Need more help?**
|
||||
See full README.md for detailed troubleshooting and technical documentation.
|
||||
|
||||
## What URLs Are Supported?
|
||||
|
||||
The filter automatically detects these patterns:
|
||||
- `https://deic.mediacms.io/view?m=TOKEN`
|
||||
- `https://deic.mediacms.io/embed?m=TOKEN`
|
||||
|
||||
Replace `deic.mediacms.io` with your configured MediaCMS URL.
|
||||
|
||||
## Quick Test Checklist
|
||||
|
||||
- [ ] Plugin installed successfully
|
||||
- [ ] Filter settings configured
|
||||
- [ ] Filter enabled in Manage filters
|
||||
- [ ] LTI tool configured and working
|
||||
- [ ] Test URL pasted in Page resource
|
||||
- [ ] Video embeds and plays correctly
|
||||
|
||||
## Support
|
||||
|
||||
Full documentation: See README.md
|
||||
MediaCMS docs: https://docs.mediacms.io
|
||||
22
moodle-plugins/filter_mediacmslti/LICENSE
Normal file
22
moodle-plugins/filter_mediacmslti/LICENSE
Normal file
@ -0,0 +1,22 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
---
|
||||
|
||||
Copyright (C) 2026 MediaCMS
|
||||
|
||||
For the full text of the GNU GPL v3 license, visit:
|
||||
https://www.gnu.org/licenses/gpl-3.0.html
|
||||
264
moodle-plugins/filter_mediacmslti/README.md
Normal file
264
moodle-plugins/filter_mediacmslti/README.md
Normal file
@ -0,0 +1,264 @@
|
||||
# MediaCMS LTI Filter for Moodle 5
|
||||
|
||||
A Moodle filter plugin that automatically converts MediaCMS video URLs into LTI-authenticated embedded video players. Users can simply paste MediaCMS URLs into any Moodle content area (course descriptions, page content, etc.) and the videos will be embedded with transparent LTI authentication.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic URL Detection**: Detects MediaCMS video URLs and converts them to embedded iframes
|
||||
- **Transparent LTI Authentication**: Automatically initiates LTI 1.3 authentication flow without user interaction
|
||||
- **Seamless Integration**: Works in any Moodle text area (Pages, Activities, Descriptions, etc.)
|
||||
- **Configurable**: Admin can set MediaCMS URL, LTI tool, and iframe dimensions
|
||||
- **Moodle 5 Compatible**: Built specifically for Moodle 5.0+
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User pastes a MediaCMS URL (e.g., `https://deic.mediacms.io/view?m=KmITliaUC`)
|
||||
2. Filter detects the URL and extracts the video token
|
||||
3. Generates an iframe with an auto-submitting form that initiates LTI authentication
|
||||
4. Form includes:
|
||||
- Current user's Moodle ID as `login_hint`
|
||||
- LTI platform issuer (ISS)
|
||||
- LTI client ID
|
||||
- Target video via custom parameters
|
||||
5. Video loads in iframe with proper LTI authentication
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing this filter, you must have:
|
||||
|
||||
1. **MediaCMS with LTI Support**: Your MediaCMS instance must have LTI 1.3 integration enabled
|
||||
2. **LTI External Tool Configured**: An LTI External Tool must be configured in Moodle that connects to MediaCMS
|
||||
3. **Moodle 5.0 or later**
|
||||
|
||||
## Installation
|
||||
|
||||
### Method 1: Via Moodle Plugin Directory (Recommended)
|
||||
|
||||
1. Download the plugin ZIP file
|
||||
2. Log in as Moodle admin
|
||||
3. Go to **Site administration → Plugins → Install plugins**
|
||||
4. Upload the ZIP file
|
||||
5. Click "Install plugin from the ZIP file"
|
||||
6. Follow the on-screen prompts
|
||||
|
||||
### Method 2: Manual Installation
|
||||
|
||||
1. Copy the `filter_mediacmslti` directory to your Moodle installation:
|
||||
```bash
|
||||
cp -r filter_mediacmslti /path/to/moodle/filter/
|
||||
```
|
||||
|
||||
2. Set proper permissions:
|
||||
```bash
|
||||
cd /path/to/moodle
|
||||
chown -R www-data:www-data filter/filter_mediacmslti
|
||||
```
|
||||
|
||||
3. Log in as Moodle admin and go to **Site administration → Notifications**
|
||||
4. Moodle will detect the new plugin and prompt you to upgrade
|
||||
5. Click "Upgrade Moodle database now"
|
||||
|
||||
## Configuration
|
||||
|
||||
### Step 1: Configure LTI External Tool (if not already done)
|
||||
|
||||
1. Go to **Site administration → Plugins → Activity modules → External tool → Manage tools**
|
||||
2. Click "Configure a tool manually"
|
||||
3. Enter the following details:
|
||||
- **Tool name**: MediaCMS
|
||||
- **Tool URL**: `https://deic.mediacms.io/lti/launch/`
|
||||
- **LTI version**: LTI 1.3
|
||||
- **Public key type**: Keyset URL
|
||||
- **Public keyset**: `https://deic.mediacms.io/lti/jwks/`
|
||||
- **Initiate login URL**: `https://deic.mediacms.io/lti/oidc/login/`
|
||||
- **Redirection URI(s)**: `https://deic.mediacms.io/lti/launch/`
|
||||
4. Enable:
|
||||
- Deep Linking (Content-Item Message)
|
||||
- Share launcher's name with tool
|
||||
- Share launcher's email with tool
|
||||
5. Click "Save changes"
|
||||
6. **Note the Tool ID** (you'll need this for the filter configuration)
|
||||
|
||||
### Step 2: Configure Filter Settings
|
||||
|
||||
1. Go to **Site administration → Plugins → Filters → MediaCMS LTI Filter**
|
||||
2. Configure the following settings:
|
||||
|
||||
- **MediaCMS URL**: Enter your MediaCMS instance URL
|
||||
- Example: `https://deic.mediacms.io`
|
||||
- Do NOT include trailing slash
|
||||
|
||||
- **LTI External Tool**: Select the MediaCMS tool you configured in Step 1
|
||||
- Choose from the dropdown of available LTI tools
|
||||
|
||||
- **Iframe Width**: Default width in pixels (default: 960)
|
||||
|
||||
- **Iframe Height**: Default height in pixels (default: 540)
|
||||
|
||||
3. Click "Save changes"
|
||||
|
||||
### Step 3: Enable the Filter
|
||||
|
||||
1. Go to **Site administration → Plugins → Filters → Manage filters**
|
||||
2. Find "MediaCMS LTI Embed" in the list
|
||||
3. Change the setting from "Disabled" to **"On"**
|
||||
- Alternatively, use "Off, but available" to allow course-level control
|
||||
4. Adjust the filter order if needed (higher = runs earlier)
|
||||
5. Click "Save changes"
|
||||
|
||||
## Usage
|
||||
|
||||
### For Content Creators
|
||||
|
||||
Once the filter is enabled, simply paste MediaCMS URLs into any Moodle content area:
|
||||
|
||||
#### Example 1: In a Page Resource
|
||||
|
||||
1. Create or edit a Page resource
|
||||
2. In the content editor, paste the MediaCMS URL:
|
||||
```
|
||||
https://deic.mediacms.io/view?m=KmITliaUC
|
||||
```
|
||||
3. Save the page
|
||||
4. The URL will automatically be replaced with an embedded video player
|
||||
|
||||
#### Example 2: In Course Description
|
||||
|
||||
1. Edit course settings
|
||||
2. In the "Course description" field, paste:
|
||||
```
|
||||
Watch this introduction video: https://deic.mediacms.io/view?m=KmITliaUC
|
||||
```
|
||||
3. Save
|
||||
4. The video will be embedded directly in the course summary
|
||||
|
||||
#### Example 3: In Activity Description
|
||||
|
||||
1. Create any activity (Forum, Assignment, etc.)
|
||||
2. In the description field, paste MediaCMS URLs
|
||||
3. Students will see embedded videos when viewing the activity
|
||||
|
||||
### Supported URL Formats
|
||||
|
||||
The filter recognizes these URL patterns:
|
||||
- `https://deic.mediacms.io/view?m=TOKEN`
|
||||
- `https://deic.mediacms.io/embed?m=TOKEN`
|
||||
- `http://` versions (if your MediaCMS uses HTTP)
|
||||
|
||||
### For End Users (Students/Teachers)
|
||||
|
||||
No action required! When viewing content with MediaCMS URLs:
|
||||
1. Page loads normally
|
||||
2. Video player appears in an iframe
|
||||
3. LTI authentication happens transparently in the background
|
||||
4. Video starts playing (if user has permission)
|
||||
|
||||
**Note**: Users must be logged into Moodle. Guest users will see the original URL without embedding.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### URLs are not being converted
|
||||
|
||||
**Check**:
|
||||
1. Filter is enabled: **Site admin → Plugins → Filters → Manage filters**
|
||||
2. MediaCMS URL in settings matches the URLs you're pasting
|
||||
3. LTI tool is selected in filter settings
|
||||
4. User is logged in (not guest)
|
||||
|
||||
### Video shows "Access Denied" error
|
||||
|
||||
**Possible causes**:
|
||||
1. LTI tool not configured correctly
|
||||
2. MediaCMS not receiving proper authentication
|
||||
3. User doesn't have permission to view the video in MediaCMS
|
||||
|
||||
**Debug**:
|
||||
- Check Moodle logs: **Site admin → Reports → Logs**
|
||||
- Check MediaCMS LTI logs on the MediaCMS admin panel
|
||||
- Verify LTI tool configuration (client_id, issuer, etc.)
|
||||
|
||||
### Iframe shows blank or loading forever
|
||||
|
||||
**Check**:
|
||||
1. MediaCMS URL is accessible from your network
|
||||
2. Browser console for JavaScript errors
|
||||
3. LTI tool ID is correct
|
||||
4. MediaCMS OIDC login endpoint is working: `https://deic.mediacms.io/lti/oidc/login/`
|
||||
|
||||
### Multiple iframes from same URL
|
||||
|
||||
The filter replaces ALL occurrences of MediaCMS URLs. If you paste the same URL twice, you'll get two embedded players.
|
||||
|
||||
**Solution**: Paste the URL only once per page, or use HTML mode to add the URL as plain text (wrap in `<code>` tags).
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How the Filter Works
|
||||
|
||||
1. **Text Processing**: Filter scans all text content using regex patterns
|
||||
2. **URL Extraction**: Identifies MediaCMS URLs and extracts video tokens
|
||||
3. **LTI Configuration**: Retrieves LTI settings (issuer, client_id) from configured tool
|
||||
4. **HTML Generation**: Creates:
|
||||
- An `<iframe>` element with unique ID
|
||||
- A hidden `<form>` that posts to MediaCMS OIDC login endpoint
|
||||
- Form includes: `iss`, `client_id`, `login_hint`, `target_link_uri`
|
||||
- JavaScript to auto-submit the form on page load
|
||||
5. **LTI Flow**: Form submission triggers LTI 1.3 authentication:
|
||||
- OIDC Login → Redirect to Moodle Auth → POST back with JWT → Session created
|
||||
6. **Video Display**: MediaCMS redirects to video player inside iframe
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- **Authentication Required**: Filter only works for logged-in users
|
||||
- **LTI 1.3 Security**: Uses OAuth2/OIDC flow with JWT validation
|
||||
- **User Context**: Each iframe uses the current user's Moodle ID as `login_hint`
|
||||
- **No Credentials Stored**: Filter doesn't store user credentials or tokens
|
||||
- **Content Security**: Iframes are scoped to MediaCMS domain
|
||||
|
||||
### Performance
|
||||
|
||||
- **Lightweight**: Regex-based URL detection is fast
|
||||
- **No Database Queries**: Uses cached configuration from Moodle settings
|
||||
- **Lazy Loading**: Videos load on-demand when iframe initiates LTI flow
|
||||
|
||||
## Uninstallation
|
||||
|
||||
1. Go to **Site administration → Plugins → Filters → Manage filters**
|
||||
2. Disable the filter first
|
||||
3. Go to **Site administration → Plugins → Plugins overview**
|
||||
4. Find "MediaCMS LTI Filter"
|
||||
5. Click "Uninstall"
|
||||
6. Confirm uninstallation
|
||||
|
||||
**Note**: Existing MediaCMS URLs will revert to plain text URLs after uninstallation.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check MediaCMS documentation: https://docs.mediacms.io
|
||||
- Report bugs on GitHub: https://github.com/mediacms-io/mediacms
|
||||
- Moodle plugin directory: (link when published)
|
||||
|
||||
## License
|
||||
|
||||
This plugin is licensed under the GNU GPL v3 or later.
|
||||
|
||||
Copyright (C) 2026 MediaCMS
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.0 (2026-01-23)
|
||||
- Initial release
|
||||
- Support for Moodle 5.0+
|
||||
- Automatic URL detection and embedding
|
||||
- LTI 1.3 authentication integration
|
||||
- Configurable iframe dimensions
|
||||
- Multi-language support (English)
|
||||
|
||||
## Credits
|
||||
|
||||
Developed by the MediaCMS team.
|
||||
|
||||
---
|
||||
|
||||
**Enjoy seamless MediaCMS video embedding in Moodle!**
|
||||
39
moodle-plugins/filter_mediacmslti/auth_callback.php
Normal file
39
moodle-plugins/filter_mediacmslti/auth_callback.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* LTI Auth Callback for Filter Launches
|
||||
*
|
||||
* This handles the OIDC redirect from MediaCMS for filter-initiated launches
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/locallib.php');
|
||||
|
||||
// This endpoint receives the response from Moodle's /mod/lti/auth.php
|
||||
// after it completes the OIDC flow
|
||||
|
||||
// Get launch parameters from query string
|
||||
$state = optional_param('state', '', PARAM_RAW);
|
||||
$id_token = optional_param('id_token', '', PARAM_RAW);
|
||||
|
||||
if (empty($id_token)) {
|
||||
die('Missing id_token');
|
||||
}
|
||||
|
||||
// Verify and decode the id_token
|
||||
// Then redirect to the MediaCMS embed
|
||||
|
||||
$PAGE->set_context(context_system::instance());
|
||||
$PAGE->set_pagelayout('embedded');
|
||||
|
||||
echo $OUTPUT->header();
|
||||
echo '<div style="padding: 20px;">';
|
||||
echo '<p>Processing authentication...</p>';
|
||||
echo '<p>ID Token received, redirecting to content...</p>';
|
||||
echo '</div>';
|
||||
echo $OUTPUT->footer();
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Privacy provider for MediaCMS LTI Filter.
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace filter_mediacmslti\privacy;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Privacy provider implementation for MediaCMS LTI Filter.
|
||||
*
|
||||
* This plugin does not store any personal data.
|
||||
*/
|
||||
class provider implements \core_privacy\local\metadata\null_provider {
|
||||
|
||||
/**
|
||||
* Get the language string identifier with the component's language
|
||||
* file to explain why this plugin stores no data.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_reason(): string {
|
||||
return 'privacy:metadata';
|
||||
}
|
||||
}
|
||||
136
moodle-plugins/filter_mediacmslti/classes/text_filter.php
Normal file
136
moodle-plugins/filter_mediacmslti/classes/text_filter.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* MediaCMS LTI Filter - Converts MediaCMS URLs to LTI-authenticated iframes.
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
namespace filter_mediacmslti;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Filter class for converting MediaCMS URLs to LTI iframes.
|
||||
*/
|
||||
class text_filter extends \core_filters\text_filter {
|
||||
|
||||
/**
|
||||
* Apply the filter to the given text.
|
||||
*
|
||||
* @param string $text The text to filter
|
||||
* @param array $options Filter options
|
||||
* @return string The filtered text
|
||||
*/
|
||||
public function filter($text, array $options = array()) {
|
||||
global $USER, $CFG, $PAGE;
|
||||
|
||||
// Don't process if user is not logged in.
|
||||
if (!isloggedin() || isguestuser()) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
// Get plugin configuration.
|
||||
$mediacmsurl = get_config('filter_mediacmslti', 'mediacmsurl');
|
||||
$ltitoolid = get_config('filter_mediacmslti', 'ltitoolid');
|
||||
$iframewidth = get_config('filter_mediacmslti', 'iframewidth') ?: 960;
|
||||
$iframeheight = get_config('filter_mediacmslti', 'iframeheight') ?: 540;
|
||||
|
||||
if (empty($mediacmsurl) || empty($ltitoolid)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
// Parse the MediaCMS URL to get the base domain.
|
||||
$parsedurl = parse_url($mediacmsurl);
|
||||
if (!isset($parsedurl['host'])) {
|
||||
return $text;
|
||||
}
|
||||
$domain = $parsedurl['host'];
|
||||
|
||||
// Escape special regex characters in domain.
|
||||
$escapeddomain = preg_quote($domain, '/');
|
||||
|
||||
// Pattern to match MediaCMS video URLs:
|
||||
// - https://lti.mediacms.io/view?m=TOKEN
|
||||
// - https://lti.mediacms.io/embed?m=TOKEN
|
||||
// - http versions
|
||||
// Improved regex to handle parameters in any order
|
||||
$pattern = '/https?:\/\/' . $escapeddomain . '\/(view|embed)\?(?:[^"\s]*&)?m=([a-zA-Z0-9_-]+)(?:&[^"\s]*)?/i';
|
||||
|
||||
// Find all matches.
|
||||
if (!preg_match_all($pattern, $text, $matches, PREG_SET_ORDER)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
// Get course context
|
||||
$context = isset($options['context']) ? $options['context'] : $PAGE->context;
|
||||
$courseid = 0;
|
||||
|
||||
// Try to determine course ID from context
|
||||
if ($context) {
|
||||
if ($context->contextlevel == CONTEXT_COURSE) {
|
||||
$courseid = $context->instanceid;
|
||||
} else if ($context->contextlevel == CONTEXT_MODULE) {
|
||||
$cm = get_coursemodule_from_id('', $context->instanceid);
|
||||
if ($cm) {
|
||||
$courseid = $cm->course;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace each match with an iframe pointing to launch.php
|
||||
foreach ($matches as $match) {
|
||||
$fullurl = $match[0];
|
||||
$mediatoken = $match[2];
|
||||
|
||||
// Build launch URL with parameters (like Kaltura does)
|
||||
$launchurl = new \moodle_url('/filter/mediacmslti/launch.php', [
|
||||
'token' => $mediatoken,
|
||||
'courseid' => $courseid,
|
||||
'width' => $iframewidth,
|
||||
'height' => $iframeheight
|
||||
]);
|
||||
|
||||
// Calculate aspect ratio percentage for responsive container
|
||||
$ratio = ($iframeheight / $iframewidth) * 100;
|
||||
|
||||
// Generate iframe (responsive)
|
||||
$iframe = \html_writer::tag('iframe', '', array(
|
||||
'width' => '100%',
|
||||
'height' => '100%',
|
||||
'class' => 'mediacms-player-iframe',
|
||||
'allowfullscreen' => 'true',
|
||||
'allow' => 'autoplay *; fullscreen *; encrypted-media *; camera *; microphone *; display-capture *;',
|
||||
'src' => $launchurl->out(false),
|
||||
'frameborder' => '0',
|
||||
'style' => 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;',
|
||||
'title' => 'MediaCMS Video'
|
||||
));
|
||||
|
||||
$iframeContainer = \html_writer::tag('div', $iframe, array(
|
||||
'class' => 'mediacms-player-container',
|
||||
'style' => 'position: relative; padding-bottom: ' . $ratio . '%; height: 0; overflow: hidden; max-width: 100%; background: #000; border-radius: 4px;'
|
||||
));
|
||||
|
||||
$text = str_replace($fullurl, $iframeContainer, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Language strings for MediaCMS LTI Filter plugin.
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$string['pluginname'] = 'MediaCMS LTI Filter';
|
||||
$string['filtername'] = 'MediaCMS LTI Embed';
|
||||
|
||||
// Settings.
|
||||
$string['mediacmsurl'] = 'MediaCMS URL';
|
||||
$string['mediacmsurl_desc'] = 'The base URL of your MediaCMS instance (e.g., https://deic.mediacms.io). URLs from this domain will be converted to LTI-authenticated iframes.';
|
||||
|
||||
$string['ltitoolid'] = 'LTI External Tool';
|
||||
$string['ltitoolid_desc'] = 'Select the LTI External Tool that is configured for MediaCMS integration. This tool must be pre-configured with the correct LTI settings.';
|
||||
|
||||
$string['noltitoolsfound'] = 'No LTI tools configured';
|
||||
|
||||
$string['iframewidth'] = 'Iframe Width';
|
||||
$string['iframewidth_desc'] = 'Default width for embedded video iframes in pixels (default: 960).';
|
||||
|
||||
$string['iframeheight'] = 'Iframe Height';
|
||||
$string['iframeheight_desc'] = 'Default height for embedded video iframes in pixels (default: 540).';
|
||||
|
||||
$string['enablefilterheading'] = 'Enable the Filter';
|
||||
$string['enablefilterheading_desc'] = 'After configuring the settings above, you must enable this filter:<br><br>
|
||||
<ol>
|
||||
<li>Go to <strong>Site administration → Plugins → Filters → Manage filters</strong></li>
|
||||
<li>Find "MediaCMS LTI Embed" in the list</li>
|
||||
<li>Change the setting from "Disabled" to "On" or "Off, but available"</li>
|
||||
<li>Click "Save changes"</li>
|
||||
</ol>
|
||||
<br>
|
||||
Once enabled, any MediaCMS video URL pasted into course descriptions, page content, or other text areas will automatically be converted to an embedded video player with LTI authentication.';
|
||||
|
||||
// Privacy.
|
||||
$string['privacy:metadata'] = 'The MediaCMS LTI Filter plugin does not store any personal data. It processes URLs in content and initiates LTI authentication using the user\'s Moodle ID as the login hint.';
|
||||
90
moodle-plugins/filter_mediacmslti/launch.php
Normal file
90
moodle-plugins/filter_mediacmslti/launch.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* LTI Launch for MediaCMS Filter - Uses Moodle's LTI libraries like Kaltura
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/lib.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/locallib.php');
|
||||
|
||||
global $SITE;
|
||||
|
||||
require_login();
|
||||
|
||||
$mediatoken = required_param('token', PARAM_ALPHANUMEXT);
|
||||
$courseid = optional_param('courseid', 0, PARAM_INT);
|
||||
$height = optional_param('height', 540, PARAM_INT);
|
||||
$width = optional_param('width', 960, PARAM_INT);
|
||||
|
||||
// Get filter configuration
|
||||
$mediacmsurl = get_config('filter_mediacmslti', 'mediacmsurl');
|
||||
$ltitoolid = get_config('filter_mediacmslti', 'ltitoolid');
|
||||
|
||||
if (empty($mediacmsurl) || empty($ltitoolid)) {
|
||||
die('Filter not configured');
|
||||
}
|
||||
|
||||
// Get the LTI tool type
|
||||
$type = $DB->get_record('lti_types', ['id' => $ltitoolid]);
|
||||
if (!$type) {
|
||||
die('LTI tool not found');
|
||||
}
|
||||
|
||||
// Set up context - if courseid is 0, use system context
|
||||
if (0 != $courseid) {
|
||||
$context = context_course::instance($courseid);
|
||||
$course = get_course($courseid);
|
||||
} else {
|
||||
$context = context_system::instance();
|
||||
$course = $SITE;
|
||||
}
|
||||
|
||||
// Set up page
|
||||
$PAGE->set_url(new moodle_url('/filter/mediacmslti/launch.php', [
|
||||
'token' => $mediatoken,
|
||||
'courseid' => $courseid,
|
||||
'width' => $width,
|
||||
'height' => $height
|
||||
]));
|
||||
$PAGE->set_context($context);
|
||||
$PAGE->set_pagelayout('embedded');
|
||||
|
||||
// Create a dummy LTI instance object (like Kaltura does)
|
||||
$instance = new stdClass();
|
||||
$instance->id = 0; // Dummy ID - not a real activity
|
||||
$instance->course = $course->id;
|
||||
$instance->typeid = $ltitoolid;
|
||||
$instance->name = 'MediaCMS video resource';
|
||||
$instance->instructorchoiceacceptgrades = 0;
|
||||
$instance->grade = 0;
|
||||
$instance->instructorchoicesendname = 1;
|
||||
$instance->instructorchoicesendemailaddr = 1;
|
||||
$instance->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
|
||||
|
||||
// Set custom parameters to pass media token (like deep linking does)
|
||||
// This will be included in the LTI custom claims JWT
|
||||
$instance->instructorcustomparameters = "media_friendly_token=" . $mediatoken;
|
||||
|
||||
// Get type config (standard tool URL, no modifications needed)
|
||||
$typeconfig = lti_get_type_type_config($ltitoolid);
|
||||
|
||||
// Use Moodle's LTI launch function to initiate OIDC properly
|
||||
// Pass 0 as dummy cmid since we don't have a real course module
|
||||
$content = lti_initiate_login($course->id, 0, $instance, $typeconfig, null, 'MediaCMS video resource');
|
||||
|
||||
// Inject media_token as a hidden field in the OIDC login form
|
||||
// This allows MediaCMS to receive and store it in the state parameter
|
||||
$hidden_field = '<input type="hidden" name="media_token" value="' . htmlspecialchars($mediatoken, ENT_QUOTES) . '" />';
|
||||
|
||||
// Insert the hidden field before the closing </form> tag
|
||||
$content = str_replace('</form>', $hidden_field . '</form>', $content);
|
||||
|
||||
echo $OUTPUT->header();
|
||||
echo $content;
|
||||
echo $OUTPUT->footer();
|
||||
82
moodle-plugins/filter_mediacmslti/settings.php
Normal file
82
moodle-plugins/filter_mediacmslti/settings.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Settings for MediaCMS LTI Filter plugin.
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
if ($ADMIN->fulltree) {
|
||||
// MediaCMS URL setting.
|
||||
$settings->add(new admin_setting_configtext(
|
||||
'filter_mediacmslti/mediacmsurl',
|
||||
get_string('mediacmsurl', 'filter_mediacmslti'),
|
||||
get_string('mediacmsurl_desc', 'filter_mediacmslti'),
|
||||
'https://deic.mediacms.io',
|
||||
PARAM_URL
|
||||
));
|
||||
|
||||
// Get list of LTI tools for dropdown.
|
||||
$ltioptions = [];
|
||||
try {
|
||||
$tools = $DB->get_records('lti_types', null, 'name ASC', 'id, name');
|
||||
foreach ($tools as $tool) {
|
||||
$ltioptions[$tool->id] = $tool->name;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Database not ready yet or no tools configured.
|
||||
$ltioptions[0] = get_string('noltitoolsfound', 'filter_mediacmslti');
|
||||
}
|
||||
|
||||
// LTI Tool ID setting.
|
||||
$settings->add(new admin_setting_configselect(
|
||||
'filter_mediacmslti/ltitoolid',
|
||||
get_string('ltitoolid', 'filter_mediacmslti'),
|
||||
get_string('ltitoolid_desc', 'filter_mediacmslti'),
|
||||
0,
|
||||
$ltioptions
|
||||
));
|
||||
|
||||
// Iframe width setting.
|
||||
$settings->add(new admin_setting_configtext(
|
||||
'filter_mediacmslti/iframewidth',
|
||||
get_string('iframewidth', 'filter_mediacmslti'),
|
||||
get_string('iframewidth_desc', 'filter_mediacmslti'),
|
||||
'960',
|
||||
PARAM_INT
|
||||
));
|
||||
|
||||
// Iframe height setting.
|
||||
$settings->add(new admin_setting_configtext(
|
||||
'filter_mediacmslti/iframeheight',
|
||||
get_string('iframeheight', 'filter_mediacmslti'),
|
||||
get_string('iframeheight_desc', 'filter_mediacmslti'),
|
||||
'540',
|
||||
PARAM_INT
|
||||
));
|
||||
|
||||
// Information about enabling the filter.
|
||||
$settings->add(new admin_setting_heading(
|
||||
'filter_mediacmslti/enablefilterheading',
|
||||
get_string('enablefilterheading', 'filter_mediacmslti'),
|
||||
get_string('enablefilterheading_desc', 'filter_mediacmslti')
|
||||
));
|
||||
}
|
||||
34
moodle-plugins/filter_mediacmslti/test.php
Normal file
34
moodle-plugins/filter_mediacmslti/test.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
echo "Test 1: PHP works<br>";
|
||||
|
||||
echo "Testing path: " . __DIR__ . '<br>';
|
||||
|
||||
// Try the path with 4 parent dirs (current version)
|
||||
if (file_exists(__DIR__ . '/../../../../config.php')) {
|
||||
echo "Found with ../../../../<br>";
|
||||
require_once(__DIR__ . '/../../../../config.php');
|
||||
}
|
||||
// Try with 2 parent dirs (correct for filter location)
|
||||
else if (file_exists(__DIR__ . '/../../config.php')) {
|
||||
echo "Found with ../../<br>";
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
}
|
||||
else {
|
||||
die("Cannot find config.php. Tried: <br>" .
|
||||
__DIR__ . '/../../../../config.php<br>' .
|
||||
__DIR__ . '/../../config.php');
|
||||
}
|
||||
|
||||
echo "Test 2: Moodle config loaded<br>";
|
||||
|
||||
echo "Test 3: Moodle loaded successfully<br>";
|
||||
echo "wwwroot: " . $CFG->wwwroot . "<br>";
|
||||
|
||||
try {
|
||||
require_login();
|
||||
echo "Test 4: User logged in: " . $USER->id . "<br>";
|
||||
} catch (Exception $e) {
|
||||
die("Login error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
echo "Test 5: All OK!";
|
||||
31
moodle-plugins/filter_mediacmslti/version.php
Normal file
31
moodle-plugins/filter_mediacmslti/version.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Version information for MediaCMS LTI Filter plugin.
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->version = 2026013000; // The current plugin version (Date: YYYYMMDDXX).
|
||||
$plugin->requires = 2024100700; // Requires Moodle 5.0 or later.
|
||||
$plugin->component = 'filter_mediacmslti'; // Full name of the plugin (used for diagnostics).
|
||||
$plugin->maturity = MATURITY_STABLE;
|
||||
$plugin->release = 'v1.1.5';
|
||||
@ -2,8 +2,10 @@ Django==5.2.6
|
||||
djangorestframework==3.16.1
|
||||
python3-saml==1.16.0
|
||||
django-allauth==65.4.1
|
||||
PyLTI1p3==2.0.0
|
||||
cryptography>=41.0.0
|
||||
psycopg[binary,pool]==3.2.4
|
||||
uwsgi==2.0.28
|
||||
gunicorn==23.0.0
|
||||
django-redis==5.4.0
|
||||
celery==5.4.0
|
||||
drf-yasg==1.21.8
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
!function(){"use strict";var n,e={4256:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every(function(n){return t.O[n](r[c])})?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=594,function(){var n={594:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some(function(e){return 0!==n[e]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],function(){return t(4256)});o=t.O(o)}();
|
||||
!function(){"use strict";var n,e={4256:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every((function(n){return t.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=594,function(){var n={594:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(e){return 0!==n[e]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],(function(){return t(4256)}));o=t.O(o)}();
|
||||
@ -1 +1 @@
|
||||
!function(){"use strict";var n,e={5879:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every(function(n){return t.O[n](r[c])})?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=543,function(){var n={543:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some(function(e){return 0!==n[e]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],function(){return t(5879)});o=t.O(o)}();
|
||||
!function(){"use strict";var n,e={5879:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every((function(n){return t.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=543,function(){var n={543:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(e){return 0!==n[e]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],(function(){return t(5879)}));o=t.O(o)}();
|
||||
@ -1 +1 @@
|
||||
!function(){"use strict";var n,e={1684:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every(function(n){return t.O[n](r[c])})?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=152,function(){var n={152:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some(function(e){return 0!==n[e]})){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],function(){return t(1684)});o=t.O(o)}();
|
||||
!function(){"use strict";var n,e={1684:function(n,e,r){(0,r(2985).C)()}},r={};function t(n){var o=r[n];if(void 0!==o)return o.exports;var i=r[n]={exports:{}};return e[n].call(i.exports,i,i.exports,t),i.exports}t.m=e,n=[],t.O=function(e,r,o,i){if(!r){var u=1/0;for(l=0;l<n.length;l++){r=n[l][0],o=n[l][1],i=n[l][2];for(var f=!0,c=0;c<r.length;c++)(!1&i||u>=i)&&Object.keys(t.O).every((function(n){return t.O[n](r[c])}))?r.splice(c--,1):(f=!1,i<u&&(u=i));if(f){n.splice(l--,1);var a=o();void 0!==a&&(e=a)}}return e}i=i||0;for(var l=n.length;l>0&&n[l-1][2]>i;l--)n[l]=n[l-1];n[l]=[r,o,i]},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,{a:e}),e},t.d=function(n,e){for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(n){if("object"==typeof window)return window}}(),t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.j=152,function(){var n={152:0};t.O.j=function(e){return 0===n[e]};var e=function(e,r){var o,i,u=r[0],f=r[1],c=r[2],a=0;if(u.some((function(e){return 0!==n[e]}))){for(o in f)t.o(f,o)&&(t.m[o]=f[o]);if(c)var l=c(t)}for(e&&e(r);a<u.length;a++)i=u[a],t.o(n,i)&&n[i]&&n[i][0](),n[i]=0;return t.O(l)},r=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];r.forEach(e.bind(null,0)),r.push=e.bind(null,r.push.bind(r))}();var o=t.O(void 0,[276],(function(){return t(1684)}));o=t.O(o)}();
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
!function(){"use strict";var t,e={282:function(t,e,n){var r=n(2985),o=n(9471),i=n(8713),s=n.n(i),a=n(8790),u=n(285),l=n(2855),c=n(9835),m=n(9479);function f(t,e,n){return t+"?"+e+(""===e?"":"&")+"page="+n}class h extends m.Y{constructor(t){super(t,"manage-comments"),this.state={resultsCount:null,requestUrl:a.ApiUrlContext._currentValue.manage.comments,currentPage:1,sortingArgs:"",sortBy:"add_date",ordering:"desc",refresh:0},this.getCountFunc=this.getCountFunc.bind(this),this.onTablePageChange=this.onTablePageChange.bind(this),this.onColumnSortClick=this.onColumnSortClick.bind(this),this.onItemsRemoval=this.onItemsRemoval.bind(this),this.onItemsRemovalFail=this.onItemsRemovalFail.bind(this)}onTablePageChange(t,e){this.setState({currentPage:e,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,this.state.sortingArgs,e)})}getCountFunc(t){this.setState({resultsCount:t})}onColumnSortClick(t,e){const n="sort_by="+t+"&ordering="+e;this.setState({sortBy:t,ordering:e,sortingArgs:n,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,n,this.state.currentPage)})}onItemsRemoval(t){this.setState({resultsCount:null,refresh:this.state.refresh+1,requestUrl:a.ApiUrlContext._currentValue.manage.comments},function(){t?u.PageActions.addNotification("The comments deleted successfully.","commentsRemovalSucceed"):u.PageActions.addNotification("The comment deleted successfully.","commentRemovalSucceed")})}onItemsRemovalFail(t){t?u.PageActions.addNotification("The comments removal failed. Please try again.","commentsRemovalFailed"):u.PageActions.addNotification("The comment removal failed. Please try again.","commentRemovalFailed")}pageContent(){return o.createElement(l.MediaListWrapper,{title:this.props.title+(null===this.state.resultsCount?"":" ("+this.state.resultsCount+")"),className:"search-results-wrap items-list-hor"},o.createElement(c.D,{pageItems:50,manageType:"comments",key:this.state.requestUrl+"["+this.state.refresh+"]",itemsCountCallback:this.getCountFunc,requestUrl:this.state.requestUrl,onPageChange:this.onTablePageChange,sortBy:this.state.sortBy,ordering:this.state.ordering,onRowsDelete:this.onItemsRemoval,onRowsDeleteFail:this.onItemsRemovalFail,onClickColumnSort:this.onColumnSortClick}))}}h.propTypes={title:s().string.isRequired},h.defaultProps={title:"Manage comments"},(0,r.C)("page-manage-comments",h)},7664:function(t,e,n){n.r(e),n.d(e,{CircleIconButton:function(){return r.i},FilterOptions:function(){return o.P},FiltersToggleButton:function(){return i.I},MaterialIcon:function(){return s.Z},NavigationContentApp:function(){return a.V},NavigationMenuList:function(){return u.S},Notifications:function(){return l.$},NumericInputWithUnit:function(){return c._},PopupMain:function(){return m.AP},PopupTop:function(){return m.cp},SpinnerLoader:function(){return f.x},UserThumbnail:function(){return h.c}});var r=n(5321),o=n(7256),i=n(3135),s=n(2828),a=n(5305),u=n(7201),l=n(6089),c=n(3818),m=n(2901),f=n(6568),h=n(878)}},n={};function r(t){var o=n[t];if(void 0!==o)return o.exports;var i=n[t]={exports:{}};return e[t].call(i.exports,i,i.exports,r),i.exports}r.m=e,t=[],r.O=function(e,n,o,i){if(!n){var s=1/0;for(c=0;c<t.length;c++){n=t[c][0],o=t[c][1],i=t[c][2];for(var a=!0,u=0;u<n.length;u++)(!1&i||s>=i)&&Object.keys(r.O).every(function(t){return r.O[t](n[u])})?n.splice(u--,1):(a=!1,i<s&&(s=i));if(a){t.splice(c--,1);var l=o();void 0!==l&&(e=l)}}return e}i=i||0;for(var c=t.length;c>0&&t[c-1][2]>i;c--)t[c]=t[c-1];t[c]=[n,o,i]},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,{a:e}),e},r.d=function(t,e){for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.j=401,function(){var t={401:0};r.O.j=function(e){return 0===t[e]};var e=function(e,n){var o,i,s=n[0],a=n[1],u=n[2],l=0;if(s.some(function(e){return 0!==t[e]})){for(o in a)r.o(a,o)&&(r.m[o]=a[o]);if(u)var c=u(r)}for(e&&e(n);l<s.length;l++)i=s[l],r.o(t,i)&&t[i]&&t[i][0](),t[i]=0;return r.O(c)},n=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];n.forEach(e.bind(null,0)),n.push=e.bind(null,n.push.bind(n))}();var o=r.O(void 0,[276],function(){return r(282)});o=r.O(o)}();
|
||||
!function(){"use strict";var t,e={282:function(t,e,n){var r=n(2985),o=n(9471),i=n(8713),s=n.n(i),a=n(8790),u=n(285),l=n(2855),c=n(9835),m=n(9479);function f(t,e,n){return t+"?"+e+(""===e?"":"&")+"page="+n}class h extends m.Y{constructor(t){super(t,"manage-comments"),this.state={resultsCount:null,requestUrl:a.ApiUrlContext._currentValue.manage.comments,currentPage:1,sortingArgs:"",sortBy:"add_date",ordering:"desc",refresh:0},this.getCountFunc=this.getCountFunc.bind(this),this.onTablePageChange=this.onTablePageChange.bind(this),this.onColumnSortClick=this.onColumnSortClick.bind(this),this.onItemsRemoval=this.onItemsRemoval.bind(this),this.onItemsRemovalFail=this.onItemsRemovalFail.bind(this)}onTablePageChange(t,e){this.setState({currentPage:e,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,this.state.sortingArgs,e)})}getCountFunc(t){this.setState({resultsCount:t})}onColumnSortClick(t,e){const n="sort_by="+t+"&ordering="+e;this.setState({sortBy:t,ordering:e,sortingArgs:n,requestUrl:f(a.ApiUrlContext._currentValue.manage.comments,n,this.state.currentPage)})}onItemsRemoval(t){this.setState({resultsCount:null,refresh:this.state.refresh+1,requestUrl:a.ApiUrlContext._currentValue.manage.comments},(function(){t?u.PageActions.addNotification("The comments deleted successfully.","commentsRemovalSucceed"):u.PageActions.addNotification("The comment deleted successfully.","commentRemovalSucceed")}))}onItemsRemovalFail(t){t?u.PageActions.addNotification("The comments removal failed. Please try again.","commentsRemovalFailed"):u.PageActions.addNotification("The comment removal failed. Please try again.","commentRemovalFailed")}pageContent(){return o.createElement(l.MediaListWrapper,{title:this.props.title+(null===this.state.resultsCount?"":" ("+this.state.resultsCount+")"),className:"search-results-wrap items-list-hor"},o.createElement(c.D,{pageItems:50,manageType:"comments",key:this.state.requestUrl+"["+this.state.refresh+"]",itemsCountCallback:this.getCountFunc,requestUrl:this.state.requestUrl,onPageChange:this.onTablePageChange,sortBy:this.state.sortBy,ordering:this.state.ordering,onRowsDelete:this.onItemsRemoval,onRowsDeleteFail:this.onItemsRemovalFail,onClickColumnSort:this.onColumnSortClick}))}}h.propTypes={title:s().string.isRequired},h.defaultProps={title:"Manage comments"},(0,r.C)("page-manage-comments",h)},7664:function(t,e,n){n.r(e),n.d(e,{CircleIconButton:function(){return r.i},FilterOptions:function(){return o.P},FiltersToggleButton:function(){return i.I},MaterialIcon:function(){return s.Z},NavigationContentApp:function(){return a.V},NavigationMenuList:function(){return u.S},Notifications:function(){return l.$},NumericInputWithUnit:function(){return c._},PopupMain:function(){return m.AP},PopupTop:function(){return m.cp},SpinnerLoader:function(){return f.x},UserThumbnail:function(){return h.c}});var r=n(5321),o=n(7256),i=n(3135),s=n(2828),a=n(5305),u=n(7201),l=n(6089),c=n(3818),m=n(2901),f=n(6568),h=n(878)}},n={};function r(t){var o=n[t];if(void 0!==o)return o.exports;var i=n[t]={exports:{}};return e[t].call(i.exports,i,i.exports,r),i.exports}r.m=e,t=[],r.O=function(e,n,o,i){if(!n){var s=1/0;for(c=0;c<t.length;c++){n=t[c][0],o=t[c][1],i=t[c][2];for(var a=!0,u=0;u<n.length;u++)(!1&i||s>=i)&&Object.keys(r.O).every((function(t){return r.O[t](n[u])}))?n.splice(u--,1):(a=!1,i<s&&(s=i));if(a){t.splice(c--,1);var l=o();void 0!==l&&(e=l)}}return e}i=i||0;for(var c=t.length;c>0&&t[c-1][2]>i;c--)t[c]=t[c-1];t[c]=[n,o,i]},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,{a:e}),e},r.d=function(t,e){for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.j=401,function(){var t={401:0};r.O.j=function(e){return 0===t[e]};var e=function(e,n){var o,i,s=n[0],a=n[1],u=n[2],l=0;if(s.some((function(e){return 0!==t[e]}))){for(o in a)r.o(a,o)&&(r.m[o]=a[o]);if(u)var c=u(r)}for(e&&e(n);l<s.length;l++)i=s[l],r.o(t,i)&&t[i]&&t[i][0](),t[i]=0;return r.O(c)},n=self.webpackChunkmediacms_frontend=self.webpackChunkmediacms_frontend||[];n.forEach(e.bind(null,0)),n.push=e.bind(null,n.push.bind(n))}();var o=r.O(void 0,[276],(function(){return r(282)}));o=r.O(o)}();
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user