Compare commits

..

11 Commits

Author SHA1 Message Date
semantic-release-bot
b7427869b6 chore(release): 7.6.0 [skip ci]
## [7.6.0](https://github.com/mediacms-io/mediacms/compare/v7.5.0...v7.6.0) (2026-02-07)

### Features

* Create SECURITY.md ([#1485](https://github.com/mediacms-io/mediacms/issues/1485)) ([11449c2](11449c2187))
2026-02-07 10:31:40 +00:00
LabPixel
11449c2187 feat: Create SECURITY.md (#1485) 2026-02-07 12:31:10 +02:00
semantic-release-bot
f7c675596f chore(release): 7.5.0 [skip ci]
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)

### Features

* bump version ([36d815c](36d815c0cf))
2026-02-06 17:26:12 +00:00
Markos Gogoulos
36d815c0cf feat: bump version 2026-02-06 19:25:31 +02:00
semantic-release-bot
8f28b00a63 chore(release): 7.4.0 [skip ci]
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)

### Features

* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](74952f68d7))
2026-02-06 17:24:27 +00:00
Yiannis Christodoulou
74952f68d7 feat: Add video player context menu with share/embed options (#1472) 2026-02-06 19:23:51 +02:00
semantic-release-bot
7950a4655a chore(release): 7.3.0 [skip ci]
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)

### Features

* add package json for semantic release ([b405a04](b405a04e34))
* add semantic release github actions ([76a27ae](76a27ae256))
* frontend unit tests ([1c15880](1c15880ae3))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](223e87073f))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](d9b1d6cab1))
* semantic release ([b76282f](b76282f9e4))

### Bug Fixes

* add delay to task creation ([1b3cdfd](1b3cdfd302))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](90331f3b4a))
* adjust poster url for audio ([01912ea](01912ea1f9))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](cd7dd4f72c))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](5eb6fafb8c))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](ba2c31b1e6))

### Documentation

* update page link ([aeef828](aeef8284bf))
2026-02-06 16:56:49 +00:00
Markos Gogoulos
b76282f9e4 feat: semantic release 2026-02-06 18:56:13 +02:00
Markos Gogoulos
b405a04e34 feat: add package json for semantic release 2026-02-06 18:53:03 +02:00
Markos Gogoulos
76a27ae256 feat: add semantic release github actions 2026-02-06 18:40:50 +02:00
Markos Gogoulos
223e87073f feat: Implement persistent "Embed Mode" to hide UI shell via Session Storage (#1484)
* initial implementation

* updates in ViewerInfoVideoTitleBanner component

* Implement persistent "Embed Mode" to hide UI shell via Session Storage

---------

Co-authored-by: Yiannis <1515939+styiannis@users.noreply.github.com>
2026-01-31 15:27:40 +02:00
150 changed files with 14435 additions and 10515 deletions

View File

@@ -0,0 +1,22 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
environment: dev
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

47
.github/workflows/semantic-release.yaml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Semantic Release
on:
push:
branches:
- main
permissions:
contents: write
issues: write
jobs:
semantic-release:
runs-on: ubuntu-latest
environment: dev
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup SSH
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.GA_DEPLOY_KEY }}
# use SSH url to ensure git commit using a deploy key bypasses the main
# branch protection rule
- name: Configure Git for SSH Push
run: git remote set-url origin "git@github.com:${{ github.repository }}.git"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install Dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Run Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,3 +1,4 @@
/templates/cms/* /templates/cms/*
/templates/*.html /templates/*.html
*.scss *.scss
/frontend/

100
.releaserc.json Normal file
View File

@@ -0,0 +1,100 @@
{
"branches": [
"main"
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "chore",
"hidden": true
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "style",
"hidden": true
},
{
"type": "refactor",
"section": "Refactors"
},
{
"type": "perf",
"section": "Performance"
},
{
"type": "test",
"hidden": true
},
{
"type": "depr",
"section": "Deprecations"
}
]
}
}
],
[
"semantic-release-replace-plugin",
{
"replacements": [
{
"files": [
"package.json"
],
"from": "\"version\": \".*\"",
"to": "\"version\": \"${nextRelease.version}\"",
"results": [
{
"file": "package.json",
"hasChanged": true,
"numMatches": 1,
"numReplacements": 1
}
],
"countMatches": true
}
]
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md",
"changelogTitle": "# Changelog"
}
],
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}

43
CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# Changelog
## [7.6.0](https://github.com/mediacms-io/mediacms/compare/v7.5.0...v7.6.0) (2026-02-07)
### Features
* Create SECURITY.md ([#1485](https://github.com/mediacms-io/mediacms/issues/1485)) ([11449c2](https://github.com/mediacms-io/mediacms/commit/11449c2187d0f450b86915d88f92595a1825e4cf))
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)
### Features
* bump version ([36d815c](https://github.com/mediacms-io/mediacms/commit/36d815c0cfbe21d3136541d410d545742b9ebecd))
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)
### Features
* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](https://github.com/mediacms-io/mediacms/commit/74952f68d79bc67617edb38eac62d2f5e7457565))
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)
### Features
* add package json for semantic release ([b405a04](https://github.com/mediacms-io/mediacms/commit/b405a04e346ca81b7d3f4e099eb984e7785cdd0f))
* add semantic release github actions ([76a27ae](https://github.com/mediacms-io/mediacms/commit/76a27ae25609178c1bd47c947b9f1a082c791d61))
* frontend unit tests ([1c15880](https://github.com/mediacms-io/mediacms/commit/1c15880ae3ef1ce77f53d5b473dfc0cc448b4977))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](https://github.com/mediacms-io/mediacms/commit/223e87073f7d5e44130c9976854cac670db0ae66))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](https://github.com/mediacms-io/mediacms/commit/d9b1d6cab1d2bdfc16f799a0a27b64313e2e0d22))
* semantic release ([b76282f](https://github.com/mediacms-io/mediacms/commit/b76282f9e465a39c2da5e9a22184d1db23de3f56))
### Bug Fixes
* add delay to task creation ([1b3cdfd](https://github.com/mediacms-io/mediacms/commit/1b3cdfd302abc5e69ebe01ca52b5091f3b24c0b2))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](https://github.com/mediacms-io/mediacms/commit/90331f3b4a2a5737de9dd75ab45c096944813c42))
* adjust poster url for audio ([01912ea](https://github.com/mediacms-io/mediacms/commit/01912ea1f99ef43793a65712539d6264f1f6410f))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](https://github.com/mediacms-io/mediacms/commit/cd7dd4f72c9f0bac466c680f686a9ecfdd3a38dd))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](https://github.com/mediacms-io/mediacms/commit/5eb6fafb8c6928b8bc3fe5f0c7af315273f78a55))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](https://github.com/mediacms-io/mediacms/commit/ba2c31b1e65b7f508dee598b1f2d86f01f9bf036))
### Documentation
* update page link ([aeef828](https://github.com/mediacms-io/mediacms/commit/aeef8284bfba2a9a7f69c684f96c54f0e0e0cf92))

23
HISTORY.md Normal file
View File

@@ -0,0 +1,23 @@
# 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)

View File

@@ -1,253 +0,0 @@
# 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

View File

@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
## Technology ## Technology
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 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
## Who is using it ## Who is using it

54
SECURITY.md Normal file
View File

@@ -0,0 +1,54 @@
# Security Policy
Thank you for helping improve the security of MediaCMS.
We take security vulnerabilities seriously and appreciate responsible disclosure.
---
## Reporting a Vulnerability
If you discover a security vulnerability in MediaCMS, **please do not open a public GitHub issue**.
Instead, report it using one of the following methods:
- **GitHub Security Advisories (preferred)**
Use the "Report a vulnerability" feature in this repository.
- **Contact Form**
Submit details via the official contact page:
https://mediacms.io/contact/
Please include as much of the following information as possible:
- Affected version(s)
- Detailed description of the issue
- Steps to reproduce (PoC if available)
- Impact assessment (e.g. RCE, XSS, privilege escalation)
- Any potential mitigations you are aware of
---
## Supported Versions
Security updates are provided for the **latest stable release** of MediaCMS.
Older versions may not receive security patches.
---
## Disclosure Policy
- We aim to acknowledge reports within **7 days**
- We aim to provide a fix or mitigation within **90 days**, depending on severity
- Please allow us time to investigate before any public disclosure
We follow responsible disclosure practices and will coordinate disclosure timelines when appropriate.
---
## Recognition
At this time, MediaCMS does not operate a formal bug bounty program.
However, we are happy to acknowledge valid security reports in release notes or advisories (with your permission).
---
Thank you for helping keep MediaCMS secure.

View File

@@ -24,7 +24,6 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig", "actions.apps.ActionsConfig",
"rbac.apps.RbacConfig", "rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig", "identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar", "debug_toolbar",
"mptt", "mptt",
"crispy_forms", "crispy_forms",

View File

@@ -300,7 +300,6 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig", "actions.apps.ActionsConfig",
"rbac.apps.RbacConfig", "rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig", "identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar", "debug_toolbar",
"mptt", "mptt",
"crispy_forms", "crispy_forms",
@@ -556,7 +555,6 @@ DJANGO_ADMIN_URL = "admin/"
USE_SAML = False USE_SAML = False
USE_RBAC = False USE_RBAC = False
USE_IDENTITY_PROVIDERS = False USE_IDENTITY_PROVIDERS = False
USE_LTI = False # Enable LTI 1.3 integration
JAZZMIN_UI_TWEAKS = {"theme": "flatly"} JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
USE_ROUNDED_CORNERS = True USE_ROUNDED_CORNERS = True
@@ -652,19 +650,3 @@ if USERS_NEEDS_TO_BE_APPROVED:
) )
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware") 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

View File

@@ -25,7 +25,6 @@ urlpatterns = [
re_path(r"^", include("files.urls")), re_path(r"^", include("files.urls")),
re_path(r"^", include("users.urls")), re_path(r"^", include("users.urls")),
re_path(r"^accounts/", include("allauth.urls")), re_path(r"^accounts/", include("allauth.urls")),
re_path(r"^lti/", include("lti.urls")),
re_path(r"^api-auth/", include("rest_framework.urls")), re_path(r"^api-auth/", include("rest_framework.urls")),
path(settings.DJANGO_ADMIN_URL, admin.site.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'), re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),

View File

@@ -1 +1 @@
VERSION = "7.8124" VERSION = "7.5"

View File

@@ -1,9 +1,3 @@
# 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 { server {
listen 80 ; listen 80 ;
@@ -34,10 +28,7 @@ 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-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'; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
proxy_pass http://127.0.0.1:9000; include /etc/nginx/sites-enabled/uwsgi_params;
proxy_set_header Host $host; uwsgi_pass 127.0.0.1:9000;
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;
} }
} }

View File

@@ -37,6 +37,7 @@ fi
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default 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/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/ cp deploy/docker/nginx.conf /etc/nginx/
#### Supervisord Configurations ##### #### Supervisord Configurations #####
@@ -44,12 +45,12 @@ cp deploy/docker/nginx.conf /etc/nginx/
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
echo "Enabling gunicorn app server" echo "Enabling uwsgi app server"
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
fi fi
if [ X"$ENABLE_NGINX" = X"yes" ] ; then if [ X"$ENABLE_NGINX" = X"yes" ] ; then
echo "Enabling nginx as gunicorn app proxy and media server" echo "Enabling nginx as uwsgi app proxy and media server"
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
fi fi

View File

@@ -11,7 +11,7 @@ else
echo "There is no script $PRE_START_PATH" echo "There is no script $PRE_START_PATH"
fi fi
# Start Supervisor, with Nginx and Gunicorn # Start Supervisor, with Nginx and uWSGI
echo "Starting server using supervisord..." echo "Starting server using supervisord..."
exec /usr/bin/supervisord exec /usr/bin/supervisord

View File

@@ -1,9 +0,0 @@
[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

View File

@@ -0,0 +1,9 @@
[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

24
deploy/docker/uwsgi.ini Normal file
View File

@@ -0,0 +1,24 @@
[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

View File

@@ -0,0 +1,16 @@
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;

View File

@@ -0,0 +1,22 @@
[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

View File

@@ -0,0 +1,29 @@
[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

View File

@@ -0,0 +1,39 @@
[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

View File

@@ -0,0 +1,13 @@
-----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-----

View File

@@ -0,0 +1,84 @@
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;
}
}

View File

@@ -0,0 +1,58 @@
-----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-----

View File

@@ -0,0 +1,28 @@
-----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-----

View File

@@ -0,0 +1,13 @@
[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

View File

@@ -0,0 +1,7 @@
/home/mediacms.io/mediacms/logs/*.log {
weekly
missingok
rotate 7
compress
notifempty
}

View File

@@ -0,0 +1,38 @@
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/*;
}

View File

@@ -0,0 +1,34 @@
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 };

View File

@@ -0,0 +1,27 @@
[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

View File

@@ -0,0 +1,16 @@
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;

View File

@@ -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: For Django, the changes from the image produced by docker-compose.yaml are these:
* Django runs in debug mode, with `python manage.py runserver` * Django runs in debug mode, with `python manage.py runserver`
* gunicorn and nginx are not run * uwsgi and nginx are not run
* Django runs in Debug mode, with Debug Toolbar * Django runs in Debug mode, with Debug Toolbar
* Static files (js/css) are loaded from static/ folder * Static files (js/css) are loaded from static/ folder
* corsheaders is installed and configured to allow all origins * corsheaders is installed and configured to allow all origins

View File

@@ -65,7 +65,6 @@ class CategoryAdminForm(forms.ModelForm):
class Meta: class Meta:
model = Category model = Category
# LTI fields will be shown as read-only when USE_LTI is enabled
fields = '__all__' fields = '__all__'
def clean(self): def clean(self):
@@ -136,7 +135,7 @@ class CategoryAdmin(admin.ModelAdmin):
list_display = ["title", "user", "add_date", "media_count"] list_display = ["title", "user", "add_date", "media_count"]
list_filter = [] list_filter = []
ordering = ("-add_date",) ordering = ("-add_date",)
readonly_fields = ("user", "media_count", "lti_platform", "lti_context_id") readonly_fields = ("user", "media_count")
change_form_template = 'admin/files/category/change_form.html' change_form_template = 'admin/files/category/change_form.html'
def get_list_filter(self, request): def get_list_filter(self, request):
@@ -168,14 +167,6 @@ 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): if getattr(settings, 'USE_RBAC', False):
rbac_fieldset = [ rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}), ('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
@@ -186,9 +177,9 @@ class CategoryAdmin(admin.ModelAdmin):
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}), ('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'}), ('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
] ]
additional_fieldsets.extend(rbac_fieldset) return basic_fieldset + rbac_fieldset
else:
return basic_fieldset + additional_fieldsets return basic_fieldset
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):

View File

@@ -64,10 +64,4 @@ def stuff(request):
if request.user.is_superuser: if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL 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 return ret

View File

@@ -965,13 +965,3 @@ def get_alphanumeric_only(string):
""" """
string = "".join([char for char in string if char.isalnum()]) string = "".join([char for char in string if char.isalnum()])
return string.lower() 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

View File

@@ -1,22 +0,0 @@
# 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),
),
]

View File

@@ -1,21 +0,0 @@
# 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'
),
),
]

View File

@@ -47,13 +47,6 @@ class Category(models.Model):
verbose_name='IDP Config Name', 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): def __str__(self):
return self.title return self.title
@@ -144,7 +137,7 @@ class Tag(models.Model):
return True return True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.title = helpers.get_alphanumeric_and_spaces(self.title) self.title = helpers.get_alphanumeric_only(self.title)
self.title = self.title[:100] self.title = self.title[:100]
super(Tag, self).save(*args, **kwargs) super(Tag, self).save(*args, **kwargs)

View File

@@ -352,11 +352,20 @@ class Media(models.Model):
# first get anything interesting out of the media # first get anything interesting out of the media
# that needs to be search able # that needs to be search able
a_tags = "" a_tags = b_tags = ""
if self.id: if self.id:
a_tags = " ".join([tag.title for tag in self.tags.all()]) 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.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags] items = [
self.title,
self.user.username,
self.user.email,
self.user.name,
self.description,
a_tags,
b_tags,
]
for subtitle in self.subtitles.all(): for subtitle in self.subtitles.all():
items.append(subtitle.subtitle_text) items.append(subtitle.subtitle_text)

View File

@@ -80,7 +80,6 @@ urlpatterns = [
views.trim_video, views.trim_video,
), ),
re_path(r"^api/v1/categories$", views.CategoryList.as_view()), 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/tags$", views.TagList.as_view()),
re_path(r"^api/v1/comments$", views.CommentList.as_view()), re_path(r"^api/v1/comments$", views.CommentList.as_view()),
re_path( re_path(

View File

@@ -1,7 +1,7 @@
# Import all views for backward compatibility # Import all views for backward compatibility
from .auth import custom_login_view, saml_metadata # noqa: F401 from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401 from .categories import CategoryList, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401 from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401 from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
from .media import MediaActions # noqa: F401 from .media import MediaActions # noqa: F401

View File

@@ -43,40 +43,6 @@ class CategoryList(APIView):
return Response(ret) 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): class TagList(APIView):
"""List tags""" """List tags"""

View File

@@ -24,7 +24,7 @@ from ..forms import (
WhisperSubtitlesForm, WhisperSubtitlesForm,
) )
from ..frontend_translations import translate_string from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_and_spaces from ..helpers import get_alphanumeric_only
from ..methods import ( from ..methods import (
can_transcribe_video, can_transcribe_video,
create_video_trim_request, create_video_trim_request,
@@ -310,8 +310,8 @@ def edit_media(request):
media.tags.remove(tag) media.tags.remove(tag)
if form.cleaned_data.get("new_tags"): if form.cleaned_data.get("new_tags"):
for tag in form.cleaned_data.get("new_tags").split(","): for tag in form.cleaned_data.get("new_tags").split(","):
tag = get_alphanumeric_and_spaces(tag) tag = get_alphanumeric_only(tag)
tag = tag[:100] tag = tag[:99]
if tag: if tag:
try: try:
tag = Tag.objects.get(title=tag) tag = Tag.objects.get(title=tag)

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en" style="height: 100%">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded Video - Full Screen</title>
<style>
body {
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
overflow: hidden;
}
</style>
</head>
<body>
<iframe
src="https://demo.mediacms.io/embed?m=zK2nirNLC"
style="
width: 100%;
max-width: calc(100vh * 16 / 9);
aspect-ratio: 16 / 9;
display: block;
margin: auto;
border: 0;
"
allowfullscreen
></iframe>
</body>
</html>

View File

@@ -204,6 +204,54 @@ class SeekIndicator extends Component {
</div> </div>
`; `;
textEl.textContent = 'Pause'; textEl.textContent = 'Pause';
} else if (direction === 'copy-url') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} else if (direction === 'copy-embed') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M16 18l6-6-6-6"/>
<path d="M8 6l-6 6 6 6"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} }
// Clear any text content in the text element // Clear any text content in the text element
@@ -239,6 +287,11 @@ class SeekIndicator extends Component {
this.showTimeout = setTimeout(() => { this.showTimeout = setTimeout(() => {
this.hide(); this.hide();
}, 500); }, 500);
} else if (direction === 'copy-url' || direction === 'copy-embed') {
// Copy operations: 500ms (same as play/pause)
this.showTimeout = setTimeout(() => {
this.hide();
}, 500);
} }
} }

View File

@@ -14,10 +14,22 @@ class EmbedInfoOverlay extends Component {
this.authorThumbnail = options.authorThumbnail || ''; this.authorThumbnail = options.authorThumbnail || '';
this.videoTitle = options.videoTitle || 'Video'; this.videoTitle = options.videoTitle || 'Video';
this.videoUrl = options.videoUrl || ''; this.videoUrl = options.videoUrl || '';
this.showTitle = options.showTitle !== undefined ? options.showTitle : true;
this.showRelated = options.showRelated !== undefined ? options.showRelated : true;
this.showUserAvatar = options.showUserAvatar !== undefined ? options.showUserAvatar : true;
this.linkTitle = options.linkTitle !== undefined ? options.linkTitle : true;
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
if (this.showTitle) {
this.createOverlay(); this.createOverlay();
} else {
// Hide overlay element if showTitle is false
const overlay = this.el();
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
}
}); });
} }
@@ -49,7 +61,7 @@ class EmbedInfoOverlay extends Component {
`; `;
// Create avatar container // Create avatar container
if (this.authorThumbnail) { if (this.authorThumbnail && this.showUserAvatar) {
const avatarContainer = document.createElement('div'); const avatarContainer = document.createElement('div');
avatarContainer.className = 'embed-avatar-container'; avatarContainer.className = 'embed-avatar-container';
avatarContainer.style.cssText = ` avatarContainer.style.cssText = `
@@ -125,7 +137,7 @@ class EmbedInfoOverlay extends Component {
overflow: hidden; overflow: hidden;
`; `;
if (this.videoUrl) { if (this.videoUrl && this.linkTitle) {
const titleLink = document.createElement('a'); const titleLink = document.createElement('a');
titleLink.href = this.videoUrl; titleLink.href = this.videoUrl;
titleLink.target = '_blank'; titleLink.target = '_blank';
@@ -186,10 +198,16 @@ class EmbedInfoOverlay extends Component {
const player = this.player(); const player = this.player();
const overlay = this.el(); const overlay = this.el();
// If showTitle is false, ensure overlay is hidden
if (!this.showTitle) {
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
return;
}
// Sync overlay visibility with control bar visibility // Sync overlay visibility with control bar visibility
const updateOverlayVisibility = () => { const updateOverlayVisibility = () => {
const controlBar = player.getChild('controlBar');
if (!player.hasStarted()) { if (!player.hasStarted()) {
// Show overlay when video hasn't started (poster is showing) - like before // Show overlay when video hasn't started (poster is showing) - like before
overlay.style.opacity = '1'; overlay.style.opacity = '1';

View File

@@ -0,0 +1,47 @@
.video-context-menu {
position: fixed;
background-color: #282828;
border-radius: 4px;
padding: 4px 0;
min-width: 240px;
z-index: 10000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.video-context-menu-item {
display: flex;
align-items: center;
padding: 10px 16px;
color: #ffffff;
cursor: pointer;
transition: background-color 0.15s ease;
font-size: 14px;
user-select: none;
}
.video-context-menu-item:hover {
background-color: #3d3d3d;
}
.video-context-menu-item:active {
background-color: #4a4a4a;
}
.video-context-menu-icon {
width: 18px;
height: 18px;
margin-right: 12px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.video-context-menu-item span {
flex: 1;
white-space: nowrap;
}

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useRef } from 'react';
import './VideoContextMenu.css';
function VideoContextMenu({ visible, position, onClose, onCopyVideoUrl, onCopyVideoUrlAtTime, onCopyEmbedCode }) {
const menuRef = useRef(null);
useEffect(() => {
if (visible && menuRef.current) {
// Position the menu
menuRef.current.style.left = `${position.x}px`;
menuRef.current.style.top = `${position.y}px`;
// Adjust if menu goes off screen
const rect = menuRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (rect.right > windowWidth) {
menuRef.current.style.left = `${position.x - rect.width}px`;
}
if (rect.bottom > windowHeight) {
menuRef.current.style.top = `${position.y - rect.height}px`;
}
}
}, [visible, position]);
useEffect(() => {
const handleClickOutside = (e) => {
if (visible && menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape' && visible) {
onClose();
}
};
if (visible) {
// Use capture phase to catch events earlier, before they can be stopped
// Listen to both mousedown and click to ensure we catch all clicks
document.addEventListener('mousedown', handleClickOutside, true);
document.addEventListener('click', handleClickOutside, true);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
document.removeEventListener('click', handleClickOutside, true);
document.removeEventListener('keydown', handleEscape);
};
}, [visible, onClose]);
if (!visible) return null;
return (
<div ref={menuRef} className="video-context-menu" onClick={(e) => e.stopPropagation()}>
<div className="video-context-menu-item" onClick={onCopyVideoUrl}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL</span>
</div>
<div className="video-context-menu-item" onClick={onCopyVideoUrlAtTime}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL at current time</span>
</div>
<div className="video-context-menu-item" onClick={onCopyEmbedCode}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy embed code</span>
</div>
</div>
);
}
export default VideoContextMenu;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useMemo } from 'react'; import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import videojs from 'video.js'; import videojs from 'video.js';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import '../../styles/embed.css'; import '../../styles/embed.css';
@@ -17,6 +17,7 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
import CustomSettingsMenu from '../controls/CustomSettingsMenu'; import CustomSettingsMenu from '../controls/CustomSettingsMenu';
import SeekIndicator from '../controls/SeekIndicator'; import SeekIndicator from '../controls/SeekIndicator';
import VideoContextMenu from '../overlays/VideoContextMenu';
import UserPreferences from '../../utils/UserPreferences'; import UserPreferences from '../../utils/UserPreferences';
import PlayerConfig from '../../config/playerConfig'; import PlayerConfig from '../../config/playerConfig';
import { AutoplayHandler } from '../../utils/AutoplayHandler'; import { AutoplayHandler } from '../../utils/AutoplayHandler';
@@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => {
}, 500); // Delay to ensure all components are ready }, 500); // Delay to ensure all components are ready
}; };
function VideoJSPlayer({ videoId = 'default-video' }) { function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelated = true, showUserAvatar = true, linkTitle = true, urlTimestamp = null }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const playerRef = useRef(null); // Track the player instance const playerRef = useRef(null); // Track the player instance
const userPreferences = useRef(new UserPreferences()); // User preferences instance const userPreferences = useRef(new UserPreferences()); // User preferences instance
@@ -177,25 +178,17 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
const keyboardHandler = useRef(null); // Keyboard handler instance const keyboardHandler = useRef(null); // Keyboard handler instance
const playbackEventHandler = useRef(null); // Playback event handler instance const playbackEventHandler = useRef(null); // Playback event handler instance
// Context menu state
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Check if this is an embed player (disable next video and autoplay features) // Check if this is an embed player (disable next video and autoplay features)
const isEmbedPlayer = videoId === 'video-embed'; const isEmbedPlayer = videoId === 'video-embed';
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Environment-based development mode configuration // Environment-based development mode configuration
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app'); const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
// Safely access window.MEDIA_DATA with fallback using useMemo
// Read options from window.MEDIA_DATA if available (for consistency with embed logic)
const mediaData = useMemo( const mediaData = useMemo(
() => () =>
typeof window !== 'undefined' && window.MEDIA_DATA typeof window !== 'undefined' && window.MEDIA_DATA
@@ -214,12 +207,37 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
}, },
siteUrl: 'https://deic.mediacms.io', siteUrl: 'https://deic.mediacms.io',
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania', nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
urlAutoplay: true,
urlMuted: false,
}, },
[] []
); );
// Helper to get effective value (prop or MEDIA_DATA or default)
const getOption = (propKey, mediaDataKey, defaultValue) => {
if (isEmbedPlayer) {
if (mediaData[mediaDataKey] !== undefined) return mediaData[mediaDataKey];
}
return propKey !== undefined ? propKey : defaultValue;
};
const finalShowTitle = getOption(showTitle, 'showTitle', true);
const finalShowRelated = getOption(showRelated, 'showRelated', true);
const finalShowUserAvatar = getOption(showUserAvatar, 'showUserAvatar', true);
const finalLinkTitle = getOption(linkTitle, 'linkTitle', true);
const finalTimestamp = getOption(urlTimestamp, 'urlTimestamp', null);
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Define chapters as JSON object // Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON // Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
// CONDITIONAL LOGIC: // CONDITIONAL LOGIC:
@@ -531,8 +549,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
isPlayList: mediaData?.isPlayList, isPlayList: mediaData?.isPlayList,
related_media: mediaData.data?.related_media || [], related_media: mediaData.data?.related_media || [],
nextLink: mediaData?.nextLink || null, nextLink: mediaData?.nextLink || null,
urlAutoplay: mediaData?.urlAutoplay || true,
urlMuted: mediaData?.urlMuted || false,
sources: getVideoSources(), sources: getVideoSources(),
}; };
@@ -738,6 +754,212 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
} }
}; };
// Context menu handlers
const handleContextMenu = useCallback((e) => {
// Only handle if clicking on video player area
const target = e.target;
const isVideoPlayerArea =
target.closest('.video-js') ||
target.classList.contains('vjs-tech') ||
target.tagName === 'VIDEO' ||
target.closest('video');
if (isVideoPlayerArea) {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition({ x: e.clientX, y: e.clientY });
setContextMenuVisible(true);
}
}, []);
const closeContextMenu = () => {
setContextMenuVisible(false);
};
// Helper function to get media ID
const getMediaId = () => {
if (typeof window !== 'undefined' && window.MEDIA_DATA?.data?.friendly_token) {
return window.MEDIA_DATA.data.friendly_token;
}
if (mediaData?.data?.friendly_token) {
return mediaData.data.friendly_token;
}
// Try to get from URL (works for both main page and embed page)
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const mediaIdFromUrl = urlParams.get('m');
if (mediaIdFromUrl) {
return mediaIdFromUrl;
}
// Also check if we're on an embed page with media ID in path
const pathMatch = window.location.pathname.match(/\/embed\/([^/?]+)/);
if (pathMatch) {
return pathMatch[1];
}
}
return currentVideo.id || 'default-video';
};
// Helper function to get base origin URL (handles embed mode)
const getBaseOrigin = () => {
if (typeof window !== 'undefined') {
// In embed mode, try to get origin from parent window if possible
// Otherwise use current window origin
try {
// Check if we're in an iframe and can access parent
if (window.parent !== window && window.parent.location.origin) {
return window.parent.location.origin;
}
} catch {
// Cross-origin iframe, use current origin
}
return window.location.origin;
}
return mediaData.siteUrl || 'https://deic.mediacms.io';
};
// Helper function to get embed URL
const getEmbedUrl = () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
// Try to get embed URL from config or construct it
if (typeof window !== 'undefined' && window.MediaCMS?.config?.url?.embed) {
return window.MediaCMS.config.url.embed + mediaId;
}
// Fallback: construct embed URL (check if current URL is embed format)
if (typeof window !== 'undefined' && window.location.pathname.includes('/embed')) {
// If we're already on an embed page, use current URL format
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('m', mediaId);
return currentUrl.toString();
}
// Default embed URL format
return `${origin}/embed?m=${mediaId}`;
};
// Copy video URL to clipboard
const handleCopyVideoUrl = async () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
// You can add a notification here if needed
} catch (err) {
console.error('Failed to copy video URL:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy video URL at current time to clipboard
const handleCopyVideoUrlAtTime = async () => {
if (!playerRef.current) {
closeContextMenu();
return;
}
const currentTime = Math.floor(playerRef.current.currentTime() || 0);
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}&t=${currentTime}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
} catch (err) {
console.error('Failed to copy video URL at time:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy embed code to clipboard
const handleCopyEmbedCode = async () => {
const embedUrl = getEmbedUrl();
const embedCode = `<iframe width="560" height="315" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`;
// Show copy embed icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-embed');
}
try {
await navigator.clipboard.writeText(embedCode);
closeContextMenu();
} catch (err) {
console.error('Failed to copy embed code:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = embedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Add context menu handler directly to video element and document (works before and after Video.js initialization)
useEffect(() => {
const videoElement = videoRef.current;
// Attach to document with capture to catch all contextmenu events, then filter
const documentHandler = (e) => {
// Check if the event originated from within the video player
const target = e.target;
const playerWrapper =
videoElement?.closest('.video-js') || document.querySelector(`#${videoId}`)?.closest('.video-js');
if (playerWrapper && (playerWrapper.contains(target) || target === playerWrapper)) {
handleContextMenu(e);
}
};
// Use capture phase on document to catch before anything else
document.addEventListener('contextmenu', documentHandler, true);
// Also attach directly to video element
if (videoElement) {
videoElement.addEventListener('contextmenu', handleContextMenu, true);
}
return () => {
document.removeEventListener('contextmenu', documentHandler, true);
if (videoElement) {
videoElement.removeEventListener('contextmenu', handleContextMenu, true);
}
};
}, [handleContextMenu, videoId]);
useEffect(() => { useEffect(() => {
// Only initialize if we don't already have a player and element exists // Only initialize if we don't already have a player and element exists
if (videoRef.current && !playerRef.current) { if (videoRef.current && !playerRef.current) {
@@ -1078,6 +1300,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
currentVideo, currentVideo,
relatedVideos, relatedVideos,
goToNextVideo, goToNextVideo,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
@@ -1098,8 +1323,8 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
} }
// Handle URL timestamp parameter // Handle URL timestamp parameter
if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) { if (finalTimestamp !== null && finalTimestamp >= 0) {
const timestamp = mediaData.urlTimestamp; const timestamp = finalTimestamp;
// Wait for video metadata to be loaded before seeking // Wait for video metadata to be loaded before seeking
if (playerRef.current.readyState() >= 1) { if (playerRef.current.readyState() >= 1) {
@@ -1997,6 +2222,10 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
authorThumbnail: currentVideo.author_thumbnail, authorThumbnail: currentVideo.author_thumbnail,
videoTitle: currentVideo.title, videoTitle: currentVideo.title,
videoUrl: currentVideo.url, videoUrl: currentVideo.url,
showTitle: finalShowTitle,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
} }
// END: Add Embed Info Overlay Component // END: Add Embed Info Overlay Component
@@ -2083,20 +2312,72 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
// Make the video element focusable // Make the video element focusable
const videoElement = playerRef.current.el(); const videoElement = playerRef.current.el();
videoElement.setAttribute('tabindex', '0'); videoElement.setAttribute('tabindex', '0');
if (!isEmbedPlayer) {
videoElement.focus(); videoElement.focus();
} }
// Add context menu (right-click) handler to the player wrapper and video element
// Attach to player wrapper (this catches all clicks on the player)
videoElement.addEventListener('contextmenu', handleContextMenu, true);
// Also try to attach to the actual video tech element
const attachContextMenu = () => {
const techElement =
playerRef.current.el().querySelector('.vjs-tech') ||
playerRef.current.el().querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement && techElement !== videoRef.current && techElement !== videoElement) {
// Use capture phase to catch before Video.js might prevent it
techElement.addEventListener('contextmenu', handleContextMenu, true);
return true;
}
return false;
};
// Try to attach immediately
attachContextMenu();
// Also try after a short delay in case elements aren't ready yet
setTimeout(() => {
attachContextMenu();
}, 100);
// Also try when video is loaded
playerRef.current.one('loadedmetadata', () => {
attachContextMenu();
});
}
}); });
} }
//}, 0); //}, 0);
} }
// Cleanup: Remove context menu event listener
return () => {
if (playerRef.current && playerRef.current.el()) {
const playerEl = playerRef.current.el();
playerEl.removeEventListener('contextmenu', handleContextMenu, true);
const techElement =
playerEl.querySelector('.vjs-tech') ||
playerEl.querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement) {
techElement.removeEventListener('contextmenu', handleContextMenu, true);
}
}
};
}, []); }, []);
return ( return (
<>
<video <video
ref={videoRef} ref={videoRef}
id={videoId} id={videoId}
controls={true} controls={true}
className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`} className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
preload="auto" preload="auto"
poster={currentVideo.poster} poster={currentVideo.poster}
tabIndex="0" tabIndex="0"
@@ -2129,6 +2410,15 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
chaptersData.length > 0 && chaptersData.length > 0 &&
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */} (console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
</video> </video>
<VideoContextMenu
visible={contextMenuVisible}
position={contextMenuPosition}
onClose={closeContextMenu}
onCopyVideoUrl={handleCopyVideoUrl}
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
onCopyEmbedCode={handleCopyEmbedCode}
/>
</>
); );
} }

View File

@@ -63,7 +63,17 @@ export class EndScreenHandler {
} }
handleVideoEnded() { handleVideoEnded() {
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options; const {
isEmbedPlayer,
userPreferences,
mediaData,
currentVideo,
relatedVideos,
goToNextVideo,
showRelated,
showUserAvatar,
linkTitle,
} = this.options;
// For embed players, show big play button when video ends // For embed players, show big play button when video ends
if (isEmbedPlayer) { if (isEmbedPlayer) {
@@ -73,6 +83,34 @@ export class EndScreenHandler {
} }
} }
// If showRelated is false, we don't show the end screen or autoplay countdown
if (showRelated === false) {
// But we still want to keep the control bar visible and hide the poster
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
const playerEl = this.player.el();
if (playerEl) {
// Hide poster elements
const posterElements = playerEl.querySelectorAll('.vjs-poster');
posterElements.forEach((posterEl) => {
posterEl.style.display = 'none';
posterEl.style.visibility = 'hidden';
posterEl.style.opacity = '0';
});
// Keep control bar visible
const controlBar = this.player.getChild('controlBar');
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents = 'auto';
}
}
}
}, 50);
return;
}
// Keep controls active after video ends // Keep controls active after video ends
setTimeout(() => { setTimeout(() => {
if (this.player && !this.player.isDisposed()) { if (this.player && !this.player.isDisposed()) {

View File

@@ -31,8 +31,11 @@ const VideoJSEmbed = ({
poster, poster,
previewSprite, previewSprite,
subtitlesInfo, subtitlesInfo,
enableAutoplay,
inEmbed, inEmbed,
showTitle,
showRelated,
showUserAvatar,
linkTitle,
hasTheaterMode, hasTheaterMode,
hasNextLink, hasNextLink,
nextLink, nextLink,
@@ -62,8 +65,10 @@ const VideoJSEmbed = ({
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Get URL parameters for autoplay, muted, and timestamp // Get URL parameters for autoplay, muted, and timestamp
const urlTimestamp = getUrlParameter('t'); const urlTimestamp = getUrlParameter('t');
const urlAutoplay = getUrlParameter('autoplay');
const urlMuted = getUrlParameter('muted'); const urlMuted = getUrlParameter('muted');
const urlShowRelated = getUrlParameter('showRelated');
const urlShowUserAvatar = getUrlParameter('showUserAvatar');
const urlLinkTitle = getUrlParameter('linkTitle');
window.MEDIA_DATA = { window.MEDIA_DATA = {
data: data || {}, data: data || {},
@@ -71,7 +76,7 @@ const VideoJSEmbed = ({
version: version, version: version,
isPlayList: isPlayList, isPlayList: isPlayList,
playerVolume: playerVolume || 0.5, playerVolume: playerVolume || 0.5,
playerSoundMuted: playerSoundMuted || (urlMuted === '1'), playerSoundMuted: urlMuted === '1',
videoQuality: videoQuality || 'auto', videoQuality: videoQuality || 'auto',
videoPlaybackSpeed: videoPlaybackSpeed || 1, videoPlaybackSpeed: videoPlaybackSpeed || 1,
inTheaterMode: inTheaterMode || false, inTheaterMode: inTheaterMode || false,
@@ -83,8 +88,11 @@ const VideoJSEmbed = ({
poster: poster || '', poster: poster || '',
previewSprite: previewSprite || null, previewSprite: previewSprite || null,
subtitlesInfo: subtitlesInfo || [], subtitlesInfo: subtitlesInfo || [],
enableAutoplay: enableAutoplay || (urlAutoplay === '1'),
inEmbed: inEmbed || false, inEmbed: inEmbed || false,
showTitle: showTitle || false,
showRelated: showRelated !== undefined ? showRelated : (urlShowRelated === '1' || urlShowRelated === 'true' || urlShowRelated === null),
showUserAvatar: showUserAvatar !== undefined ? showUserAvatar : (urlShowUserAvatar === '1' || urlShowUserAvatar === 'true' || urlShowUserAvatar === null),
linkTitle: linkTitle !== undefined ? linkTitle : (urlLinkTitle === '1' || urlLinkTitle === 'true' || urlLinkTitle === null),
hasTheaterMode: hasTheaterMode || false, hasTheaterMode: hasTheaterMode || false,
hasNextLink: hasNextLink || false, hasNextLink: hasNextLink || false,
nextLink: nextLink || null, nextLink: nextLink || null,
@@ -92,8 +100,10 @@ const VideoJSEmbed = ({
errorMessage: errorMessage || '', errorMessage: errorMessage || '',
// URL parameters // URL parameters
urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null, urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null,
urlAutoplay: urlAutoplay === '1',
urlMuted: urlMuted === '1', urlMuted: urlMuted === '1',
urlShowRelated: urlShowRelated === '1' || urlShowRelated === 'true',
urlShowUserAvatar: urlShowUserAvatar === '1' || urlShowUserAvatar === 'true',
urlLinkTitle: urlLinkTitle === '1' || urlLinkTitle === 'true',
onClickNextCallback: onClickNextCallback || null, onClickNextCallback: onClickNextCallback || null,
onClickPreviousCallback: onClickPreviousCallback || null, onClickPreviousCallback: onClickPreviousCallback || null,
onStateUpdateCallback: onStateUpdateCallback || null, onStateUpdateCallback: onStateUpdateCallback || null,
@@ -176,12 +186,18 @@ const VideoJSEmbed = ({
// Scroll to the video player with smooth behavior // Scroll to the video player with smooth behavior
const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main'); const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main');
if (videoElement) { if (videoElement) {
const urlScroll = getUrlParameter('scroll');
const isIframe = window.parent !== window;
// Only scroll if not in an iframe, OR if explicitly requested via scroll=1 parameter
if (!isIframe || urlScroll === '1' || urlScroll === 'true') {
videoElement.scrollIntoView({ videoElement.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'center', block: 'center',
inline: 'nearest' inline: 'nearest'
}); });
} }
}
} else { } else {
console.warn('VideoJS player not found for timestamp navigation'); console.warn('VideoJS player not found for timestamp navigation');
} }
@@ -220,7 +236,14 @@ const VideoJSEmbed = ({
return ( return (
<div className="video-js-wrapper" ref={containerRef}> <div className="video-js-wrapper" ref={containerRef}>
{inEmbed ? <div id="video-js-root-embed" className="video-js-root-embed" /> : <div id="video-js-root-main" className="video-js-root-main" />} {inEmbed ? (
<div
id="video-js-root-embed"
className="video-js-root-embed"
/>
) : (
<div id="video-js-root-main" className="video-js-root-main" />
)}
</div> </div>
); );
}; };

View File

@@ -4,10 +4,32 @@ import { LinksContext, SiteConsumer } from '../../utils/contexts/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { PageActions, MediaPageActions } from '../../utils/actions/'; import { PageActions, MediaPageActions } from '../../utils/actions/';
import { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/'; import { CircleIconButton, MaterialIcon, NumericInputWithUnit } from '../_shared/';
import VideoViewer from '../media-viewer/VideoViewer';
const EMBED_OPTIONS_STORAGE_KEY = 'mediacms_embed_options';
function loadEmbedOptions() {
try {
const saved = localStorage.getItem(EMBED_OPTIONS_STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (e) {
// Ignore localStorage errors
}
return null;
}
function saveEmbedOptions(options) {
try {
localStorage.setItem(EMBED_OPTIONS_STORAGE_KEY, JSON.stringify(options));
} catch (e) {
// Ignore localStorage errors
}
}
export function MediaShareEmbed(props) { export function MediaShareEmbed(props) {
const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions; const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions;
const savedOptions = loadEmbedOptions();
const links = useContext(LinksContext); const links = useContext(LinksContext);
@@ -18,12 +40,19 @@ export function MediaShareEmbed(props) {
const onRightBottomRef = useRef(null); const onRightBottomRef = useRef(null);
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56); const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56);
const [keepAspectRatio, setKeepAspectRatio] = useState(false); const [keepAspectRatio, setKeepAspectRatio] = useState(savedOptions?.keepAspectRatio ?? true);
const [aspectRatio, setAspectRatio] = useState('16:9'); const [showTitle, setShowTitle] = useState(savedOptions?.showTitle ?? true);
const [embedWidthValue, setEmbedWidthValue] = useState(embedVideoDimensions.width); const [showRelated, setShowRelated] = useState(savedOptions?.showRelated ?? true);
const [embedWidthUnit, setEmbedWidthUnit] = useState(embedVideoDimensions.widthUnit); const [showUserAvatar, setShowUserAvatar] = useState(savedOptions?.showUserAvatar ?? true);
const [embedHeightValue, setEmbedHeightValue] = useState(embedVideoDimensions.height); const [linkTitle, setLinkTitle] = useState(savedOptions?.linkTitle ?? true);
const [embedHeightUnit, setEmbedHeightUnit] = useState(embedVideoDimensions.heightUnit); const [responsive, setResponsive] = useState(savedOptions?.responsive ?? false);
const [startAt, setStartAt] = useState(false);
const [startTime, setStartTime] = useState('0:00');
const [aspectRatio, setAspectRatio] = useState(savedOptions?.aspectRatio ?? '16:9');
const [embedWidthValue, setEmbedWidthValue] = useState(savedOptions?.embedWidthValue ?? embedVideoDimensions.width);
const [embedWidthUnit, setEmbedWidthUnit] = useState(savedOptions?.embedWidthUnit ?? embedVideoDimensions.widthUnit);
const [embedHeightValue, setEmbedHeightValue] = useState(savedOptions?.embedHeightValue ?? embedVideoDimensions.height);
const [embedHeightUnit, setEmbedHeightUnit] = useState(savedOptions?.embedHeightUnit ?? embedVideoDimensions.heightUnit);
const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60); const [rightMiddlePositionTop, setRightMiddlePositionTop] = useState(60);
const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60); const [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60);
const [unitOptions, setUnitOptions] = useState([ const [unitOptions, setUnitOptions] = useState([
@@ -71,36 +100,65 @@ export function MediaShareEmbed(props) {
setEmbedHeightUnit(newVal); setEmbedHeightUnit(newVal);
} }
function onKeepAspectRatioChange() { function onShowTitleChange() {
const newVal = !keepAspectRatio; setShowTitle(!showTitle);
}
function onShowRelatedChange() {
setShowRelated(!showRelated);
}
function onShowUserAvatarChange() {
setShowUserAvatar(!showUserAvatar);
}
function onLinkTitleChange() {
setLinkTitle(!linkTitle);
}
function onResponsiveChange() {
const nextResponsive = !responsive;
setResponsive(nextResponsive);
if (!nextResponsive) {
if (aspectRatio !== 'custom') {
const arr = aspectRatio.split(':'); const arr = aspectRatio.split(':');
const x = arr[0]; const x = arr[0];
const y = arr[1]; const y = arr[1];
setKeepAspectRatio(newVal); setKeepAspectRatio(true);
setEmbedWidthUnit(newVal ? 'px' : embedWidthUnit); setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
setEmbedHeightUnit(newVal ? 'px' : embedHeightUnit); } else {
setEmbedHeightValue(newVal ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue); setKeepAspectRatio(false);
setUnitOptions( }
newVal } else {
? [{ key: 'px', label: 'px' }] setKeepAspectRatio(false);
: [ }
{ key: 'px', label: 'px' }, }
{ key: 'percent', label: '%' },
] function onStartAtChange() {
); setStartAt(!startAt);
}
function onStartTimeChange(e) {
setStartTime(e.target.value);
} }
function onAspectRatioChange() { function onAspectRatioChange() {
const newVal = aspectRatioValueRef.current.value; const newVal = aspectRatioValueRef.current.value;
if (newVal === 'custom') {
setAspectRatio(newVal);
setKeepAspectRatio(false);
} else {
const arr = newVal.split(':'); const arr = newVal.split(':');
const x = arr[0]; const x = arr[0];
const y = arr[1]; const y = arr[1];
setAspectRatio(newVal); setAspectRatio(newVal);
setEmbedHeightValue(keepAspectRatio ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue); setKeepAspectRatio(true);
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
}
} }
function onWindowResize() { function onWindowResize() {
@@ -130,13 +188,88 @@ export function MediaShareEmbed(props) {
}; };
}, []); }, []);
// Save embed options to localStorage when they change (except startAt/startTime)
useEffect(() => {
saveEmbedOptions({
showTitle,
showRelated,
showUserAvatar,
linkTitle,
responsive,
aspectRatio,
embedWidthValue,
embedWidthUnit,
embedHeightValue,
embedHeightUnit,
keepAspectRatio,
});
}, [showTitle, showRelated, showUserAvatar, linkTitle, responsive, aspectRatio, embedWidthValue, embedWidthUnit, embedHeightValue, embedHeightUnit, keepAspectRatio]);
function getEmbedCode() {
const mediaId = MediaPageStore.get('media-id');
const params = new URLSearchParams();
if (showTitle) params.set('showTitle', '1');
else params.set('showTitle', '0');
if (showRelated) params.set('showRelated', '1');
else params.set('showRelated', '0');
if (showUserAvatar) params.set('showUserAvatar', '1');
else params.set('showUserAvatar', '0');
if (linkTitle) params.set('linkTitle', '1');
else params.set('linkTitle', '0');
if (startAt && startTime) {
const parts = startTime.split(':').reverse();
let seconds = 0;
if (parts[0]) seconds += parseInt(parts[0], 10) || 0;
if (parts[1]) seconds += (parseInt(parts[1], 10) || 0) * 60;
if (parts[2]) seconds += (parseInt(parts[2], 10) || 0) * 3600;
if (seconds > 0) params.set('t', seconds);
}
const separator = links.embed.includes('?') ? '&' : '?';
const finalUrl = `${links.embed}${mediaId}${separator}${params.toString()}`;
if (responsive) {
if (aspectRatio === 'custom') {
// Use current width/height values to calculate aspect ratio for custom
const ratio = `${embedWidthValue} / ${embedHeightValue}`;
const maxWidth = `calc(100vh * ${embedWidthValue} / ${embedHeightValue})`;
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
}
const arr = aspectRatio.split(':');
const ratio = `${arr[0]} / ${arr[1]}`;
const maxWidth = `calc(100vh * ${arr[0]} / ${arr[1]})`;
return `<iframe src="${finalUrl}" style="width:100%;max-width:${maxWidth};aspect-ratio:${ratio};display:block;margin:auto;border:0;" allowFullScreen></iframe>`;
}
const width = 'percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue;
const height = 'percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue;
return `<iframe width="${width}" height="${height}" src="${finalUrl}" frameBorder="0" allowFullScreen></iframe>`;
}
return ( return (
<div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}> <div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}>
<div className="share-embed-inner"> <div className="share-embed-inner">
<div className="on-left"> <div className="on-left">
<div className="media-embed-wrap"> <div className="media-embed-wrap">
<SiteConsumer> <SiteConsumer>
{(site) => <VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} inEmbed={true} />} {(site) => {
const previewUrl = `${links.embed + MediaPageStore.get('media-id')}&showTitle=${showTitle ? '1' : '0'}&showRelated=${showRelated ? '1' : '0'}&showUserAvatar=${showUserAvatar ? '1' : '0'}&linkTitle=${linkTitle ? '1' : '0'}${startAt ? '&t=' + (startTime.split(':').reverse().reduce((acc, cur, i) => acc + (parseInt(cur, 10) || 0) * Math.pow(60, i), 0)) : ''}`;
const style = {};
style.width = '100%';
style.height = '480px';
style.overflow = 'hidden';
return (
<div style={style}>
<iframe width="100%" height="100%" src={previewUrl} frameBorder="0" allowFullScreen></iframe>
</div>
);
}}
</SiteConsumer> </SiteConsumer>
</div> </div>
</div> </div>
@@ -158,16 +291,7 @@ export function MediaShareEmbed(props) {
> >
<textarea <textarea
readOnly readOnly
value={ value={getEmbedCode()}
'<iframe width="' +
('percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue) +
'" height="' +
('percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue) +
'" src="' +
links.embed +
MediaPageStore.get('media-id') +
'" frameborder="0" allowfullscreen></iframe>'
}
></textarea> ></textarea>
<div className="iframe-config"> <div className="iframe-config">
@@ -179,34 +303,79 @@ export function MediaShareEmbed(props) {
</div>*/} </div>*/}
<div className="option-content"> <div className="option-content">
<div className="ratio-options"> <div className="ratio-options" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 10px' }}>
<div className="options-group"> <div className="options-group">
<label style={{ minHeight: '36px' }}> <label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={keepAspectRatio} onChange={onKeepAspectRatioChange} /> <input type="checkbox" checked={showTitle} onChange={onShowTitleChange} />
Keep aspect ratio Show title
</label> </label>
</div> </div>
{!keepAspectRatio ? null : (
<div className="options-group"> <div className="options-group">
<select ref={aspectRatioValueRef} onChange={onAspectRatioChange} value={aspectRatio}> <label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
<optgroup label="Horizontal orientation"> <input type="checkbox" checked={linkTitle} onChange={onLinkTitleChange} disabled={!showTitle} />
Link title
</label>
</div>
<div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={showRelated} onChange={onShowRelatedChange} />
Show related
</label>
</div>
<div className="options-group">
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
<input type="checkbox" checked={showUserAvatar} onChange={onShowUserAvatarChange} disabled={!showTitle} />
Show user avatar
</label>
</div>
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
<input type="checkbox" checked={responsive} onChange={onResponsiveChange} />
Responsive
</label>
</div>
<div className="options-group" style={{ display: 'flex', alignItems: 'center' }}>
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', marginRight: '10px' }}>
<input type="checkbox" checked={startAt} onChange={onStartAtChange} />
Start at
</label>
{startAt && (
<input
type="text"
value={startTime}
onChange={onStartTimeChange}
style={{ width: '60px', height: '28px', fontSize: '12px', padding: '2px 5px' }}
/>
)}
</div>
<div className="options-group" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ fontSize: '12px', marginBottom: '4px', color: 'rgba(0,0,0,0.6)' }}>Aspect Ratio</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<select
ref={aspectRatioValueRef}
onChange={onAspectRatioChange}
value={aspectRatio}
style={{ height: '28px', fontSize: '12px' }}
>
<option value="16:9">16:9</option> <option value="16:9">16:9</option>
<option value="4:3">4:3</option> <option value="4:3">4:3</option>
<option value="3:2">3:2</option> <option value="3:2">3:2</option>
</optgroup> <option value="custom">Custom</option>
<optgroup label="Vertical orientation">
<option value="9:16">9:16</option>
<option value="3:4">3:4</option>
<option value="2:3">2:3</option>
</optgroup>
</select> </select>
</div> </div>
)} </div>
</div> </div>
<br /> <br />
{!responsive && (
<>
<div className="options-group"> <div className="options-group">
<NumericInputWithUnit <NumericInputWithUnit
valueCallback={onEmbedWidthValueChange} valueCallback={onEmbedWidthValueChange}
@@ -232,6 +401,8 @@ export function MediaShareEmbed(props) {
units={unitOptions} units={unitOptions}
/> />
</div> </div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
@use "sass:math"; @use "sass:math";
@import '../../../css/includes/_variables.scss'; @import "../../../css/includes/_variables.scss";
@import '../../../css/includes/_variables_dimensions.scss'; @import "../../../css/includes/_variables_dimensions.scss";
.visible-sidebar .page-main-wrap { .visible-sidebar .page-main-wrap {
padding-left: 0; padding-left: 0;
@@ -119,7 +119,7 @@
background-color: var(--media-actions-share-copy-field-bg-color); background-color: var(--media-actions-share-copy-field-bg-color);
} }
input[type='text'] { input[type="text"] {
color: var(--media-actions-share-copy-field-input-text-color); color: var(--media-actions-share-copy-field-input-text-color);
} }
} }
@@ -180,7 +180,7 @@
color: var(--report-form-field-label-text-color); color: var(--report-form-field-label-text-color);
} }
input[type='text'], input[type="text"],
textarea { textarea {
color: var(--report-form-field-input-text-color); color: var(--report-form-field-input-text-color);
border-color: var(--report-form-field-input-border-color); border-color: var(--report-form-field-input-border-color);
@@ -479,7 +479,7 @@
&.audio-player-container { &.audio-player-container {
&:before { &:before {
content: '\E3A1'; content: "\E3A1";
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@@ -490,12 +490,11 @@
line-height: 1; line-height: 1;
padding: 0; padding: 0;
font-family: 'Material Icons'; font-family: "Material Icons";
text-decoration: none; text-decoration: none;
color: #888; color: #888;
} }
.vjs-big-play-button { .vjs-big-play-button {
} }
@@ -514,6 +513,13 @@
} }
} }
.embedded-app {
.viewer-container,
.viewer-info {
width: 100%;
}
}
.viewer-image-container { .viewer-image-container {
position: relative; position: relative;
display: block; display: block;
@@ -550,8 +556,6 @@
max-width: 90%; max-width: 90%;
} }
.slideshow-image img { .slideshow-image img {
display: block; display: block;
width: auto; width: auto;
@@ -560,7 +564,9 @@
max-height: 90vh; max-height: 90vh;
border-radius: 0; border-radius: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: transform 60s ease-in-out, opacity 60 ease-in-out; transition:
transform 60s ease-in-out,
opacity 60 ease-in-out;
} }
.slideshow-title { .slideshow-title {
@@ -572,7 +578,6 @@
z-index: 1200; z-index: 1200;
} }
.arrow { .arrow {
position: absolute; position: absolute;
display: flex; display: flex;
@@ -590,7 +595,9 @@
padding: 10px; padding: 10px;
border-radius: 50%; border-radius: 50%;
z-index: 1000; z-index: 1000;
transition: background-color 0.2s ease, transform 0.2s ease; transition:
background-color 0.2s ease,
transform 0.2s ease;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
} }
@@ -685,18 +692,19 @@
width: 100%; // Default width for mobile width: 100%; // Default width for mobile
height: 400px; // Default height for mobile height: 400px; // Default height for mobile
@media (min-width: 768px) and (max-width: 1023px) { // Tablets @media (min-width: 768px) and (max-width: 1023px) {
// Tablets
width: 90%; width: 90%;
height: 600px; height: 600px;
} }
@media (min-width: 1024px) { // Desktop @media (min-width: 1024px) {
// Desktop
width: 85%; width: 85%;
height: 900px; height: 900px;
} }
} }
.viewer-container .player-container.viewer-pdf-container, .viewer-container .player-container.viewer-pdf-container,
.viewer-container .player-container.viewer-attachment-container { .viewer-container .player-container.viewer-attachment-container {
background-color: var(--item-thumb-bg-color); background-color: var(--item-thumb-bg-color);
@@ -785,78 +793,6 @@
} }
} }
.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 { .remove-media-icon {
background-color: rgba(220, 53, 69, 0.9); background-color: rgba(220, 53, 69, 0.9);
@@ -1078,7 +1014,7 @@
&.like, &.like,
&.dislike { &.dislike {
&:before { &:before {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: -4px; left: -4px;
@@ -1218,7 +1154,7 @@
border-radius: 2px; border-radius: 2px;
} }
input[type='text'] { input[type="text"] {
width: 100%; width: 100%;
height: 42px; height: 42px;
padding: 1px 0 1px 16px; padding: 1px 0 1px 16px;
@@ -1278,13 +1214,18 @@
width: 220px; width: 220px;
} }
box-shadow: 0 16px 24px 2px rgba(#000, 0.14), 0 6px 30px 5px rgba(#000, 0.12), box-shadow:
0 16px 24px 2px rgba(#000, 0.14),
0 6px 30px 5px rgba(#000, 0.12),
0 8px 10px -5px rgba(#000, 0.4); 0 8px 10px -5px rgba(#000, 0.4);
&.main-options, &.main-options,
&.video-download-options { &.video-download-options {
width: 240px; width: 240px;
box-shadow: 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12), 0 3px 1px -2px rgba(#000, 0.2); box-shadow:
0 2px 2px 0 rgba(#000, 0.14),
0 1px 5px 0 rgba(#000, 0.12),
0 3px 1px -2px rgba(#000, 0.2);
} }
} }
} }
@@ -1342,7 +1283,9 @@
padding: 24px; padding: 24px;
text-align: initial; text-align: initial;
box-shadow: rgba(#000, 0.14) 0px 16px 24px 2px, rgba(#000, 0.12) 0px 6px 30px 5px, box-shadow:
rgba(#000, 0.14) 0px 16px 24px 2px,
rgba(#000, 0.12) 0px 6px 30px 5px,
rgba(#000, 0.4) 0px 8px 10px; rgba(#000, 0.4) 0px 8px 10px;
} }
} }
@@ -1375,13 +1318,18 @@
width: 220px; width: 220px;
} }
box-shadow: 0 16px 24px 2px rgba(#000, 0.14), 0 6px 30px 5px rgba(#000, 0.12), box-shadow:
0 16px 24px 2px rgba(#000, 0.14),
0 6px 30px 5px rgba(#000, 0.12),
0 8px 10px -5px rgba(#000, 0.4); 0 8px 10px -5px rgba(#000, 0.4);
&.main-options, &.main-options,
&.video-download-options { &.video-download-options {
width: 240px; width: 240px;
box-shadow: 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12), 0 3px 1px -2px rgba(#000, 0.2); box-shadow:
0 2px 2px 0 rgba(#000, 0.14),
0 1px 5px 0 rgba(#000, 0.12),
0 3px 1px -2px rgba(#000, 0.2);
.popup-main { .popup-main {
min-height: 0; min-height: 0;
@@ -1484,7 +1432,7 @@
font-weight: 500; font-weight: 500;
} }
input[type='text'], input[type="text"],
textarea { textarea {
min-width: 100%; min-width: 100%;
width: 100%; width: 100%;
@@ -1507,7 +1455,7 @@
cursor: not-allowed; cursor: not-allowed;
} }
input[type='text'] { input[type="text"] {
font-size: 14px; font-size: 14px;
} }
@@ -1804,7 +1752,7 @@
border-width: 0 0 1px; border-width: 0 0 1px;
&:after { &:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: -5px; bottom: -5px;
right: 0; right: 0;
@@ -1860,7 +1808,7 @@
max-height: 100%; max-height: 100%;
padding: 16px; padding: 16px;
cursor: text; cursor: text;
font-family: 'Roboto Mono', monospace; font-family: "Roboto Mono", monospace;
font-size: 14px; font-size: 14px;
line-height: 1.714285714; line-height: 1.714285714;
outline: 0; outline: 0;
@@ -1894,7 +1842,7 @@
vertical-align: top; vertical-align: top;
input { input {
&[type='checkbox'] { &[type="checkbox"] {
margin-left: 0; margin-left: 0;
} }
} }
@@ -1906,7 +1854,7 @@
width: 100%; width: 100%;
input { input {
&[type='checkbox'] { &[type="checkbox"] {
margin-left: 0; margin-left: 0;
} }
} }
@@ -1982,9 +1930,21 @@
} }
} }
.media-embed-wrap {
display: block;
width: 100%;
height: 100%;
background: #000;
.media-embed-wrap { .media-embed-wrap {
display: block; display: block;
.player-container,
.player-container-inner {
width: 100%;
height: 100%;
padding-top: 0;
background: #000;
}
.player-container, .player-container,
.player-container-inner { .player-container-inner {
width: 100%; width: 100%;
@@ -1998,6 +1958,10 @@
.circle-icon-button { .circle-icon-button {
} }
.video-js.vjs-mediacms {
padding-top: 0;
}
}
.video-js.vjs-mediacms { .video-js.vjs-mediacms {
padding-top: math.div(9, 16) * 100%; padding-top: math.div(9, 16) * 100%;
} }
@@ -2051,8 +2015,8 @@
} }
.item-date:before { .item-date:before {
content: ''; content: "";
content: '\2022'; content: "\2022";
margin: 0 4px; margin: 0 4px;
} }
@@ -2089,14 +2053,14 @@
margin-right: 4px; margin-right: 4px;
&:after { &:after {
content: ','; content: ",";
} }
&:last-child { &:last-child {
margin-right: 0; margin-right: 0;
&:after { &:after {
content: ''; content: "";
} }
} }
} }

View File

@@ -3,8 +3,8 @@ import { SiteContext } from '../../utils/contexts/';
import { useUser, usePopup } from '../../utils/hooks/'; import { useUser, usePopup } from '../../utils/hooks/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { PageActions, MediaPageActions } from '../../utils/actions/'; import { PageActions, MediaPageActions } from '../../utils/actions/';
import { formatInnerLink, publishedOnDate } from '../../utils/helpers/'; import { formatInnerLink, inEmbeddedApp, publishedOnDate } from '../../utils/helpers/';
import { PopupMain, CircleIconButton, MaterialIcon, NavigationMenuList, NavigationContentApp } from '../_shared/'; import { PopupMain } from '../_shared/';
import CommentsList from '../comments/Comments'; import CommentsList from '../comments/Comments';
import { replaceString } from '../../utils/helpers/'; import { replaceString } from '../../utils/helpers/';
import { translateString } from '../../utils/helpers/'; import { translateString } from '../../utils/helpers/';
@@ -72,103 +72,17 @@ function MediaMetaField(props) {
); );
} }
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;
// 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) { function EditMediaButton(props) {
const [popupContentRef, PopupContent, PopupTrigger] = usePopup(); let link = props.link;
const menuItems = getEditMenuItems(); if (window.MediaCMS.site.devEnv) {
link = '/edit-media.html';
const popupPages = { }
main: (
<div className="edit-options">
<PopupMain>
<NavigationMenuList items={menuItems} />
</PopupMain>
</div>
),
};
return ( return (
<div className="edit-media-dropdown"> <a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
<PopupTrigger contentRef={popupContentRef}>
<button className="edit-media-icon" title={translateString('Edit media')}>
<i className="material-icons">edit</i> <i className="material-icons">edit</i>
</button> </a>
</PopupTrigger>
<PopupContent contentRef={popupContentRef}>
<NavigationContentApp
initPage="main"
focusFirstItemOnPageChange={false}
pages={popupPages}
/>
</PopupContent>
</div>
); );
} }
@@ -211,7 +125,9 @@ export default function ViewerInfoContent(props) {
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete'); PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
setTimeout(function () { setTimeout(function () {
window.location.href = window.location.href =
SiteContext._currentValue.url + '/' + MediaPageStore.get('media-data').author_profile.replace(/^\//g, ''); SiteContext._currentValue.url +
'/' +
MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
}, 2000); }, 2000);
}, 100); }, 100);
@@ -271,7 +187,12 @@ export default function ViewerInfoContent(props) {
{void 0 === PageStore.get('config-media-item').displayAuthor || {void 0 === PageStore.get('config-media-item').displayAuthor ||
null === PageStore.get('config-media-item').displayAuthor || null === PageStore.get('config-media-item').displayAuthor ||
!!PageStore.get('config-media-item').displayAuthor ? ( !!PageStore.get('config-media-item').displayAuthor ? (
<MediaAuthorBanner link={authorLink} thumb={authorThumb} name={props.author.name} published={props.published} /> <MediaAuthorBanner
link={authorLink}
thumb={authorThumb}
name={props.author.name}
published={props.published}
/>
) : null} ) : null}
<div className="media-content-banner"> <div className="media-content-banner">
@@ -298,14 +219,20 @@ export default function ViewerInfoContent(props) {
{categoriesContent.length ? ( {categoriesContent.length ? (
<MediaMetaField <MediaMetaField
value={categoriesContent} value={categoriesContent}
title={1 < categoriesContent.length ? translateString('Categories') : translateString('Category')} title={
1 < categoriesContent.length
? translateString('Categories')
: translateString('Category')
}
id="categories" id="categories"
/> />
) : null} ) : null}
{userCan.editMedia ? ( {userCan.editMedia ? (
<div className="media-author-actions"> <div className="media-author-actions">
{userCan.editMedia ? <EditMediaButton /> : null} {userCan.editMedia ? (
<EditMediaButton link={MediaPageStore.get('media-data').edit_url} />
) : null}
{userCan.deleteMedia ? ( {userCan.deleteMedia ? (
<PopupTrigger contentRef={popupContentRef}> <PopupTrigger contentRef={popupContentRef}>
@@ -320,14 +247,22 @@ export default function ViewerInfoContent(props) {
<PopupMain> <PopupMain>
<div className="popup-message"> <div className="popup-message">
<span className="popup-message-title">Media removal</span> <span className="popup-message-title">Media removal</span>
<span className="popup-message-main">You're willing to remove media permanently?</span> <span className="popup-message-main">
You're willing to remove media permanently?
</span>
</div> </div>
<hr /> <hr />
<span className="popup-message-bottom"> <span className="popup-message-bottom">
<button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}> <button
className="button-link cancel-comment-removal"
onClick={cancelMediaRemoval}
>
CANCEL CANCEL
</button> </button>
<button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}> <button
className="button-link proceed-comment-removal"
onClick={proceedMediaRemoval}
>
PROCEED PROCEED
</button> </button>
</span> </span>
@@ -339,7 +274,7 @@ export default function ViewerInfoContent(props) {
</div> </div>
</div> </div>
<CommentsList /> {!inEmbeddedApp() && <CommentsList />}
</div> </div>
); );
} }

View File

@@ -1,8 +1,16 @@
import React from 'react'; import React from 'react';
import { formatViewsNumber } from '../../utils/helpers/'; import { formatViewsNumber, inEmbeddedApp } from '../../utils/helpers/';
import { PageStore, MediaPageStore } from '../../utils/stores/'; import { PageStore, MediaPageStore } from '../../utils/stores/';
import { MemberContext, PlaylistsContext } from '../../utils/contexts/'; import { MemberContext, PlaylistsContext } from '../../utils/contexts/';
import { MediaLikeIcon, MediaDislikeIcon, OtherMediaDownloadLink, VideoMediaDownloadLink, MediaSaveButton, MediaShareButton, MediaMoreOptionsIcon } from '../media-actions/'; import {
MediaLikeIcon,
MediaDislikeIcon,
OtherMediaDownloadLink,
VideoMediaDownloadLink,
MediaSaveButton,
MediaShareButton,
MediaMoreOptionsIcon,
} from '../media-actions/';
import ViewerInfoTitleBanner from './ViewerInfoTitleBanner'; import ViewerInfoTitleBanner from './ViewerInfoTitleBanner';
import { translateString } from '../../utils/helpers/'; import { translateString } from '../../utils/helpers/';
@@ -74,7 +82,8 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
{displayViews ? ( {displayViews ? (
<div className="media-views"> <div className="media-views">
{formatViewsNumber(this.props.views, true)} {1 >= this.props.views ? translateString('view') : translateString('views')} {formatViewsNumber(this.props.views, true)}{' '}
{1 >= this.props.views ? translateString('view') : translateString('views')}
</div> </div>
) : null} ) : null}
@@ -82,9 +91,12 @@ export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
<div> <div>
{MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null} {MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null}
{MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null} {MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null}
{MemberContext._currentValue.can.shareMedia ? <MediaShareButton isVideo={true} /> : null} {!inEmbeddedApp() && MemberContext._currentValue.can.shareMedia ? (
<MediaShareButton isVideo={true} />
) : null}
{!MemberContext._currentValue.is.anonymous && {!inEmbeddedApp() &&
!MemberContext._currentValue.is.anonymous &&
MemberContext._currentValue.can.saveMedia && MemberContext._currentValue.can.saveMedia &&
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? ( -1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
<MediaSaveButton /> <MediaSaveButton />

View File

@@ -410,8 +410,12 @@ export default class VideoViewer extends React.PureComponent {
poster: this.videoPoster, poster: this.videoPoster,
previewSprite: previewSprite, previewSprite: previewSprite,
subtitlesInfo: this.props.data.subtitles_info, subtitlesInfo: this.props.data.subtitles_info,
enableAutoplay: !this.props.inEmbed,
inEmbed: this.props.inEmbed, inEmbed: this.props.inEmbed,
showTitle: this.props.showTitle,
showRelated: this.props.showRelated,
showUserAvatar: this.props.showUserAvatar,
linkTitle: this.props.linkTitle,
urlTimestamp: this.props.timestamp,
hasTheaterMode: !this.props.inEmbed, hasTheaterMode: !this.props.inEmbed,
hasNextLink: !!nextLink, hasNextLink: !!nextLink,
nextLink: nextLink, nextLink: nextLink,
@@ -435,9 +439,19 @@ export default class VideoViewer extends React.PureComponent {
VideoViewer.defaultProps = { VideoViewer.defaultProps = {
inEmbed: !0, inEmbed: !0,
showTitle: !0,
showRelated: !0,
showUserAvatar: !0,
linkTitle: !0,
timestamp: null,
siteUrl: PropTypes.string.isRequired, siteUrl: PropTypes.string.isRequired,
}; };
VideoViewer.propTypes = { VideoViewer.propTypes = {
inEmbed: PropTypes.bool, inEmbed: PropTypes.bool,
showTitle: PropTypes.bool,
showRelated: PropTypes.bool,
showUserAvatar: PropTypes.bool,
linkTitle: PropTypes.bool,
timestamp: PropTypes.number,
}; };

View File

@@ -23,6 +23,11 @@
transition-property: padding-left; transition-property: padding-left;
transition-duration: 0.2s; transition-duration: 0.2s;
} }
.embedded-app & {
padding-top: 0;
padding-left: 0;
}
} }
#page-profile-media, #page-profile-media,

View File

@@ -162,12 +162,16 @@ class ProfileSearchBar extends React.PureComponent {
if (!this.state.visibleForm) { if (!this.state.visibleForm) {
return ( return (
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.showForm}> <span
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }}
onClick={this.showForm}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">search</i> <i className="material-icons">search</i>
</CircleIconButton> </CircleIconButton>
{hasSearchText ? ( {hasSearchText ? (
<span style={{ <span
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -176,7 +180,8 @@ class ProfileSearchBar extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}}></span> }}
></span>
) : null} ) : null}
</span> </span>
); );
@@ -189,7 +194,8 @@ class ProfileSearchBar extends React.PureComponent {
<i className="material-icons">search</i> <i className="material-icons">search</i>
</CircleIconButton> </CircleIconButton>
{hasSearchText ? ( {hasSearchText ? (
<span style={{ <span
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -198,7 +204,8 @@ class ProfileSearchBar extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}}></span> }}
></span>
) : null} ) : null}
</span> </span>
<span> <span>
@@ -427,17 +434,32 @@ class NavMenuInlineTabs extends React.PureComponent {
{!['about', 'playlists'].includes(this.props.type) ? ( {!['about', 'playlists'].includes(this.props.type) ? (
<li className="media-search"> <li className="media-search">
<ProfileSearchBar onQueryChange={this.props.onQueryChange} toggleSearchField={this.onToggleSearchField} type={this.props.type} /> <ProfileSearchBar
onQueryChange={this.props.onQueryChange}
toggleSearchField={this.onToggleSearchField}
type={this.props.type}
/>
</li> </li>
) : null} ) : null}
{this.props.onToggleFiltersClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? ( {this.props.onToggleFiltersClick &&
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-filters-toggle"> <li className="media-filters-toggle">
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleFiltersClick} title={translateString('Filters')}> <span
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleFiltersClick}
title={translateString('Filters')}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">filter_list</i> <i className="material-icons">filter_list</i>
</CircleIconButton> </CircleIconButton>
{this.props.hasActiveFilters ? ( {this.props.hasActiveFilters ? (
<span style={{ <span
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -446,19 +468,31 @@ class NavMenuInlineTabs extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}}></span> }}
></span>
) : null} ) : null}
</span> </span>
</li> </li>
) : null} ) : null}
{this.props.onToggleTagsClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? ( {this.props.onToggleTagsClick &&
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-tags-toggle"> <li className="media-tags-toggle">
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleTagsClick} title={translateString('Tags')}> <span
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleTagsClick}
title={translateString('Tags')}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">local_offer</i> <i className="material-icons">local_offer</i>
</CircleIconButton> </CircleIconButton>
{this.props.hasActiveTags ? ( {this.props.hasActiveTags ? (
<span style={{ <span
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -467,19 +501,31 @@ class NavMenuInlineTabs extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}}></span> }}
></span>
) : null} ) : null}
</span> </span>
</li> </li>
) : null} ) : null}
{this.props.onToggleSortingClick && ['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? ( {this.props.onToggleSortingClick &&
['media', 'shared_by_me', 'shared_with_me'].includes(this.props.type) ? (
<li className="media-sorting-toggle"> <li className="media-sorting-toggle">
<span style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', position: 'relative' }} onClick={this.props.onToggleSortingClick} title={translateString('Sort By')}> <span
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
position: 'relative',
}}
onClick={this.props.onToggleSortingClick}
title={translateString('Sort By')}
>
<CircleIconButton buttonShadow={false}> <CircleIconButton buttonShadow={false}>
<i className="material-icons">swap_vert</i> <i className="material-icons">swap_vert</i>
</CircleIconButton> </CircleIconButton>
{this.props.hasActiveSort ? ( {this.props.hasActiveSort ? (
<span style={{ <span
style={{
position: 'absolute', position: 'absolute',
top: '8px', top: '8px',
right: '8px', right: '8px',
@@ -488,7 +534,8 @@ class NavMenuInlineTabs extends React.PureComponent {
borderRadius: '50%', borderRadius: '50%',
backgroundColor: 'var(--default-theme-color)', backgroundColor: 'var(--default-theme-color)',
border: '2px solid white', border: '2px solid white',
}}></span> }}
></span>
) : null} ) : null}
</span> </span>
</li> </li>
@@ -658,6 +705,7 @@ export default function ProfilePagesHeader(props) {
return ( return (
<div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}> <div ref={profilePageHeaderRef} className={'profile-page-header' + (fixedNav ? ' fixed-nav' : '')}>
{!props.hideChannelBanner && (
<span className="profile-banner-wrap"> <span className="profile-banner-wrap">
{props.author.banner_thumbnail_url ? ( {props.author.banner_thumbnail_url ? (
<span <span
@@ -685,14 +733,22 @@ export default function ProfilePagesHeader(props) {
<PopupMain> <PopupMain>
<div className="popup-message"> <div className="popup-message">
<span className="popup-message-title">Profile removal</span> <span className="popup-message-title">Profile removal</span>
<span className="popup-message-main">You're willing to remove profile permanently?</span> <span className="popup-message-main">
You're willing to remove profile permanently?
</span>
</div> </div>
<hr /> <hr />
<span className="popup-message-bottom"> <span className="popup-message-bottom">
<button className="button-link cancel-profile-removal" onClick={cancelProfileRemoval}> <button
className="button-link cancel-profile-removal"
onClick={cancelProfileRemoval}
>
CANCEL CANCEL
</button> </button>
<button className="button-link proceed-profile-removal" onClick={proceedMediaRemoval}> <button
className="button-link proceed-profile-removal"
onClick={proceedMediaRemoval}
>
PROCEED PROCEED
</button> </button>
</span> </span>
@@ -709,17 +765,22 @@ export default function ProfilePagesHeader(props) {
) )
) : null} ) : null}
</span> </span>
)}
<div className="profile-info-nav-wrap"> <div className="profile-info-nav-wrap">
{props.author.thumbnail_url || props.author.name ? ( {props.author.thumbnail_url || props.author.name ? (
<div className="profile-info"> <div className="profile-info">
<div className="profile-info-inner"> <div className="profile-info-inner">
<div>{props.author.thumbnail_url ? <img src={props.author.thumbnail_url} alt="" /> : null}</div> <div>
{props.author.thumbnail_url ? <img src={props.author.thumbnail_url} alt="" /> : null}
</div>
<div> <div>
{props.author.name ? ( {props.author.name ? (
<div className="profile-name-edit-wrapper"> <div className="profile-name-edit-wrapper">
<h1>{props.author.name}</h1> <h1>{props.author.name}</h1>
{userCanEditProfile && !userIsAuthor ? <EditProfileButton link={ProfilePageStore.get('author-data').edit_url} /> : null} {userCanEditProfile && !userIsAuthor ? (
<EditProfileButton link={ProfilePageStore.get('author-data').edit_url} />
) : null}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -41,7 +41,7 @@ export const EmbedPage: React.FC = () => {
}, []); }, []);
return ( return (
<div className="embed-wrap" style={wrapperStyles}> <div className="embed-wrap media-embed-wrap" style={wrapperStyles}>
{failedMediaLoad && ( {failedMediaLoad && (
<div className="player-container player-container-error" style={containerStyles}> <div className="player-container player-container-error" style={containerStyles}>
<div className="player-container-inner" style={containerStyles}> <div className="player-container-inner" style={containerStyles}>
@@ -59,9 +59,32 @@ export const EmbedPage: React.FC = () => {
{loadedVideo && ( {loadedVideo && (
<SiteConsumer> <SiteConsumer>
{(site) => ( {(site) => {
<VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} containerStyles={containerStyles} /> const urlParams = new URLSearchParams(window.location.search);
)} const urlShowTitle = urlParams.get('showTitle');
const showTitle = urlShowTitle !== '0';
const urlShowRelated = urlParams.get('showRelated');
const showRelated = urlShowRelated !== '0';
const urlShowUserAvatar = urlParams.get('showUserAvatar');
const showUserAvatar = urlShowUserAvatar !== '0';
const urlLinkTitle = urlParams.get('linkTitle');
const linkTitle = urlLinkTitle !== '0';
const urlTimestamp = urlParams.get('t');
const timestamp = urlTimestamp ? parseInt(urlTimestamp, 10) : null;
return (
<VideoViewer
data={MediaPageStore.get('media-data')}
siteUrl={site.url}
containerStyles={containerStyles}
showTitle={showTitle}
showRelated={showRelated}
showUserAvatar={showUserAvatar}
linkTitle={linkTitle}
timestamp={timestamp}
/>
);
}}
</SiteConsumer> </SiteConsumer>
)} )}
</div> </div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import UrlParse from 'url-parse'; import UrlParse from 'url-parse';
import { ApiUrlContext, MemberContext, SiteContext } from '../utils/contexts/'; import { ApiUrlContext, MemberContext, SiteContext } from '../utils/contexts/';
import { formatInnerLink, csrfToken, postRequest } from '../utils/helpers/'; import { formatInnerLink, csrfToken, postRequest, inEmbeddedApp } from '../utils/helpers/';
import { PageActions } from '../utils/actions/'; import { PageActions } from '../utils/actions/';
import { PageStore, ProfilePageStore } from '../utils/stores/'; import { PageStore, ProfilePageStore } from '../utils/stores/';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
@@ -268,7 +268,7 @@ export class ProfileAboutPage extends ProfileMediaPage {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" hideChannelBanner={inEmbeddedApp()} />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}> <ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@@ -28,7 +29,7 @@ export class ProfileHistoryPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" hideChannelBanner={inEmbeddedApp()} />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@@ -28,7 +29,7 @@ export class ProfileLikedPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" hideChannelBanner={inEmbeddedApp()} />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts'; import { ApiUrlContext, LinksConsumer, MemberContext } from '../utils/contexts';
import { PageStore, ProfilePageStore } from '../utils/stores'; import { PageStore, ProfilePageStore } from '../utils/stores';
import { ProfilePageActions, PageActions } from '../utils/actions'; import { ProfilePageActions, PageActions } from '../utils/actions';
import { translateString } from '../utils/helpers/'; import { inEmbeddedApp, translateString } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@@ -120,7 +120,13 @@ export class ProfileMediaPage extends Page {
if (author) { if (author) {
if (this.state.query) { if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + this.state.filterArgs; requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + this.state.filterArgs;
} }
@@ -171,7 +177,13 @@ export class ProfileMediaPage extends Page {
let requestUrl; let requestUrl;
if (newQuery) { if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&q=' + encodeURIComponent(newQuery) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + this.state.filterArgs; requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + this.state.filterArgs;
} }
@@ -212,37 +224,55 @@ export class ProfileMediaPage extends Page {
this.setState({ this.setState({
showConfirmModal: true, showConfirmModal: true,
pendingAction: action, pendingAction: action,
confirmMessage: translateString('You are going to delete') + ` ${selectedCount} ` + translateString('media, are you sure?'), confirmMessage:
translateString('You are going to delete') +
` ${selectedCount} ` +
translateString('media, are you sure?'),
}); });
} else if (action === 'enable-comments') { } else if (action === 'enable-comments') {
this.setState({ this.setState({
showConfirmModal: true, showConfirmModal: true,
pendingAction: action, pendingAction: action,
confirmMessage: translateString('You are going to enable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'), confirmMessage:
translateString('You are going to enable comments to') +
` ${selectedCount} ` +
translateString('media, are you sure?'),
}); });
} else if (action === 'disable-comments') { } else if (action === 'disable-comments') {
this.setState({ this.setState({
showConfirmModal: true, showConfirmModal: true,
pendingAction: action, pendingAction: action,
confirmMessage: translateString('You are going to disable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'), confirmMessage:
translateString('You are going to disable comments to') +
` ${selectedCount} ` +
translateString('media, are you sure?'),
}); });
} else if (action === 'enable-download') { } else if (action === 'enable-download') {
this.setState({ this.setState({
showConfirmModal: true, showConfirmModal: true,
pendingAction: action, pendingAction: action,
confirmMessage: translateString('You are going to enable download for') + ` ${selectedCount} ` + translateString('media, are you sure?'), confirmMessage:
translateString('You are going to enable download for') +
` ${selectedCount} ` +
translateString('media, are you sure?'),
}); });
} else if (action === 'disable-download') { } else if (action === 'disable-download') {
this.setState({ this.setState({
showConfirmModal: true, showConfirmModal: true,
pendingAction: action, pendingAction: action,
confirmMessage: translateString('You are going to disable download for') + ` ${selectedCount} ` + translateString('media, are you sure?'), confirmMessage:
translateString('You are going to disable download for') +
` ${selectedCount} ` +
translateString('media, are you sure?'),
}); });
} else if (action === 'copy-media') { } else if (action === 'copy-media') {
this.setState({ this.setState({
showConfirmModal: true, showConfirmModal: true,
pendingAction: action, pendingAction: action,
confirmMessage: translateString('You are going to copy') + ` ${selectedCount} ` + translateString('media, are you sure?'), confirmMessage:
translateString('You are going to copy') +
` ${selectedCount} ` +
translateString('media, are you sure?'),
}); });
} else if (action === 'add-remove-coviewers') { } else if (action === 'add-remove-coviewers') {
this.setState({ this.setState({
@@ -337,7 +367,8 @@ export class ProfileMediaPage extends Page {
return response.json(); return response.json();
}) })
.then((data) => { .then((data) => {
const message = selectedCount === 1 const message =
selectedCount === 1
? translateString('The media was deleted successfully.') ? translateString('The media was deleted successfully.')
: translateString('Successfully deleted') + ` ${selectedCount} ` + translateString('media.'); : translateString('Successfully deleted') + ` ${selectedCount} ` + translateString('media.');
this.showNotification(message); this.showNotification(message);
@@ -590,10 +621,18 @@ export class ProfileMediaPage extends Page {
this.setState({ selectedTag: tag }, () => { this.setState({ selectedTag: tag }, () => {
// Apply tag filter // Apply tag filter
this.onFiltersUpdate({ this.onFiltersUpdate({
media_type: this.state.filterArgs.includes('media_type') ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] : null, media_type: this.state.filterArgs.includes('media_type')
upload_date: this.state.filterArgs.includes('upload_date') ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] : null, ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1]
duration: this.state.filterArgs.includes('duration') ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] : null, : null,
publish_state: this.state.filterArgs.includes('publish_state') ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] : null, upload_date: this.state.filterArgs.includes('upload_date')
? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1]
: null,
duration: this.state.filterArgs.includes('duration')
? this.state.filterArgs.match(/duration=([^&]*)/)?.[1]
: null,
publish_state: this.state.filterArgs.includes('publish_state')
? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1]
: null,
sort_by: this.state.selectedSort, sort_by: this.state.selectedSort,
tag: tag, tag: tag,
}); });
@@ -604,10 +643,18 @@ export class ProfileMediaPage extends Page {
this.setState({ selectedSort: sortOption }, () => { this.setState({ selectedSort: sortOption }, () => {
// Apply sort filter // Apply sort filter
this.onFiltersUpdate({ this.onFiltersUpdate({
media_type: this.state.filterArgs.includes('media_type') ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1] : null, media_type: this.state.filterArgs.includes('media_type')
upload_date: this.state.filterArgs.includes('upload_date') ? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1] : null, ? this.state.filterArgs.match(/media_type=([^&]*)/)?.[1]
duration: this.state.filterArgs.includes('duration') ? this.state.filterArgs.match(/duration=([^&]*)/)?.[1] : null, : null,
publish_state: this.state.filterArgs.includes('publish_state') ? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1] : null, upload_date: this.state.filterArgs.includes('upload_date')
? this.state.filterArgs.match(/upload_date=([^&]*)/)?.[1]
: null,
duration: this.state.filterArgs.includes('duration')
? this.state.filterArgs.match(/duration=([^&]*)/)?.[1]
: null,
publish_state: this.state.filterArgs.includes('publish_state')
? this.state.filterArgs.match(/publish_state=([^&]*)/)?.[1]
: null,
sort_by: sortOption, sort_by: sortOption,
tag: this.state.selectedTag, tag: this.state.selectedTag,
}); });
@@ -707,9 +754,16 @@ export class ProfileMediaPage extends Page {
let requestUrl; let requestUrl;
if (this.state.query) { if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + this.state.filterArgs;
} }
this.setState({ this.setState({
@@ -851,7 +905,10 @@ export class ProfileMediaPage extends Page {
onResponseDataLoaded(responseData) { onResponseDataLoaded(responseData) {
// Extract tags from response // Extract tags from response
if (responseData && responseData.tags) { if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag); const tags = responseData.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
this.setState({ availableTags: tags }); this.setState({ availableTags: tags });
} }
} }
@@ -862,12 +919,12 @@ export class ProfileMediaPage extends Page {
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username; const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active (excluding default sort and tags) // Check if any filters are active (excluding default sort and tags)
const hasActiveFilters = this.state.filterArgs && ( const hasActiveFilters =
this.state.filterArgs.includes('media_type=') || this.state.filterArgs &&
(this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') || this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') || this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state=') this.state.filterArgs.includes('publish_state='));
);
const hasActiveTags = this.state.selectedTag && this.state.selectedTag !== 'all'; const hasActiveTags = this.state.selectedTag && this.state.selectedTag !== 'all';
const hasActiveSort = this.state.selectedSort && this.state.selectedSort !== 'date_added_desc'; const hasActiveSort = this.state.selectedSort && this.state.selectedSort !== 'date_added_desc';
@@ -885,6 +942,7 @@ export class ProfileMediaPage extends Page {
hasActiveFilters={hasActiveFilters} hasActiveFilters={hasActiveFilters}
hasActiveTags={hasActiveTags} hasActiveTags={hasActiveTags}
hasActiveSort={hasActiveSort} hasActiveSort={hasActiveSort}
hideChannelBanner={inEmbeddedApp()}
/> />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
@@ -900,8 +958,18 @@ export class ProfileMediaPage extends Page {
onDeselectAll={this.handleDeselectAll} onDeselectAll={this.handleDeselectAll}
showAddMediaButton={isMediaAuthor} showAddMediaButton={isMediaAuthor}
> >
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} selectedTag={this.state.selectedTag} selectedSort={this.state.selectedSort} /> <ProfileMediaFilters
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} /> hidden={this.state.hiddenFilters}
tags={this.state.availableTags}
onFiltersUpdate={this.onFiltersUpdate}
selectedTag={this.state.selectedTag}
selectedSort={this.state.selectedSort}
/>
<ProfileMediaTags
hidden={this.state.hiddenTags}
tags={this.state.availableTags}
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} /> <ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync <LazyLoadItemListAsync
key={`${this.state.requestUrl}-${this.state.listKey}`} key={`${this.state.requestUrl}-${this.state.listKey}`}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { ApiUrlConsumer } from '../utils/contexts/'; import { ApiUrlConsumer } from '../utils/contexts/';
import { PageStore } from '../utils/stores/'; import { PageStore } from '../utils/stores/';
import { inEmbeddedApp } from '../utils/helpers/';
import { MediaListWrapper } from '../components/MediaListWrapper'; import { MediaListWrapper } from '../components/MediaListWrapper';
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader'; import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent'; import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
@@ -30,7 +31,7 @@ export class ProfilePlaylistsPage extends ProfileMediaPage {
pageContent() { pageContent() {
return [ return [
this.state.author ? ( this.state.author ? (
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" /> <ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" hideChannelBanner={inEmbeddedApp()} />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">

View File

@@ -11,7 +11,7 @@ import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFi
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags'; import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting'; import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { BulkActionsModals } from '../components/BulkActionsModals'; import { BulkActionsModals } from '../components/BulkActionsModals';
import { translateString } from '../utils/helpers'; import { inEmbeddedApp, translateString } from '../utils/helpers';
import { withBulkActions } from '../utils/hoc/withBulkActions'; import { withBulkActions } from '../utils/hoc/withBulkActions';
import { Page } from './_Page'; import { Page } from './_Page';
@@ -24,9 +24,7 @@ function EmptySharedByMe(props) {
{(links) => ( {(links) => (
<div className="empty-media empty-channel-media"> <div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div> <div className="welcome-title">No shared media</div>
<div className="start-uploading"> <div className="start-uploading">Media that you have shared with others will show up here.</div>
Media that you have shared with others will show up here.
</div>
</div> </div>
)} )}
</LinksConsumer> </LinksConsumer>
@@ -81,9 +79,20 @@ class ProfileSharedByMePage extends Page {
if (author) { if (author) {
if (this.state.query) { if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me' + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_by_me' +
this.state.filterArgs;
} }
} }
@@ -132,9 +141,20 @@ class ProfileSharedByMePage extends Page {
let requestUrl; let requestUrl;
if (newQuery) { if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me' +
this.state.filterArgs;
} }
let title = this.state.title; let title = this.state.title;
@@ -290,9 +310,20 @@ class ProfileSharedByMePage extends Page {
let requestUrl; let requestUrl;
if (this.state.query) { if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_by_me' +
this.state.filterArgs;
} }
this.setState({ this.setState({
@@ -304,7 +335,10 @@ class ProfileSharedByMePage extends Page {
onResponseDataLoaded(responseData) { onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) { if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag); const tags = responseData.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
this.setState({ availableTags: tags }); this.setState({ availableTags: tags });
} }
} }
@@ -315,12 +349,12 @@ class ProfileSharedByMePage extends Page {
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username; const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active // Check if any filters are active
const hasActiveFilters = this.state.filterArgs && ( const hasActiveFilters =
this.state.filterArgs.includes('media_type=') || this.state.filterArgs &&
(this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') || this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') || this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state=') this.state.filterArgs.includes('publish_state='));
);
return [ return [
this.state.author ? ( this.state.author ? (
@@ -335,6 +369,7 @@ class ProfileSharedByMePage extends Page {
hasActiveFilters={hasActiveFilters} hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'} hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'} hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
hideChannelBanner={inEmbeddedApp()}
/> />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
@@ -349,8 +384,16 @@ class ProfileSharedByMePage extends Page {
onSelectAll={this.props.bulkActions.handleSelectAll} onSelectAll={this.props.bulkActions.handleSelectAll}
onDeselectAll={this.props.bulkActions.handleDeselectAll} onDeselectAll={this.props.bulkActions.handleDeselectAll}
> >
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} /> <ProfileMediaFilters
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} /> hidden={this.state.hiddenFilters}
tags={this.state.availableTags}
onFiltersUpdate={this.onFiltersUpdate}
/>
<ProfileMediaTags
hidden={this.state.hiddenTags}
tags={this.state.availableTags}
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} /> <ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync <LazyLoadItemListAsync
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`} key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}

View File

@@ -10,7 +10,7 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters'; import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags'; import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting'; import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
import { translateString } from '../utils/helpers'; import { inEmbeddedApp, translateString } from '../utils/helpers';
import { Page } from './_Page'; import { Page } from './_Page';
@@ -22,9 +22,7 @@ function EmptySharedWithMe(props) {
{(links) => ( {(links) => (
<div className="empty-media empty-channel-media"> <div className="empty-media empty-channel-media">
<div className="welcome-title">No shared media</div> <div className="welcome-title">No shared media</div>
<div className="start-uploading"> <div className="start-uploading">Media that others have shared with you will show up here.</div>
Media that others have shared with you will show up here.
</div>
</div> </div>
)} )}
</LinksConsumer> </LinksConsumer>
@@ -79,9 +77,20 @@ export class ProfileSharedWithMePage extends Page {
if (author) { if (author) {
if (this.state.query) { if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me' + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
author.id +
'&show=shared_with_me' +
this.state.filterArgs;
} }
} }
@@ -130,9 +139,20 @@ export class ProfileSharedWithMePage extends Page {
let requestUrl; let requestUrl;
if (newQuery) { if (newQuery) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(newQuery) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me' +
this.state.filterArgs;
} }
let title = this.state.title; let title = this.state.title;
@@ -288,9 +308,20 @@ export class ProfileSharedWithMePage extends Page {
let requestUrl; let requestUrl;
if (this.state.query) { if (this.state.query) {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me&q=' +
encodeURIComponent(this.state.query) +
this.state.filterArgs;
} else { } else {
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs; requestUrl =
ApiUrlContext._currentValue.media +
'?author=' +
this.state.author.id +
'&show=shared_with_me' +
this.state.filterArgs;
} }
this.setState({ this.setState({
@@ -302,7 +333,10 @@ export class ProfileSharedWithMePage extends Page {
onResponseDataLoaded(responseData) { onResponseDataLoaded(responseData) {
if (responseData && responseData.tags) { if (responseData && responseData.tags) {
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag); const tags = responseData.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
this.setState({ availableTags: tags }); this.setState({ availableTags: tags });
} }
} }
@@ -313,12 +347,12 @@ export class ProfileSharedWithMePage extends Page {
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username; const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
// Check if any filters are active // Check if any filters are active
const hasActiveFilters = this.state.filterArgs && ( const hasActiveFilters =
this.state.filterArgs.includes('media_type=') || this.state.filterArgs &&
(this.state.filterArgs.includes('media_type=') ||
this.state.filterArgs.includes('upload_date=') || this.state.filterArgs.includes('upload_date=') ||
this.state.filterArgs.includes('duration=') || this.state.filterArgs.includes('duration=') ||
this.state.filterArgs.includes('publish_state=') this.state.filterArgs.includes('publish_state='));
);
return [ return [
this.state.author ? ( this.state.author ? (
@@ -333,16 +367,22 @@ export class ProfileSharedWithMePage extends Page {
hasActiveFilters={hasActiveFilters} hasActiveFilters={hasActiveFilters}
hasActiveTags={this.state.selectedTag !== 'all'} hasActiveTags={this.state.selectedTag !== 'all'}
hasActiveSort={this.state.selectedSort !== 'date_added_desc'} hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
hideChannelBanner={inEmbeddedApp()}
/> />
) : null, ) : null,
this.state.author ? ( this.state.author ? (
<ProfilePagesContent key="ProfilePagesContent"> <ProfilePagesContent key="ProfilePagesContent">
<MediaListWrapper <MediaListWrapper title={this.state.title} className="items-list-ver">
title={this.state.title} <ProfileMediaFilters
className="items-list-ver" hidden={this.state.hiddenFilters}
> tags={this.state.availableTags}
<ProfileMediaFilters hidden={this.state.hiddenFilters} tags={this.state.availableTags} onFiltersUpdate={this.onFiltersUpdate} /> onFiltersUpdate={this.onFiltersUpdate}
<ProfileMediaTags hidden={this.state.hiddenTags} tags={this.state.availableTags} onTagSelect={this.onTagSelect} /> />
<ProfileMediaTags
hidden={this.state.hiddenTags}
tags={this.state.availableTags}
onTagSelect={this.onTagSelect}
/>
<ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} /> <ProfileMediaSorting hidden={this.state.hiddenSorting} onSortSelect={this.onSortSelect} />
<LazyLoadItemListAsync <LazyLoadItemListAsync
key={this.state.requestUrl} key={this.state.requestUrl}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { PageStore, MediaPageStore } from '../utils/stores/'; import { PageStore, MediaPageStore } from '../utils/stores/';
import { MediaPageActions } from '../utils/actions/'; import { MediaPageActions } from '../utils/actions/';
import { inEmbeddedApp } from '../utils/helpers/';
import ViewerError from '../components/media-page/ViewerError'; import ViewerError from '../components/media-page/ViewerError';
import ViewerInfo from '../components/media-page/ViewerInfo'; import ViewerInfo from '../components/media-page/ViewerInfo';
import ViewerSidebar from '../components/media-page/ViewerSidebar'; import ViewerSidebar from '../components/media-page/ViewerSidebar';
@@ -86,7 +87,7 @@ export class _MediaPage extends Page {
{!this.state.infoAndSidebarViewType {!this.state.infoAndSidebarViewType
? [ ? [
<ViewerInfo key="viewer-info" />, <ViewerInfo key="viewer-info" />,
this.state.pagePlaylistLoaded ? ( !inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar <ViewerSidebar
key="viewer-sidebar" key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')} mediaId={MediaPageStore.get('media-id')}
@@ -95,7 +96,7 @@ export class _MediaPage extends Page {
) : null, ) : null,
] ]
: [ : [
this.state.pagePlaylistLoaded ? ( !inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar <ViewerSidebar
key="viewer-sidebar" key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')} mediaId={MediaPageStore.get('media-id')}

View File

@@ -2,6 +2,7 @@ import React from 'react';
// FIXME: 'VideoViewerStore' is used only in case of video media, but is included in every media page code. // FIXME: 'VideoViewerStore' is used only in case of video media, but is included in every media page code.
import { PageStore, MediaPageStore, VideoViewerStore } from '../utils/stores/'; import { PageStore, MediaPageStore, VideoViewerStore } from '../utils/stores/';
import { MediaPageActions } from '../utils/actions/'; import { MediaPageActions } from '../utils/actions/';
import { inEmbeddedApp } from '../utils/helpers/';
import ViewerInfoVideo from '../components/media-page/ViewerInfoVideo'; import ViewerInfoVideo from '../components/media-page/ViewerInfoVideo';
import ViewerError from '../components/media-page/ViewerError'; import ViewerError from '../components/media-page/ViewerError';
import ViewerSidebar from '../components/media-page/ViewerSidebar'; import ViewerSidebar from '../components/media-page/ViewerSidebar';
@@ -54,7 +55,8 @@ export class _VideoMediaPage extends Page {
} }
onMediaLoad() { onMediaLoad() {
const isVideoMedia = 'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type'); const isVideoMedia =
'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type');
if (isVideoMedia) { if (isVideoMedia) {
this.onViewerModeChange = this.onViewerModeChange.bind(this); this.onViewerModeChange = this.onViewerModeChange.bind(this);
@@ -102,7 +104,7 @@ export class _VideoMediaPage extends Page {
{!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode) {!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode)
? [ ? [
<ViewerInfoVideo key="viewer-info" />, <ViewerInfoVideo key="viewer-info" />,
this.state.pagePlaylistLoaded ? ( !inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar <ViewerSidebar
key="viewer-sidebar" key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')} mediaId={MediaPageStore.get('media-id')}
@@ -111,7 +113,7 @@ export class _VideoMediaPage extends Page {
) : null, ) : null,
] ]
: [ : [
this.state.pagePlaylistLoaded ? ( !inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
<ViewerSidebar <ViewerSidebar
key="viewer-sidebar" key="viewer-sidebar"
mediaId={MediaPageStore.get('media-id')} mediaId={MediaPageStore.get('media-id')}

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { BrowserCache } from '../classes/'; import { BrowserCache } from '../classes/';
import { PageStore } from '../stores/'; import { PageStore } from '../stores/';
import { addClassname, removeClassname } from '../helpers/'; import { addClassname, removeClassname, inEmbeddedApp } from '../helpers/';
import SiteContext from './SiteContext'; import SiteContext from './SiteContext';
let slidingSidebarTimeout; let slidingSidebarTimeout;
@@ -45,7 +45,10 @@ export const LayoutProvider = ({ children }) => {
const site = useContext(SiteContext); const site = useContext(SiteContext);
const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400); const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400);
const enabledSidebar = !!(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar')); const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media', []);
const isEmbeddedApp = useMemo(() => inEmbeddedApp(), []);
const enabledSidebar = Boolean(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar'));
const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar')); const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false); const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
@@ -61,28 +64,27 @@ export const LayoutProvider = ({ children }) => {
}; };
useEffect(() => { useEffect(() => {
if (visibleSidebar) { if (!isEmbeddedApp && visibleSidebar) {
addClassname(document.body, 'visible-sidebar'); addClassname(document.body, 'visible-sidebar');
} else { } else {
removeClassname(document.body, 'visible-sidebar'); removeClassname(document.body, 'visible-sidebar');
} }
if ('media' !== PageStore.get('current-page') && 1023 < window.innerWidth) {
if (!isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth) {
cache.set('visible-sidebar', visibleSidebar); cache.set('visible-sidebar', visibleSidebar);
} }
}, [visibleSidebar]); }, [isEmbeddedApp, isMediaPage, visibleSidebar]);
useEffect(() => { useEffect(() => {
PageStore.once('page_init', () => { PageStore.once('page_init', () => {
if ('media' === PageStore.get('current-page')) { if (isEmbeddedApp || isMediaPage) {
setVisibleSidebar(false); setVisibleSidebar(false);
removeClassname(document.body, 'visible-sidebar'); removeClassname(document.body, 'visible-sidebar');
} }
}); });
setVisibleSidebar( setVisibleSidebar(
'media' !== PageStore.get('current-page') && !isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth && (null === visibleSidebar || visibleSidebar)
1023 < window.innerWidth &&
(null === visibleSidebar || visibleSidebar)
); );
}, []); }, []);

View File

@@ -0,0 +1,20 @@
export function inEmbeddedApp() {
try {
const params = new URL(globalThis.location.href).searchParams;
const mode = params.get('mode');
if (mode === 'embed_mode') {
sessionStorage.setItem('media_cms_embed_mode', 'true');
return true;
}
if (mode === 'standard') {
sessionStorage.removeItem('media_cms_embed_mode');
return false;
}
return sessionStorage.getItem('media_cms_embed_mode') === 'true';
} catch (e) {
return false;
}
}

View File

@@ -14,3 +14,4 @@ export * from './quickSort';
export * from './requests'; export * from './requests';
export { translateString } from './translate'; export { translateString } from './translate';
export { replaceString } from './replacementStrings'; export { replaceString } from './replacementStrings';
export * from './embeddedApp';

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { LayoutProvider } from './contexts/LayoutContext'; import { LayoutProvider } from './contexts/LayoutContext';
import { UserProvider } from './contexts/UserContext'; import { UserProvider } from './contexts/UserContext';
import { inEmbeddedApp } from './helpers';
const AppProviders = ({ children }) => ( const AppProviders = ({ children }) => (
<LayoutProvider> <LayoutProvider>
@@ -15,9 +16,27 @@ const AppProviders = ({ children }) => (
import { PageHeader, PageSidebar } from '../components/page-layout'; import { PageHeader, PageSidebar } from '../components/page-layout';
export function renderPage(idSelector, PageComponent) { export function renderPage(idSelector, PageComponent) {
if (inEmbeddedApp()) {
globalThis.document.body.classList.add('embedded-app');
globalThis.document.body.classList.remove('visible-sidebar');
const appContent = idSelector ? document.getElementById(idSelector) : undefined;
if (appContent && PageComponent) {
ReactDOM.render(
<AppProviders>
<PageComponent />
</AppProviders>,
appContent
);
}
return;
}
const appContent = idSelector ? document.getElementById(idSelector) : undefined;
const appHeader = document.getElementById('app-header'); const appHeader = document.getElementById('app-header');
const appSidebar = document.getElementById('app-sidebar'); const appSidebar = document.getElementById('app-sidebar');
const appContent = idSelector ? document.getElementById(idSelector) : undefined;
if (appContent && PageComponent) { if (appContent && PageComponent) {
ReactDOM.render( ReactDOM.render(

302
install-rhel.sh Normal file
View File

@@ -0,0 +1,302 @@
#!/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 Normal file
View File

@@ -0,0 +1,140 @@
#!/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"''

View File

@@ -1,6 +0,0 @@
"""
LTI 1.3 Integration for MediaCMS
Enables integration with Learning Management Systems like Moodle
"""
default_app_config = 'lti.apps.LtiConfig'

View File

@@ -1,461 +0,0 @@
"""
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)

View File

@@ -1,239 +0,0 @@
"""
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

View File

@@ -1,16 +0,0 @@
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

View File

@@ -1,217 +0,0 @@
"""
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)}")

View File

@@ -1,102 +0,0 @@
# 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)

View File

@@ -1,347 +0,0 @@
"""
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

View File

@@ -1,45 +0,0 @@
"""
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()

View File

@@ -1,190 +0,0 @@
# 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')},
),
]

View File

@@ -1,218 +0,0 @@
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

View File

@@ -1,178 +0,0 @@
"""
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

View File

@@ -1,28 +0,0 @@
"""
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'),
]

View File

@@ -1,704 +0,0 @@
"""
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 '',
}
)

View File

@@ -1,46 +0,0 @@
# 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.

View File

@@ -1,54 +0,0 @@
# 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.

View File

@@ -1,223 +0,0 @@
# 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!**

View File

@@ -1,105 +0,0 @@
# 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

View File

@@ -1,22 +0,0 @@
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

View File

@@ -1,264 +0,0 @@
# 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!**

View File

@@ -1,39 +0,0 @@
<?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();

View File

@@ -1,45 +0,0 @@
<?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';
}
}

View File

@@ -1,136 +0,0 @@
<?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;
}
}

View File

@@ -1,55 +0,0 @@
<?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.';

Some files were not shown because too many files have changed in this diff Show More