mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-03-10 06:57:25 -04:00
Compare commits
7 Commits
feat-lti-i
...
v7.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f28b00a63 | ||
|
|
74952f68d7 | ||
|
|
7950a4655a | ||
|
|
b76282f9e4 | ||
|
|
b405a04e34 | ||
|
|
76a27ae256 | ||
|
|
223e87073f |
22
.github/workflows/semantic-pull-request.yaml
vendored
Normal file
22
.github/workflows/semantic-pull-request.yaml
vendored
Normal 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
47
.github/workflows/semantic-release.yaml
vendored
Normal 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 }}
|
||||
@@ -1,3 +1,4 @@
|
||||
/templates/cms/*
|
||||
/templates/*.html
|
||||
*.scss
|
||||
*.scss
|
||||
/frontend/
|
||||
100
.releaserc.json
Normal file
100
.releaserc.json
Normal 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}"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
31
CHANGELOG.md
Normal file
31
CHANGELOG.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [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
23
HISTORY.md
Normal 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)
|
||||
253
LTI_SETUP.md
253
LTI_SETUP.md
@@ -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
|
||||
@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
|
||||
|
||||
## Technology
|
||||
|
||||
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, 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
|
||||
|
||||
@@ -24,7 +24,6 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
|
||||
@@ -300,7 +300,6 @@ INSTALLED_APPS = [
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"lti.apps.LtiConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
@@ -556,7 +555,6 @@ DJANGO_ADMIN_URL = "admin/"
|
||||
USE_SAML = False
|
||||
USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
USE_LTI = False # Enable LTI 1.3 integration
|
||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
|
||||
USE_ROUNDED_CORNERS = True
|
||||
@@ -652,19 +650,3 @@ if USERS_NEEDS_TO_BE_APPROVED:
|
||||
)
|
||||
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
|
||||
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
|
||||
|
||||
|
||||
# LTI 1.3 Integration Settings
|
||||
if USE_LTI:
|
||||
# Session timeout for LTI launches (seconds)
|
||||
LTI_SESSION_TIMEOUT = 3600 # 1 hour
|
||||
|
||||
# Cookie settings required for iframe embedding from LMS
|
||||
# IMPORTANT: Requires HTTPS to be enabled
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SAMESITE = 'None'
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||
# Use cached_db for reliability - stores in both cache AND database
|
||||
# This prevents session loss during multiple simultaneous LTI launches
|
||||
|
||||
@@ -25,7 +25,6 @@ urlpatterns = [
|
||||
re_path(r"^", include("files.urls")),
|
||||
re_path(r"^", include("users.urls")),
|
||||
re_path(r"^accounts/", include("allauth.urls")),
|
||||
re_path(r"^lti/", include("lti.urls")),
|
||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
|
||||
@@ -1 +1 @@
|
||||
VERSION = "7.8124"
|
||||
VERSION = "7.7"
|
||||
|
||||
@@ -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 {
|
||||
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-Expose-Headers' 'Content-Length,Content-Range';
|
||||
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
include /etc/nginx/sites-enabled/uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-enabled/default
|
||||
cp deploy/docker/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
|
||||
cp deploy/docker/nginx.conf /etc/nginx/
|
||||
|
||||
#### 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
|
||||
|
||||
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
|
||||
echo "Enabling gunicorn app server"
|
||||
cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
|
||||
echo "Enabling uwsgi app server"
|
||||
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf
|
||||
fi
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ else
|
||||
echo "There is no script $PRE_START_PATH"
|
||||
fi
|
||||
|
||||
# Start Supervisor, with Nginx and Gunicorn
|
||||
# Start Supervisor, with Nginx and uWSGI
|
||||
echo "Starting server using supervisord..."
|
||||
|
||||
exec /usr/bin/supervisord
|
||||
|
||||
@@ -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
|
||||
9
deploy/docker/supervisord/supervisord-uwsgi.conf
Normal file
9
deploy/docker/supervisord/supervisord-uwsgi.conf
Normal 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
24
deploy/docker/uwsgi.ini
Normal 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
|
||||
16
deploy/docker/uwsgi_params
Normal file
16
deploy/docker/uwsgi_params
Normal 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;
|
||||
22
deploy/local_install/celery_beat.service
Normal file
22
deploy/local_install/celery_beat.service
Normal 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
|
||||
|
||||
29
deploy/local_install/celery_long.service
Normal file
29
deploy/local_install/celery_long.service
Normal 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
|
||||
|
||||
39
deploy/local_install/celery_short.service
Normal file
39
deploy/local_install/celery_short.service
Normal 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
|
||||
|
||||
13
deploy/local_install/dhparams.pem
Normal file
13
deploy/local_install/dhparams.pem
Normal 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-----
|
||||
84
deploy/local_install/mediacms.io
Normal file
84
deploy/local_install/mediacms.io
Normal 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;
|
||||
}
|
||||
}
|
||||
58
deploy/local_install/mediacms.io_fullchain.pem
Normal file
58
deploy/local_install/mediacms.io_fullchain.pem
Normal 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-----
|
||||
28
deploy/local_install/mediacms.io_privkey.pem
Normal file
28
deploy/local_install/mediacms.io_privkey.pem
Normal 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-----
|
||||
13
deploy/local_install/mediacms.service
Normal file
13
deploy/local_install/mediacms.service
Normal 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
|
||||
7
deploy/local_install/mediacms_logrorate
Normal file
7
deploy/local_install/mediacms_logrorate
Normal file
@@ -0,0 +1,7 @@
|
||||
/home/mediacms.io/mediacms/logs/*.log {
|
||||
weekly
|
||||
missingok
|
||||
rotate 7
|
||||
compress
|
||||
notifempty
|
||||
}
|
||||
38
deploy/local_install/nginx.conf
Normal file
38
deploy/local_install/nginx.conf
Normal 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/*;
|
||||
}
|
||||
|
||||
34
deploy/local_install/selinux-mediacms.te
Normal file
34
deploy/local_install/selinux-mediacms.te
Normal 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 };
|
||||
27
deploy/local_install/uwsgi.ini
Normal file
27
deploy/local_install/uwsgi.ini
Normal 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
|
||||
16
deploy/local_install/uwsgi_params
Normal file
16
deploy/local_install/uwsgi_params
Normal 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;
|
||||
@@ -23,7 +23,7 @@ and will start all services required for MediaCMS, as Celery/Redis for asynchron
|
||||
For Django, the changes from the image produced by docker-compose.yaml are these:
|
||||
|
||||
* Django runs in debug mode, with `python manage.py runserver`
|
||||
* gunicorn and nginx are not run
|
||||
* uwsgi and nginx are not run
|
||||
* Django runs in Debug mode, with Debug Toolbar
|
||||
* Static files (js/css) are loaded from static/ folder
|
||||
* corsheaders is installed and configured to allow all origins
|
||||
|
||||
@@ -65,7 +65,6 @@ class CategoryAdminForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
# LTI fields will be shown as read-only when USE_LTI is enabled
|
||||
fields = '__all__'
|
||||
|
||||
def clean(self):
|
||||
@@ -136,7 +135,7 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "user", "add_date", "media_count"]
|
||||
list_filter = []
|
||||
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'
|
||||
|
||||
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):
|
||||
rbac_fieldset = [
|
||||
('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'}),
|
||||
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||
]
|
||||
additional_fieldsets.extend(rbac_fieldset)
|
||||
|
||||
return basic_fieldset + additional_fieldsets
|
||||
return basic_fieldset + rbac_fieldset
|
||||
else:
|
||||
return basic_fieldset
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -64,10 +64,4 @@ def stuff(request):
|
||||
if request.user.is_superuser:
|
||||
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
|
||||
|
||||
if getattr(settings, 'USE_LTI', False):
|
||||
lti_session = request.session.get('lti_session')
|
||||
|
||||
if lti_session and request.user.is_authenticated:
|
||||
ret['lti_session'] = lti_session
|
||||
|
||||
return ret
|
||||
|
||||
@@ -965,13 +965,3 @@ def get_alphanumeric_only(string):
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum()])
|
||||
return string.lower()
|
||||
|
||||
|
||||
def get_alphanumeric_and_spaces(string):
|
||||
"""Returns a query that contains only alphanumeric characters and spaces
|
||||
This include characters other than the English alphabet too
|
||||
"""
|
||||
string = "".join([char for char in string if char.isalnum() or char.isspace()])
|
||||
# Replace multiple spaces with single space and strip
|
||||
string = " ".join(string.split())
|
||||
return string
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -47,13 +47,6 @@ class Category(models.Model):
|
||||
verbose_name='IDP Config Name',
|
||||
)
|
||||
|
||||
# LTI/LMS integration fields
|
||||
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
|
||||
|
||||
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
|
||||
|
||||
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -144,7 +137,7 @@ class Tag(models.Model):
|
||||
return True
|
||||
|
||||
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]
|
||||
super(Tag, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -352,11 +352,20 @@ class Media(models.Model):
|
||||
# first get anything interesting out of the media
|
||||
# that needs to be search able
|
||||
|
||||
a_tags = ""
|
||||
a_tags = b_tags = ""
|
||||
if self.id:
|
||||
a_tags = " ".join([tag.title for tag in self.tags.all()])
|
||||
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
|
||||
|
||||
items = [self.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():
|
||||
items.append(subtitle.subtitle_text)
|
||||
|
||||
@@ -80,7 +80,6 @@ urlpatterns = [
|
||||
views.trim_video,
|
||||
),
|
||||
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
|
||||
re_path(r"^api/v1/categories/contributor$", views.CategoryListContributor.as_view()),
|
||||
re_path(r"^api/v1/tags$", views.TagList.as_view()),
|
||||
re_path(r"^api/v1/comments$", views.CommentList.as_view()),
|
||||
re_path(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Import all views for backward compatibility
|
||||
|
||||
from .auth import custom_login_view, saml_metadata # noqa: F401
|
||||
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
|
||||
from .categories import CategoryList, TagList # noqa: F401
|
||||
from .comments import CommentDetail, CommentList # noqa: F401
|
||||
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
|
||||
from .media import MediaActions # noqa: F401
|
||||
|
||||
@@ -43,40 +43,6 @@ class CategoryList(APIView):
|
||||
return Response(ret)
|
||||
|
||||
|
||||
class CategoryListContributor(APIView):
|
||||
"""List categories where user has contributor access"""
|
||||
|
||||
@swagger_auto_schema(
|
||||
manual_parameters=[],
|
||||
tags=['Categories'],
|
||||
operation_summary='Lists Categories for Contributors',
|
||||
operation_description='Lists all categories where the user has contributor access',
|
||||
responses={
|
||||
200: openapi.Response('response description', CategorySerializer),
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
if not request.user.is_authenticated:
|
||||
return Response([])
|
||||
|
||||
categories = Category.objects.none()
|
||||
|
||||
# Get global/public categories (non-RBAC)
|
||||
public_categories = Category.objects.filter(is_rbac_category=False).prefetch_related("user")
|
||||
|
||||
# Get RBAC categories where user has contributor access
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_categories = request.user.get_rbac_categories_as_contributor()
|
||||
categories = public_categories.union(rbac_categories)
|
||||
else:
|
||||
categories = public_categories
|
||||
|
||||
categories = categories.order_by("title")
|
||||
|
||||
serializer = CategorySerializer(categories, many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TagList(APIView):
|
||||
"""List tags"""
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from ..forms import (
|
||||
WhisperSubtitlesForm,
|
||||
)
|
||||
from ..frontend_translations import translate_string
|
||||
from ..helpers import get_alphanumeric_and_spaces
|
||||
from ..helpers import get_alphanumeric_only
|
||||
from ..methods import (
|
||||
can_transcribe_video,
|
||||
create_video_trim_request,
|
||||
@@ -310,8 +310,8 @@ def edit_media(request):
|
||||
media.tags.remove(tag)
|
||||
if form.cleaned_data.get("new_tags"):
|
||||
for tag in form.cleaned_data.get("new_tags").split(","):
|
||||
tag = get_alphanumeric_and_spaces(tag)
|
||||
tag = tag[:100]
|
||||
tag = get_alphanumeric_only(tag)
|
||||
tag = tag[:99]
|
||||
if tag:
|
||||
try:
|
||||
tag = Tag.objects.get(title=tag)
|
||||
|
||||
34
frontend-tools/video-js/examples/full-screen-video.html
Normal file
34
frontend-tools/video-js/examples/full-screen-video.html
Normal 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>
|
||||
@@ -204,6 +204,54 @@ class SeekIndicator extends Component {
|
||||
</div>
|
||||
`;
|
||||
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
|
||||
@@ -239,6 +287,11 @@ class SeekIndicator extends Component {
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.hide();
|
||||
}, 500);
|
||||
} else if (direction === 'copy-url' || direction === 'copy-embed') {
|
||||
// Copy operations: 500ms (same as play/pause)
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.hide();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,22 @@ class EmbedInfoOverlay extends Component {
|
||||
this.authorThumbnail = options.authorThumbnail || '';
|
||||
this.videoTitle = options.videoTitle || 'Video';
|
||||
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
|
||||
this.player().ready(() => {
|
||||
this.createOverlay();
|
||||
if (this.showTitle) {
|
||||
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
|
||||
if (this.authorThumbnail) {
|
||||
if (this.authorThumbnail && this.showUserAvatar) {
|
||||
const avatarContainer = document.createElement('div');
|
||||
avatarContainer.className = 'embed-avatar-container';
|
||||
avatarContainer.style.cssText = `
|
||||
@@ -125,7 +137,7 @@ class EmbedInfoOverlay extends Component {
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
if (this.videoUrl) {
|
||||
if (this.videoUrl && this.linkTitle) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = this.videoUrl;
|
||||
titleLink.target = '_blank';
|
||||
@@ -186,10 +198,16 @@ class EmbedInfoOverlay extends Component {
|
||||
const player = this.player();
|
||||
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
|
||||
const updateOverlayVisibility = () => {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
|
||||
if (!player.hasStarted()) {
|
||||
// Show overlay when video hasn't started (poster is showing) - like before
|
||||
overlay.style.opacity = '1';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 'video.js/dist/video-js.css';
|
||||
import '../../styles/embed.css';
|
||||
@@ -17,6 +17,7 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
|
||||
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
|
||||
import CustomSettingsMenu from '../controls/CustomSettingsMenu';
|
||||
import SeekIndicator from '../controls/SeekIndicator';
|
||||
import VideoContextMenu from '../overlays/VideoContextMenu';
|
||||
import UserPreferences from '../../utils/UserPreferences';
|
||||
import PlayerConfig from '../../config/playerConfig';
|
||||
import { AutoplayHandler } from '../../utils/AutoplayHandler';
|
||||
@@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => {
|
||||
}, 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 playerRef = useRef(null); // Track the player 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 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)
|
||||
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
|
||||
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(
|
||||
() =>
|
||||
typeof window !== 'undefined' && window.MEDIA_DATA
|
||||
@@ -214,12 +207,37 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
},
|
||||
siteUrl: 'https://deic.mediacms.io',
|
||||
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
|
||||
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
|
||||
// CONDITIONAL LOGIC:
|
||||
@@ -531,8 +549,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
isPlayList: mediaData?.isPlayList,
|
||||
related_media: mediaData.data?.related_media || [],
|
||||
nextLink: mediaData?.nextLink || null,
|
||||
urlAutoplay: mediaData?.urlAutoplay || true,
|
||||
urlMuted: mediaData?.urlMuted || false,
|
||||
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(() => {
|
||||
// Only initialize if we don't already have a player and element exists
|
||||
if (videoRef.current && !playerRef.current) {
|
||||
@@ -1078,6 +1300,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
currentVideo,
|
||||
relatedVideos,
|
||||
goToNextVideo,
|
||||
showRelated: finalShowRelated,
|
||||
showUserAvatar: finalShowUserAvatar,
|
||||
linkTitle: finalLinkTitle,
|
||||
});
|
||||
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
|
||||
|
||||
@@ -1098,8 +1323,8 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
}
|
||||
|
||||
// Handle URL timestamp parameter
|
||||
if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) {
|
||||
const timestamp = mediaData.urlTimestamp;
|
||||
if (finalTimestamp !== null && finalTimestamp >= 0) {
|
||||
const timestamp = finalTimestamp;
|
||||
|
||||
// Wait for video metadata to be loaded before seeking
|
||||
if (playerRef.current.readyState() >= 1) {
|
||||
@@ -1997,6 +2222,10 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
authorThumbnail: currentVideo.author_thumbnail,
|
||||
videoTitle: currentVideo.title,
|
||||
videoUrl: currentVideo.url,
|
||||
showTitle: finalShowTitle,
|
||||
showRelated: finalShowRelated,
|
||||
showUserAvatar: finalShowUserAvatar,
|
||||
linkTitle: finalLinkTitle,
|
||||
});
|
||||
}
|
||||
// END: Add Embed Info Overlay Component
|
||||
@@ -2083,52 +2312,113 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
|
||||
// Make the video element focusable
|
||||
const videoElement = playerRef.current.el();
|
||||
videoElement.setAttribute('tabindex', '0');
|
||||
videoElement.focus();
|
||||
|
||||
if (!isEmbedPlayer) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<video
|
||||
ref={videoRef}
|
||||
id={videoId}
|
||||
controls={true}
|
||||
className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
||||
preload="auto"
|
||||
poster={currentVideo.poster}
|
||||
tabIndex="0"
|
||||
>
|
||||
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
||||
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
||||
<p className="vjs-no-js">
|
||||
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
||||
supports HTML5 video
|
||||
</a>
|
||||
</p>
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
id={videoId}
|
||||
controls={true}
|
||||
className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
||||
preload="auto"
|
||||
poster={currentVideo.poster}
|
||||
tabIndex="0"
|
||||
>
|
||||
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
||||
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
||||
<p className="vjs-no-js">
|
||||
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
||||
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
||||
supports HTML5 video
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Add subtitle tracks */}
|
||||
{/* {subtitleTracks &&
|
||||
subtitleTracks.map((track, index) => (
|
||||
<track
|
||||
key={index}
|
||||
kind={track.kind}
|
||||
src={track.src}
|
||||
srcLang={track.srclang}
|
||||
label={track.label}
|
||||
default={track.default}
|
||||
/>
|
||||
))} */}
|
||||
{/*
|
||||
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
||||
{/* Add chapters track */}
|
||||
{/* {chaptersData &&
|
||||
chaptersData.length > 0 &&
|
||||
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
||||
</video>
|
||||
{/* Add subtitle tracks */}
|
||||
{/* {subtitleTracks &&
|
||||
subtitleTracks.map((track, index) => (
|
||||
<track
|
||||
key={index}
|
||||
kind={track.kind}
|
||||
src={track.src}
|
||||
srcLang={track.srclang}
|
||||
label={track.label}
|
||||
default={track.default}
|
||||
/>
|
||||
))} */}
|
||||
{/*
|
||||
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
||||
{/* Add chapters track */}
|
||||
{/* {chaptersData &&
|
||||
chaptersData.length > 0 &&
|
||||
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
||||
</video>
|
||||
<VideoContextMenu
|
||||
visible={contextMenuVisible}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onCopyVideoUrl={handleCopyVideoUrl}
|
||||
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
|
||||
onCopyEmbedCode={handleCopyEmbedCode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,17 @@ export class EndScreenHandler {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
setTimeout(() => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
|
||||
@@ -31,8 +31,11 @@ const VideoJSEmbed = ({
|
||||
poster,
|
||||
previewSprite,
|
||||
subtitlesInfo,
|
||||
enableAutoplay,
|
||||
inEmbed,
|
||||
showTitle,
|
||||
showRelated,
|
||||
showUserAvatar,
|
||||
linkTitle,
|
||||
hasTheaterMode,
|
||||
hasNextLink,
|
||||
nextLink,
|
||||
@@ -62,8 +65,10 @@ const VideoJSEmbed = ({
|
||||
if (typeof window !== 'undefined') {
|
||||
// Get URL parameters for autoplay, muted, and timestamp
|
||||
const urlTimestamp = getUrlParameter('t');
|
||||
const urlAutoplay = getUrlParameter('autoplay');
|
||||
const urlMuted = getUrlParameter('muted');
|
||||
const urlShowRelated = getUrlParameter('showRelated');
|
||||
const urlShowUserAvatar = getUrlParameter('showUserAvatar');
|
||||
const urlLinkTitle = getUrlParameter('linkTitle');
|
||||
|
||||
window.MEDIA_DATA = {
|
||||
data: data || {},
|
||||
@@ -71,7 +76,7 @@ const VideoJSEmbed = ({
|
||||
version: version,
|
||||
isPlayList: isPlayList,
|
||||
playerVolume: playerVolume || 0.5,
|
||||
playerSoundMuted: playerSoundMuted || (urlMuted === '1'),
|
||||
playerSoundMuted: urlMuted === '1',
|
||||
videoQuality: videoQuality || 'auto',
|
||||
videoPlaybackSpeed: videoPlaybackSpeed || 1,
|
||||
inTheaterMode: inTheaterMode || false,
|
||||
@@ -83,8 +88,11 @@ const VideoJSEmbed = ({
|
||||
poster: poster || '',
|
||||
previewSprite: previewSprite || null,
|
||||
subtitlesInfo: subtitlesInfo || [],
|
||||
enableAutoplay: enableAutoplay || (urlAutoplay === '1'),
|
||||
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,
|
||||
hasNextLink: hasNextLink || false,
|
||||
nextLink: nextLink || null,
|
||||
@@ -92,8 +100,10 @@ const VideoJSEmbed = ({
|
||||
errorMessage: errorMessage || '',
|
||||
// URL parameters
|
||||
urlTimestamp: urlTimestamp ? parseInt(urlTimestamp, 10) : null,
|
||||
urlAutoplay: urlAutoplay === '1',
|
||||
urlMuted: urlMuted === '1',
|
||||
urlShowRelated: urlShowRelated === '1' || urlShowRelated === 'true',
|
||||
urlShowUserAvatar: urlShowUserAvatar === '1' || urlShowUserAvatar === 'true',
|
||||
urlLinkTitle: urlLinkTitle === '1' || urlLinkTitle === 'true',
|
||||
onClickNextCallback: onClickNextCallback || null,
|
||||
onClickPreviousCallback: onClickPreviousCallback || null,
|
||||
onStateUpdateCallback: onStateUpdateCallback || null,
|
||||
@@ -176,11 +186,17 @@ const VideoJSEmbed = ({
|
||||
// Scroll to the video player with smooth behavior
|
||||
const videoElement = document.querySelector(inEmbedRef.current ? '#video-embed' : '#video-main');
|
||||
if (videoElement) {
|
||||
videoElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
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({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('VideoJS player not found for timestamp navigation');
|
||||
@@ -220,7 +236,14 @@ const VideoJSEmbed = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,10 +4,32 @@ import { LinksContext, SiteConsumer } from '../../utils/contexts/';
|
||||
import { PageStore, MediaPageStore } from '../../utils/stores/';
|
||||
import { PageActions, MediaPageActions } from '../../utils/actions/';
|
||||
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) {
|
||||
const embedVideoDimensions = PageStore.get('config-options').embedded.video.dimensions;
|
||||
const savedOptions = loadEmbedOptions();
|
||||
|
||||
const links = useContext(LinksContext);
|
||||
|
||||
@@ -18,12 +40,19 @@ export function MediaShareEmbed(props) {
|
||||
const onRightBottomRef = useRef(null);
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 144 + 56);
|
||||
const [keepAspectRatio, setKeepAspectRatio] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState('16:9');
|
||||
const [embedWidthValue, setEmbedWidthValue] = useState(embedVideoDimensions.width);
|
||||
const [embedWidthUnit, setEmbedWidthUnit] = useState(embedVideoDimensions.widthUnit);
|
||||
const [embedHeightValue, setEmbedHeightValue] = useState(embedVideoDimensions.height);
|
||||
const [embedHeightUnit, setEmbedHeightUnit] = useState(embedVideoDimensions.heightUnit);
|
||||
const [keepAspectRatio, setKeepAspectRatio] = useState(savedOptions?.keepAspectRatio ?? true);
|
||||
const [showTitle, setShowTitle] = useState(savedOptions?.showTitle ?? true);
|
||||
const [showRelated, setShowRelated] = useState(savedOptions?.showRelated ?? true);
|
||||
const [showUserAvatar, setShowUserAvatar] = useState(savedOptions?.showUserAvatar ?? true);
|
||||
const [linkTitle, setLinkTitle] = useState(savedOptions?.linkTitle ?? true);
|
||||
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 [rightMiddlePositionBottom, setRightMiddlePositionBottom] = useState(60);
|
||||
const [unitOptions, setUnitOptions] = useState([
|
||||
@@ -71,36 +100,65 @@ export function MediaShareEmbed(props) {
|
||||
setEmbedHeightUnit(newVal);
|
||||
}
|
||||
|
||||
function onKeepAspectRatioChange() {
|
||||
const newVal = !keepAspectRatio;
|
||||
function onShowTitleChange() {
|
||||
setShowTitle(!showTitle);
|
||||
}
|
||||
|
||||
const arr = aspectRatio.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
function onShowRelatedChange() {
|
||||
setShowRelated(!showRelated);
|
||||
}
|
||||
|
||||
setKeepAspectRatio(newVal);
|
||||
setEmbedWidthUnit(newVal ? 'px' : embedWidthUnit);
|
||||
setEmbedHeightUnit(newVal ? 'px' : embedHeightUnit);
|
||||
setEmbedHeightValue(newVal ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
|
||||
setUnitOptions(
|
||||
newVal
|
||||
? [{ key: 'px', label: 'px' }]
|
||||
: [
|
||||
{ key: 'px', label: 'px' },
|
||||
{ key: 'percent', label: '%' },
|
||||
]
|
||||
);
|
||||
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 x = arr[0];
|
||||
const y = arr[1];
|
||||
|
||||
setKeepAspectRatio(true);
|
||||
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
|
||||
} else {
|
||||
setKeepAspectRatio(false);
|
||||
}
|
||||
} else {
|
||||
setKeepAspectRatio(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onStartAtChange() {
|
||||
setStartAt(!startAt);
|
||||
}
|
||||
|
||||
function onStartTimeChange(e) {
|
||||
setStartTime(e.target.value);
|
||||
}
|
||||
|
||||
function onAspectRatioChange() {
|
||||
const newVal = aspectRatioValueRef.current.value;
|
||||
|
||||
const arr = newVal.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
if (newVal === 'custom') {
|
||||
setAspectRatio(newVal);
|
||||
setKeepAspectRatio(false);
|
||||
} else {
|
||||
const arr = newVal.split(':');
|
||||
const x = arr[0];
|
||||
const y = arr[1];
|
||||
|
||||
setAspectRatio(newVal);
|
||||
setEmbedHeightValue(keepAspectRatio ? parseInt((embedWidthValue * y) / x, 10) : embedHeightValue);
|
||||
setAspectRatio(newVal);
|
||||
setKeepAspectRatio(true);
|
||||
setEmbedHeightValue(parseInt((embedWidthValue * y) / x, 10));
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="share-embed" style={{ maxHeight: maxHeight + 'px' }}>
|
||||
<div className="share-embed-inner">
|
||||
<div className="on-left">
|
||||
<div className="media-embed-wrap">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,16 +291,7 @@ export function MediaShareEmbed(props) {
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
value={
|
||||
'<iframe width="' +
|
||||
('percent' === embedWidthUnit ? embedWidthValue + '%' : embedWidthValue) +
|
||||
'" height="' +
|
||||
('percent' === embedHeightUnit ? embedHeightValue + '%' : embedHeightValue) +
|
||||
'" src="' +
|
||||
links.embed +
|
||||
MediaPageStore.get('media-id') +
|
||||
'" frameborder="0" allowfullscreen></iframe>'
|
||||
}
|
||||
value={getEmbedCode()}
|
||||
></textarea>
|
||||
|
||||
<div className="iframe-config">
|
||||
@@ -179,59 +303,106 @@ export function MediaShareEmbed(props) {
|
||||
</div>*/}
|
||||
|
||||
<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">
|
||||
<label style={{ minHeight: '36px' }}>
|
||||
<input type="checkbox" checked={keepAspectRatio} onChange={onKeepAspectRatioChange} />
|
||||
Keep aspect ratio
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={showTitle} onChange={onShowTitleChange} />
|
||||
Show title
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!keepAspectRatio ? null : (
|
||||
<div className="options-group">
|
||||
<select ref={aspectRatioValueRef} onChange={onAspectRatioChange} value={aspectRatio}>
|
||||
<optgroup label="Horizontal orientation">
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="3:2">3:2</option>
|
||||
</optgroup>
|
||||
<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>
|
||||
<div className="options-group">
|
||||
<label style={{ minHeight: '36px', whiteSpace: 'nowrap', opacity: showTitle ? 1 : 0.5 }}>
|
||||
<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="4:3">4:3</option>
|
||||
<option value="3:2">3:2</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedWidthValueChange}
|
||||
unitCallback={onEmbedWidthUnitChange}
|
||||
label={'Width'}
|
||||
defaultValue={parseInt(embedWidthValue, 10)}
|
||||
defaultUnit={embedWidthUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
{!responsive && (
|
||||
<>
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedWidthValueChange}
|
||||
unitCallback={onEmbedWidthUnitChange}
|
||||
label={'Width'}
|
||||
defaultValue={parseInt(embedWidthValue, 10)}
|
||||
defaultUnit={embedWidthUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedHeightValueChange}
|
||||
unitCallback={onEmbedHeightUnitChange}
|
||||
label={'Height'}
|
||||
defaultValue={parseInt(embedHeightValue, 10)}
|
||||
defaultUnit={embedHeightUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="options-group">
|
||||
<NumericInputWithUnit
|
||||
valueCallback={onEmbedHeightValueChange}
|
||||
unitCallback={onEmbedHeightUnitChange}
|
||||
label={'Height'}
|
||||
defaultValue={parseInt(embedHeightValue, 10)}
|
||||
defaultUnit={embedHeightUnit}
|
||||
minValue={1}
|
||||
maxValue={99999}
|
||||
units={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,343 +3,278 @@ import { SiteContext } from '../../utils/contexts/';
|
||||
import { useUser, usePopup } from '../../utils/hooks/';
|
||||
import { PageStore, MediaPageStore } from '../../utils/stores/';
|
||||
import { PageActions, MediaPageActions } from '../../utils/actions/';
|
||||
import { formatInnerLink, publishedOnDate } from '../../utils/helpers/';
|
||||
import { PopupMain, CircleIconButton, MaterialIcon, NavigationMenuList, NavigationContentApp } from '../_shared/';
|
||||
import { formatInnerLink, inEmbeddedApp, publishedOnDate } from '../../utils/helpers/';
|
||||
import { PopupMain } from '../_shared/';
|
||||
import CommentsList from '../comments/Comments';
|
||||
import { replaceString } from '../../utils/helpers/';
|
||||
import { translateString } from '../../utils/helpers/';
|
||||
|
||||
function metafield(arr) {
|
||||
let i;
|
||||
let sep;
|
||||
let ret = [];
|
||||
let i;
|
||||
let sep;
|
||||
let ret = [];
|
||||
|
||||
if (arr && arr.length) {
|
||||
i = 0;
|
||||
sep = 1 < arr.length ? ', ' : '';
|
||||
while (i < arr.length) {
|
||||
ret[i] = (
|
||||
<div key={i}>
|
||||
<a href={arr[i].url} title={arr[i].title}>
|
||||
{arr[i].title}
|
||||
</a>
|
||||
{i < arr.length - 1 ? sep : ''}
|
||||
</div>
|
||||
);
|
||||
i += 1;
|
||||
if (arr && arr.length) {
|
||||
i = 0;
|
||||
sep = 1 < arr.length ? ', ' : '';
|
||||
while (i < arr.length) {
|
||||
ret[i] = (
|
||||
<div key={i}>
|
||||
<a href={arr[i].url} title={arr[i].title}>
|
||||
{arr[i].title}
|
||||
</a>
|
||||
{i < arr.length - 1 ? sep : ''}
|
||||
</div>
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
return ret;
|
||||
}
|
||||
|
||||
function MediaAuthorBanner(props) {
|
||||
return (
|
||||
<div className="media-author-banner">
|
||||
<div>
|
||||
<a className="author-banner-thumb" href={props.link || null} title={props.name}>
|
||||
<span style={{ backgroundImage: 'url(' + props.thumb + ')' }}>
|
||||
<img src={props.thumb} loading="lazy" alt={props.name} title={props.name} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<a href={props.link} className="author-banner-name" title={props.name}>
|
||||
<span>{props.name}</span>
|
||||
</a>
|
||||
</span>
|
||||
{PageStore.get('config-media-item').displayPublishDate && props.published ? (
|
||||
<span className="author-banner-date">
|
||||
{translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="media-author-banner">
|
||||
<div>
|
||||
<a className="author-banner-thumb" href={props.link || null} title={props.name}>
|
||||
<span style={{ backgroundImage: 'url(' + props.thumb + ')' }}>
|
||||
<img src={props.thumb} loading="lazy" alt={props.name} title={props.name} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<a href={props.link} className="author-banner-name" title={props.name}>
|
||||
<span>{props.name}</span>
|
||||
</a>
|
||||
</span>
|
||||
{PageStore.get('config-media-item').displayPublishDate && props.published ? (
|
||||
<span className="author-banner-date">
|
||||
{translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaMetaField(props) {
|
||||
return (
|
||||
<div className={props.id.trim() ? 'media-content-' + props.id.trim() : null}>
|
||||
<div className="media-content-field">
|
||||
<div className="media-content-field-label">
|
||||
<h4>{props.title}</h4>
|
||||
return (
|
||||
<div className={props.id.trim() ? 'media-content-' + props.id.trim() : null}>
|
||||
<div className="media-content-field">
|
||||
<div className="media-content-field-label">
|
||||
<h4>{props.title}</h4>
|
||||
</div>
|
||||
<div className="media-content-field-content">{props.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="media-content-field-content">{props.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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 (
|
||||
<div className="edit-media-dropdown">
|
||||
<PopupTrigger contentRef={popupContentRef}>
|
||||
<button className="edit-media-icon" title={translateString('Edit media')}>
|
||||
<i className="material-icons">edit</i>
|
||||
</button>
|
||||
</PopupTrigger>
|
||||
<PopupContent contentRef={popupContentRef}>
|
||||
<NavigationContentApp
|
||||
initPage="main"
|
||||
focusFirstItemOnPageChange={false}
|
||||
pages={popupPages}
|
||||
/>
|
||||
</PopupContent>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media-icon">
|
||||
<i className="material-icons">edit</i>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewerInfoContent(props) {
|
||||
const { userCan } = useUser();
|
||||
const { userCan } = useUser();
|
||||
|
||||
const description = props.description.trim();
|
||||
const tagsContent =
|
||||
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
|
||||
? metafield(MediaPageStore.get('media-tags'))
|
||||
: [];
|
||||
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? []
|
||||
: !PageStore.get('config-enabled').taxonomies.categories ||
|
||||
PageStore.get('config-enabled').taxonomies.categories.enabled
|
||||
? metafield(MediaPageStore.get('media-categories'))
|
||||
: [];
|
||||
const description = props.description.trim();
|
||||
const tagsContent =
|
||||
!PageStore.get('config-enabled').taxonomies.tags || PageStore.get('config-enabled').taxonomies.tags.enabled
|
||||
? metafield(MediaPageStore.get('media-tags'))
|
||||
: [];
|
||||
const categoriesContent = PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? []
|
||||
: !PageStore.get('config-enabled').taxonomies.categories ||
|
||||
PageStore.get('config-enabled').taxonomies.categories.enabled
|
||||
? metafield(MediaPageStore.get('media-categories'))
|
||||
: [];
|
||||
|
||||
let summary = MediaPageStore.get('media-summary');
|
||||
let summary = MediaPageStore.get('media-summary');
|
||||
|
||||
summary = summary ? summary.trim() : '';
|
||||
summary = summary ? summary.trim() : '';
|
||||
|
||||
const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
|
||||
const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
|
||||
|
||||
const [hasSummary, setHasSummary] = useState('' !== summary);
|
||||
const [isContentVisible, setIsContentVisible] = useState('' == summary);
|
||||
const [hasSummary, setHasSummary] = useState('' !== summary);
|
||||
const [isContentVisible, setIsContentVisible] = useState('' == summary);
|
||||
|
||||
function proceedMediaRemoval() {
|
||||
MediaPageActions.removeMedia();
|
||||
popupContentRef.current.toggle();
|
||||
}
|
||||
|
||||
function cancelMediaRemoval() {
|
||||
popupContentRef.current.toggle();
|
||||
}
|
||||
|
||||
function onMediaDelete(mediaId) {
|
||||
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
|
||||
setTimeout(function () {
|
||||
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
|
||||
setTimeout(function () {
|
||||
window.location.href =
|
||||
SiteContext._currentValue.url + '/' + MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
|
||||
}, 2000);
|
||||
}, 100);
|
||||
|
||||
if (void 0 !== mediaId) {
|
||||
console.info("Removed media '" + mediaId + '"');
|
||||
}
|
||||
}
|
||||
|
||||
function onMediaDeleteFail(mediaId) {
|
||||
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
|
||||
setTimeout(function () {
|
||||
PageActions.addNotification('Media removal failed', 'mediaDeleteFail');
|
||||
}, 100);
|
||||
|
||||
if (void 0 !== mediaId) {
|
||||
console.info('Media "' + mediaId + '"' + ' removal failed');
|
||||
}
|
||||
}
|
||||
|
||||
function onClickLoadMore() {
|
||||
setIsContentVisible(!isContentVisible);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
MediaPageStore.on('media_delete', onMediaDelete);
|
||||
MediaPageStore.on('media_delete_fail', onMediaDeleteFail);
|
||||
return () => {
|
||||
MediaPageStore.removeListener('media_delete', onMediaDelete);
|
||||
MediaPageStore.removeListener('media_delete_fail', onMediaDeleteFail);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url);
|
||||
const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url);
|
||||
|
||||
function setTimestampAnchors(text) {
|
||||
function wrapTimestampWithAnchor(match, string) {
|
||||
let split = match.split(':'),
|
||||
s = 0,
|
||||
m = 1;
|
||||
|
||||
while (split.length > 0) {
|
||||
s += m * parseInt(split.pop(), 10);
|
||||
m *= 60;
|
||||
}
|
||||
|
||||
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
|
||||
return wrapped;
|
||||
function proceedMediaRemoval() {
|
||||
MediaPageActions.removeMedia();
|
||||
popupContentRef.current.toggle();
|
||||
}
|
||||
|
||||
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
|
||||
return text.replace(timeRegex, wrapTimestampWithAnchor);
|
||||
}
|
||||
function cancelMediaRemoval() {
|
||||
popupContentRef.current.toggle();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="media-info-content">
|
||||
{void 0 === PageStore.get('config-media-item').displayAuthor ||
|
||||
null === PageStore.get('config-media-item').displayAuthor ||
|
||||
!!PageStore.get('config-media-item').displayAuthor ? (
|
||||
<MediaAuthorBanner link={authorLink} thumb={authorThumb} name={props.author.name} published={props.published} />
|
||||
) : null}
|
||||
function onMediaDelete(mediaId) {
|
||||
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
|
||||
setTimeout(function () {
|
||||
PageActions.addNotification('Media removed. Redirecting...', 'mediaDelete');
|
||||
setTimeout(function () {
|
||||
window.location.href =
|
||||
SiteContext._currentValue.url +
|
||||
'/' +
|
||||
MediaPageStore.get('media-data').author_profile.replace(/^\//g, '');
|
||||
}, 2000);
|
||||
}, 100);
|
||||
|
||||
<div className="media-content-banner">
|
||||
<div className="media-content-banner-inner">
|
||||
{hasSummary ? <div className="media-content-summary">{summary}</div> : null}
|
||||
{(!hasSummary || isContentVisible) && description ? (
|
||||
<div
|
||||
className="media-content-description"
|
||||
dangerouslySetInnerHTML={{ __html: setTimestampAnchors(description) }}
|
||||
></div>
|
||||
) : null}
|
||||
{hasSummary ? (
|
||||
<button className="load-more" onClick={onClickLoadMore}>
|
||||
{isContentVisible ? 'SHOW LESS' : 'SHOW MORE'}
|
||||
</button>
|
||||
) : null}
|
||||
{tagsContent.length ? (
|
||||
<MediaMetaField
|
||||
value={tagsContent}
|
||||
title={1 < tagsContent.length ? translateString('Tags') : translateString('Tag')}
|
||||
id="tags"
|
||||
/>
|
||||
) : null}
|
||||
{categoriesContent.length ? (
|
||||
<MediaMetaField
|
||||
value={categoriesContent}
|
||||
title={1 < categoriesContent.length ? translateString('Categories') : translateString('Category')}
|
||||
id="categories"
|
||||
/>
|
||||
) : null}
|
||||
if (void 0 !== mediaId) {
|
||||
console.info("Removed media '" + mediaId + '"');
|
||||
}
|
||||
}
|
||||
|
||||
{userCan.editMedia ? (
|
||||
<div className="media-author-actions">
|
||||
{userCan.editMedia ? <EditMediaButton /> : null}
|
||||
function onMediaDeleteFail(mediaId) {
|
||||
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
|
||||
setTimeout(function () {
|
||||
PageActions.addNotification('Media removal failed', 'mediaDeleteFail');
|
||||
}, 100);
|
||||
|
||||
{userCan.deleteMedia ? (
|
||||
<PopupTrigger contentRef={popupContentRef}>
|
||||
<button className="remove-media-icon" title={translateString('Delete media')}>
|
||||
<i className="material-icons">delete</i>
|
||||
</button>
|
||||
</PopupTrigger>
|
||||
) : null}
|
||||
if (void 0 !== mediaId) {
|
||||
console.info('Media "' + mediaId + '"' + ' removal failed');
|
||||
}
|
||||
}
|
||||
|
||||
{userCan.deleteMedia ? (
|
||||
<PopupContent contentRef={popupContentRef}>
|
||||
<PopupMain>
|
||||
<div className="popup-message">
|
||||
<span className="popup-message-title">Media removal</span>
|
||||
<span className="popup-message-main">You're willing to remove media permanently?</span>
|
||||
</div>
|
||||
<hr />
|
||||
<span className="popup-message-bottom">
|
||||
<button className="button-link cancel-comment-removal" onClick={cancelMediaRemoval}>
|
||||
CANCEL
|
||||
</button>
|
||||
<button className="button-link proceed-comment-removal" onClick={proceedMediaRemoval}>
|
||||
PROCEED
|
||||
</button>
|
||||
</span>
|
||||
</PopupMain>
|
||||
</PopupContent>
|
||||
) : null}
|
||||
function onClickLoadMore() {
|
||||
setIsContentVisible(!isContentVisible);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
MediaPageStore.on('media_delete', onMediaDelete);
|
||||
MediaPageStore.on('media_delete_fail', onMediaDeleteFail);
|
||||
return () => {
|
||||
MediaPageStore.removeListener('media_delete', onMediaDelete);
|
||||
MediaPageStore.removeListener('media_delete_fail', onMediaDeleteFail);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url);
|
||||
const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url);
|
||||
|
||||
function setTimestampAnchors(text) {
|
||||
function wrapTimestampWithAnchor(match, string) {
|
||||
let split = match.split(':'),
|
||||
s = 0,
|
||||
m = 1;
|
||||
|
||||
while (split.length > 0) {
|
||||
s += m * parseInt(split.pop(), 10);
|
||||
m *= 60;
|
||||
}
|
||||
|
||||
const wrapped = `<a href="#" data-timestamp="${s}" class="video-timestamp">${match}</a>`;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
|
||||
return text.replace(timeRegex, wrapTimestampWithAnchor);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="media-info-content">
|
||||
{void 0 === PageStore.get('config-media-item').displayAuthor ||
|
||||
null === PageStore.get('config-media-item').displayAuthor ||
|
||||
!!PageStore.get('config-media-item').displayAuthor ? (
|
||||
<MediaAuthorBanner
|
||||
link={authorLink}
|
||||
thumb={authorThumb}
|
||||
name={props.author.name}
|
||||
published={props.published}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="media-content-banner">
|
||||
<div className="media-content-banner-inner">
|
||||
{hasSummary ? <div className="media-content-summary">{summary}</div> : null}
|
||||
{(!hasSummary || isContentVisible) && description ? (
|
||||
<div
|
||||
className="media-content-description"
|
||||
dangerouslySetInnerHTML={{ __html: setTimestampAnchors(description) }}
|
||||
></div>
|
||||
) : null}
|
||||
{hasSummary ? (
|
||||
<button className="load-more" onClick={onClickLoadMore}>
|
||||
{isContentVisible ? 'SHOW LESS' : 'SHOW MORE'}
|
||||
</button>
|
||||
) : null}
|
||||
{tagsContent.length ? (
|
||||
<MediaMetaField
|
||||
value={tagsContent}
|
||||
title={1 < tagsContent.length ? translateString('Tags') : translateString('Tag')}
|
||||
id="tags"
|
||||
/>
|
||||
) : null}
|
||||
{categoriesContent.length ? (
|
||||
<MediaMetaField
|
||||
value={categoriesContent}
|
||||
title={
|
||||
1 < categoriesContent.length
|
||||
? translateString('Categories')
|
||||
: translateString('Category')
|
||||
}
|
||||
id="categories"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{userCan.editMedia ? (
|
||||
<div className="media-author-actions">
|
||||
{userCan.editMedia ? (
|
||||
<EditMediaButton link={MediaPageStore.get('media-data').edit_url} />
|
||||
) : null}
|
||||
|
||||
{userCan.deleteMedia ? (
|
||||
<PopupTrigger contentRef={popupContentRef}>
|
||||
<button className="remove-media-icon" title={translateString('Delete media')}>
|
||||
<i className="material-icons">delete</i>
|
||||
</button>
|
||||
</PopupTrigger>
|
||||
) : null}
|
||||
|
||||
{userCan.deleteMedia ? (
|
||||
<PopupContent contentRef={popupContentRef}>
|
||||
<PopupMain>
|
||||
<div className="popup-message">
|
||||
<span className="popup-message-title">Media removal</span>
|
||||
<span className="popup-message-main">
|
||||
You're willing to remove media permanently?
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<span className="popup-message-bottom">
|
||||
<button
|
||||
className="button-link cancel-comment-removal"
|
||||
onClick={cancelMediaRemoval}
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
<button
|
||||
className="button-link proceed-comment-removal"
|
||||
onClick={proceedMediaRemoval}
|
||||
>
|
||||
PROCEED
|
||||
</button>
|
||||
</span>
|
||||
</PopupMain>
|
||||
</PopupContent>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommentsList />
|
||||
</div>
|
||||
);
|
||||
{!inEmbeddedApp() && <CommentsList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +1,119 @@
|
||||
import React from 'react';
|
||||
import { formatViewsNumber } from '../../utils/helpers/';
|
||||
import { formatViewsNumber, inEmbeddedApp } from '../../utils/helpers/';
|
||||
import { PageStore, MediaPageStore } from '../../utils/stores/';
|
||||
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 { translateString } from '../../utils/helpers/';
|
||||
|
||||
export default class ViewerInfoVideoTitleBanner extends ViewerInfoTitleBanner {
|
||||
render() {
|
||||
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
|
||||
render() {
|
||||
const displayViews = PageStore.get('config-options').pages.media.displayViews && void 0 !== this.props.views;
|
||||
|
||||
const mediaData = MediaPageStore.get('media-data');
|
||||
const mediaState = mediaData.state;
|
||||
const isShared = mediaData.is_shared;
|
||||
const mediaData = MediaPageStore.get('media-data');
|
||||
const mediaState = mediaData.state;
|
||||
const isShared = mediaData.is_shared;
|
||||
|
||||
let stateTooltip = '';
|
||||
let stateTooltip = '';
|
||||
|
||||
switch (mediaState) {
|
||||
case 'private':
|
||||
stateTooltip = 'The site admins have to make its access public';
|
||||
break;
|
||||
case 'unlisted':
|
||||
stateTooltip = 'The site admins have to make it appear on listings';
|
||||
break;
|
||||
switch (mediaState) {
|
||||
case 'private':
|
||||
stateTooltip = 'The site admins have to make its access public';
|
||||
break;
|
||||
case 'unlisted':
|
||||
stateTooltip = 'The site admins have to make it appear on listings';
|
||||
break;
|
||||
}
|
||||
|
||||
const sharedTooltip = 'This media is shared with specific users or categories';
|
||||
|
||||
return (
|
||||
<div className="media-title-banner">
|
||||
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? this.mediaCategories(true)
|
||||
: null}
|
||||
|
||||
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
|
||||
|
||||
{isShared || 'public' !== mediaState ? (
|
||||
<div className="media-labels-area">
|
||||
<div className="media-labels-area-inner">
|
||||
{isShared ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>shared</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={sharedTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : 'public' !== mediaState ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>{mediaState}</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={stateTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
'media-views-actions' +
|
||||
(this.state.likedMedia ? ' liked-media' : '') +
|
||||
(this.state.dislikedMedia ? ' disliked-media' : '')
|
||||
}
|
||||
>
|
||||
{!displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? this.mediaCategories()
|
||||
: null}
|
||||
|
||||
{displayViews ? (
|
||||
<div className="media-views">
|
||||
{formatViewsNumber(this.props.views, true)}{' '}
|
||||
{1 >= this.props.views ? translateString('view') : translateString('views')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="media-actions">
|
||||
<div>
|
||||
{MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null}
|
||||
{MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null}
|
||||
{!inEmbeddedApp() && MemberContext._currentValue.can.shareMedia ? (
|
||||
<MediaShareButton isVideo={true} />
|
||||
) : null}
|
||||
|
||||
{!inEmbeddedApp() &&
|
||||
!MemberContext._currentValue.is.anonymous &&
|
||||
MemberContext._currentValue.can.saveMedia &&
|
||||
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
|
||||
<MediaSaveButton />
|
||||
) : null}
|
||||
|
||||
{!this.props.allowDownload || !MemberContext._currentValue.can.downloadMedia ? null : !this
|
||||
.downloadLink ? (
|
||||
<VideoMediaDownloadLink />
|
||||
) : (
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||
)}
|
||||
|
||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedTooltip = 'This media is shared with specific users or categories';
|
||||
|
||||
return (
|
||||
<div className="media-title-banner">
|
||||
{displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? this.mediaCategories(true)
|
||||
: null}
|
||||
|
||||
{void 0 !== this.props.title ? <h1>{this.props.title}</h1> : null}
|
||||
|
||||
{isShared || 'public' !== mediaState ? (
|
||||
<div className="media-labels-area">
|
||||
<div className="media-labels-area-inner">
|
||||
{isShared ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>shared</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={sharedTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : 'public' !== mediaState ? (
|
||||
<>
|
||||
<span className="media-label-state">
|
||||
<span>{mediaState}</span>
|
||||
</span>
|
||||
<span className="helper-icon" data-tooltip={stateTooltip}>
|
||||
<i className="material-icons">help_outline</i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
'media-views-actions' +
|
||||
(this.state.likedMedia ? ' liked-media' : '') +
|
||||
(this.state.dislikedMedia ? ' disliked-media' : '')
|
||||
}
|
||||
>
|
||||
{!displayViews && PageStore.get('config-options').pages.media.categoriesWithTitle
|
||||
? this.mediaCategories()
|
||||
: null}
|
||||
|
||||
{displayViews ? (
|
||||
<div className="media-views">
|
||||
{formatViewsNumber(this.props.views, true)} {1 >= this.props.views ? translateString('view') : translateString('views')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="media-actions">
|
||||
<div>
|
||||
{MemberContext._currentValue.can.likeMedia ? <MediaLikeIcon /> : null}
|
||||
{MemberContext._currentValue.can.dislikeMedia ? <MediaDislikeIcon /> : null}
|
||||
{MemberContext._currentValue.can.shareMedia ? <MediaShareButton isVideo={true} /> : null}
|
||||
|
||||
{!MemberContext._currentValue.is.anonymous &&
|
||||
MemberContext._currentValue.can.saveMedia &&
|
||||
-1 < PlaylistsContext._currentValue.mediaTypes.indexOf(MediaPageStore.get('media-type')) ? (
|
||||
<MediaSaveButton />
|
||||
) : null}
|
||||
|
||||
{!this.props.allowDownload || !MemberContext._currentValue.can.downloadMedia ? null : !this
|
||||
.downloadLink ? (
|
||||
<VideoMediaDownloadLink />
|
||||
) : (
|
||||
<OtherMediaDownloadLink link={this.downloadLink} title={this.downloadFilename} />
|
||||
)}
|
||||
|
||||
<MediaMoreOptionsIcon allowDownload={this.props.allowDownload} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,8 +410,12 @@ export default class VideoViewer extends React.PureComponent {
|
||||
poster: this.videoPoster,
|
||||
previewSprite: previewSprite,
|
||||
subtitlesInfo: this.props.data.subtitles_info,
|
||||
enableAutoplay: !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,
|
||||
hasNextLink: !!nextLink,
|
||||
nextLink: nextLink,
|
||||
@@ -435,9 +439,19 @@ export default class VideoViewer extends React.PureComponent {
|
||||
|
||||
VideoViewer.defaultProps = {
|
||||
inEmbed: !0,
|
||||
showTitle: !0,
|
||||
showRelated: !0,
|
||||
showUserAvatar: !0,
|
||||
linkTitle: !0,
|
||||
timestamp: null,
|
||||
siteUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
VideoViewer.propTypes = {
|
||||
inEmbed: PropTypes.bool,
|
||||
showTitle: PropTypes.bool,
|
||||
showRelated: PropTypes.bool,
|
||||
showUserAvatar: PropTypes.bool,
|
||||
linkTitle: PropTypes.bool,
|
||||
timestamp: PropTypes.number,
|
||||
};
|
||||
@@ -1,28 +1,33 @@
|
||||
.page-main-wrap {
|
||||
padding-top: var(--header-height);
|
||||
will-change: padding-left;
|
||||
padding-top: var(--header-height);
|
||||
will-change: padding-left;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.visible-sidebar & {
|
||||
padding-left: var(--sidebar-width);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.visible-sidebar #page-media & {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.visible-sidebar & {
|
||||
padding-left: var(--sidebar-width);
|
||||
opacity: 1;
|
||||
#page-media {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visible-sidebar #page-media & {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.visible-sidebar & {
|
||||
#page-media {
|
||||
padding-left: 0;
|
||||
body.sliding-sidebar & {
|
||||
transition-property: padding-left;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
body.sliding-sidebar & {
|
||||
transition-property: padding-left;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
.embedded-app & {
|
||||
padding-top: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#page-profile-media,
|
||||
@@ -30,20 +35,20 @@
|
||||
#page-profile-about,
|
||||
#page-liked.profile-page-liked,
|
||||
#page-history.profile-page-history {
|
||||
.page-main {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
.page-main {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
}
|
||||
|
||||
.page-main {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-main-inner {
|
||||
display: block;
|
||||
margin: 1em 1em 0 1em;
|
||||
display: block;
|
||||
margin: 1em 1em 0 1em;
|
||||
}
|
||||
|
||||
#page-profile-media,
|
||||
@@ -51,7 +56,7 @@
|
||||
#page-profile-about,
|
||||
#page-liked.profile-page-liked,
|
||||
#page-history.profile-page-history {
|
||||
.page-main-wrap {
|
||||
background-color: var(--body-bg-color);
|
||||
}
|
||||
.page-main-wrap {
|
||||
background-color: var(--body-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ export const EmbedPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="embed-wrap" style={wrapperStyles}>
|
||||
<div className="embed-wrap media-embed-wrap" style={wrapperStyles}>
|
||||
{failedMediaLoad && (
|
||||
<div className="player-container player-container-error" style={containerStyles}>
|
||||
<div className="player-container-inner" style={containerStyles}>
|
||||
@@ -59,9 +59,32 @@ export const EmbedPage: React.FC = () => {
|
||||
|
||||
{loadedVideo && (
|
||||
<SiteConsumer>
|
||||
{(site) => (
|
||||
<VideoViewer data={MediaPageStore.get('media-data')} siteUrl={site.url} containerStyles={containerStyles} />
|
||||
)}
|
||||
{(site) => {
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import UrlParse from 'url-parse';
|
||||
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 { PageStore, ProfilePageStore } from '../utils/stores/';
|
||||
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
|
||||
@@ -268,7 +268,7 @@ export class ProfileAboutPage extends ProfileMediaPage {
|
||||
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" />
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="about" hideChannelBanner={inEmbeddedApp()} />
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent" enabledContactForm={this.enabledContactForm}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ApiUrlConsumer } from '../utils/contexts/';
|
||||
import { PageStore } from '../utils/stores/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
|
||||
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
|
||||
@@ -28,7 +29,7 @@ export class ProfileHistoryPage extends ProfileMediaPage {
|
||||
pageContent() {
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" />
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="history" hideChannelBanner={inEmbeddedApp()} />
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ApiUrlConsumer } from '../utils/contexts/';
|
||||
import { PageStore } from '../utils/stores/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
|
||||
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
|
||||
@@ -28,7 +29,7 @@ export class ProfileLikedPage extends ProfileMediaPage {
|
||||
pageContent() {
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" />
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="liked" hideChannelBanner={inEmbeddedApp()} />
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ApiUrlConsumer } from '../utils/contexts/';
|
||||
import { PageStore } from '../utils/stores/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/';
|
||||
import { MediaListWrapper } from '../components/MediaListWrapper';
|
||||
import ProfilePagesHeader from '../components/profile-page/ProfilePagesHeader';
|
||||
import ProfilePagesContent from '../components/profile-page/ProfilePagesContent';
|
||||
@@ -30,7 +31,7 @@ export class ProfilePlaylistsPage extends ProfileMediaPage {
|
||||
pageContent() {
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" />
|
||||
<ProfilePagesHeader key="ProfilePagesHeader" author={this.state.author} type="playlists" hideChannelBanner={inEmbeddedApp()} />
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFi
|
||||
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
|
||||
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
|
||||
import { BulkActionsModals } from '../components/BulkActionsModals';
|
||||
import { translateString } from '../utils/helpers';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers';
|
||||
import { withBulkActions } from '../utils/hoc/withBulkActions';
|
||||
|
||||
import { Page } from './_Page';
|
||||
@@ -19,400 +19,443 @@ import { Page } from './_Page';
|
||||
import '../components/profile-page/ProfilePage.scss';
|
||||
|
||||
function EmptySharedByMe(props) {
|
||||
return (
|
||||
<LinksConsumer>
|
||||
{(links) => (
|
||||
<div className="empty-media empty-channel-media">
|
||||
<div className="welcome-title">No shared media</div>
|
||||
<div className="start-uploading">
|
||||
Media that you have shared with others will show up here.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LinksConsumer>
|
||||
);
|
||||
return (
|
||||
<LinksConsumer>
|
||||
{(links) => (
|
||||
<div className="empty-media empty-channel-media">
|
||||
<div className="welcome-title">No shared media</div>
|
||||
<div className="start-uploading">Media that you have shared with others will show up here.</div>
|
||||
</div>
|
||||
)}
|
||||
</LinksConsumer>
|
||||
);
|
||||
}
|
||||
|
||||
class ProfileSharedByMePage extends Page {
|
||||
constructor(props, pageSlug) {
|
||||
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me');
|
||||
constructor(props, pageSlug) {
|
||||
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me');
|
||||
|
||||
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me';
|
||||
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-by-me';
|
||||
|
||||
this.state = {
|
||||
channelMediaCount: -1,
|
||||
author: ProfilePageStore.get('author-data'),
|
||||
uploadsPreviewItemsCount: 0,
|
||||
title: this.props.title,
|
||||
query: ProfilePageStore.get('author-query'),
|
||||
requestUrl: null,
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
filterArgs: '',
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
};
|
||||
this.state = {
|
||||
channelMediaCount: -1,
|
||||
author: ProfilePageStore.get('author-data'),
|
||||
uploadsPreviewItemsCount: 0,
|
||||
title: this.props.title,
|
||||
query: ProfilePageStore.get('author-query'),
|
||||
requestUrl: null,
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
filterArgs: '',
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
};
|
||||
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
|
||||
this.getCountFunc = this.getCountFunc.bind(this);
|
||||
this.changeRequestQuery = this.changeRequestQuery.bind(this);
|
||||
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
|
||||
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
|
||||
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
|
||||
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
|
||||
this.getCountFunc = this.getCountFunc.bind(this);
|
||||
this.changeRequestQuery = this.changeRequestQuery.bind(this);
|
||||
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
|
||||
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
|
||||
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
|
||||
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
ProfilePageActions.load_author_data();
|
||||
}
|
||||
|
||||
authorDataLoad() {
|
||||
const author = ProfilePageStore.get('author-data');
|
||||
|
||||
let requestUrl = this.state.requestUrl;
|
||||
|
||||
if (author) {
|
||||
if (this.state.query) {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
|
||||
} else {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_by_me' + this.state.filterArgs;
|
||||
}
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
author: author,
|
||||
requestUrl: requestUrl,
|
||||
});
|
||||
}
|
||||
componentDidMount() {
|
||||
ProfilePageActions.load_author_data();
|
||||
}
|
||||
|
||||
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
|
||||
this.setState({
|
||||
uploadsPreviewItemsCount: totalAuthorPreviewItems,
|
||||
});
|
||||
}
|
||||
authorDataLoad() {
|
||||
const author = ProfilePageStore.get('author-data');
|
||||
|
||||
getCountFunc(count) {
|
||||
this.setState(
|
||||
{
|
||||
channelMediaCount: count,
|
||||
},
|
||||
() => {
|
||||
if (this.state.query) {
|
||||
let title = '';
|
||||
let requestUrl = this.state.requestUrl;
|
||||
|
||||
if (!count) {
|
||||
title = 'No results for "' + this.state.query + '"';
|
||||
} else if (1 === count) {
|
||||
title = '1 result for "' + this.state.query + '"';
|
||||
} else {
|
||||
title = count + ' results for "' + this.state.query + '"';
|
||||
}
|
||||
|
||||
this.setState({
|
||||
title: title,
|
||||
});
|
||||
if (author) {
|
||||
if (this.state.query) {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
author.id +
|
||||
'&show=shared_by_me&q=' +
|
||||
encodeURIComponent(this.state.query) +
|
||||
this.state.filterArgs;
|
||||
} else {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
author.id +
|
||||
'&show=shared_by_me' +
|
||||
this.state.filterArgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
changeRequestQuery(newQuery) {
|
||||
if (!this.state.author) {
|
||||
return;
|
||||
this.setState({
|
||||
author: author,
|
||||
requestUrl: requestUrl,
|
||||
});
|
||||
}
|
||||
|
||||
let requestUrl;
|
||||
|
||||
if (newQuery) {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
|
||||
} else {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_by_me' + this.state.filterArgs;
|
||||
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
|
||||
this.setState({
|
||||
uploadsPreviewItemsCount: totalAuthorPreviewItems,
|
||||
});
|
||||
}
|
||||
|
||||
let title = this.state.title;
|
||||
getCountFunc(count) {
|
||||
this.setState(
|
||||
{
|
||||
channelMediaCount: count,
|
||||
},
|
||||
() => {
|
||||
if (this.state.query) {
|
||||
let title = '';
|
||||
|
||||
if ('' === newQuery) {
|
||||
title = this.props.title;
|
||||
if (!count) {
|
||||
title = 'No results for "' + this.state.query + '"';
|
||||
} else if (1 === count) {
|
||||
title = '1 result for "' + this.state.query + '"';
|
||||
} else {
|
||||
title = count + ' results for "' + this.state.query + '"';
|
||||
}
|
||||
|
||||
this.setState({
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
requestUrl: requestUrl,
|
||||
query: newQuery,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleFiltersClick() {
|
||||
this.setState({
|
||||
hiddenFilters: !this.state.hiddenFilters,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleTagsClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: !this.state.hiddenTags,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleSortingClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: !this.state.hiddenSorting,
|
||||
});
|
||||
}
|
||||
|
||||
onTagSelect(tag) {
|
||||
this.setState({ selectedTag: tag }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSortSelect(sortBy) {
|
||||
this.setState({ selectedSort: sortBy }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: sortBy,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onFiltersUpdate(updatedArgs) {
|
||||
const args = {
|
||||
media_type: null,
|
||||
upload_date: null,
|
||||
duration: null,
|
||||
publish_state: null,
|
||||
sort_by: null,
|
||||
ordering: null,
|
||||
t: null,
|
||||
};
|
||||
|
||||
switch (updatedArgs.media_type) {
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
args.media_type = updatedArgs.media_type;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (updatedArgs.upload_date) {
|
||||
case 'today':
|
||||
case 'this_week':
|
||||
case 'this_month':
|
||||
case 'this_year':
|
||||
args.upload_date = updatedArgs.upload_date;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle duration filter
|
||||
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
|
||||
args.duration = updatedArgs.duration;
|
||||
}
|
||||
|
||||
// Handle publish state filter
|
||||
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
|
||||
args.publish_state = updatedArgs.publish_state;
|
||||
}
|
||||
|
||||
switch (updatedArgs.sort_by) {
|
||||
case 'date_added_desc':
|
||||
// Default sorting, no need to add parameters
|
||||
break;
|
||||
case 'date_added_asc':
|
||||
args.ordering = 'asc';
|
||||
break;
|
||||
case 'alphabetically_asc':
|
||||
args.sort_by = 'title_asc';
|
||||
break;
|
||||
case 'alphabetically_desc':
|
||||
args.sort_by = 'title_desc';
|
||||
break;
|
||||
case 'plays_least':
|
||||
args.sort_by = 'views_asc';
|
||||
break;
|
||||
case 'plays_most':
|
||||
args.sort_by = 'views_desc';
|
||||
break;
|
||||
case 'likes_least':
|
||||
args.sort_by = 'likes_asc';
|
||||
break;
|
||||
case 'likes_most':
|
||||
args.sort_by = 'likes_desc';
|
||||
break;
|
||||
}
|
||||
|
||||
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
|
||||
args.t = updatedArgs.tag;
|
||||
}
|
||||
|
||||
const newArgs = [];
|
||||
|
||||
for (let arg in args) {
|
||||
if (null !== args[arg]) {
|
||||
newArgs.push(arg + '=' + args[arg]);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
|
||||
},
|
||||
function () {
|
||||
changeRequestQuery(newQuery) {
|
||||
if (!this.state.author) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
let requestUrl;
|
||||
|
||||
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;
|
||||
if (newQuery) {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
this.state.author.id +
|
||||
'&show=shared_by_me&q=' +
|
||||
encodeURIComponent(newQuery) +
|
||||
this.state.filterArgs;
|
||||
} 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;
|
||||
|
||||
if ('' === newQuery) {
|
||||
title = this.props.title;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
requestUrl: requestUrl,
|
||||
requestUrl: requestUrl,
|
||||
query: newQuery,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onResponseDataLoaded(responseData) {
|
||||
if (responseData && responseData.tags) {
|
||||
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
|
||||
this.setState({ availableTags: tags });
|
||||
}
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
onToggleFiltersClick() {
|
||||
this.setState({
|
||||
hiddenFilters: !this.state.hiddenFilters,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
onToggleTagsClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: !this.state.hiddenTags,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = this.state.filterArgs && (
|
||||
this.state.filterArgs.includes('media_type=') ||
|
||||
this.state.filterArgs.includes('upload_date=') ||
|
||||
this.state.filterArgs.includes('duration=') ||
|
||||
this.state.filterArgs.includes('publish_state=')
|
||||
);
|
||||
onToggleSortingClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: !this.state.hiddenSorting,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader
|
||||
key="ProfilePagesHeader"
|
||||
author={this.state.author}
|
||||
type="shared_by_me"
|
||||
onQueryChange={this.changeRequestQuery}
|
||||
onToggleFiltersClick={this.onToggleFiltersClick}
|
||||
onToggleTagsClick={this.onToggleTagsClick}
|
||||
onToggleSortingClick={this.onToggleSortingClick}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
hasActiveTags={this.state.selectedTag !== 'all'}
|
||||
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
|
||||
/>
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={this.state.title}
|
||||
className="items-list-ver"
|
||||
showBulkActions={isMediaAuthor}
|
||||
selectedCount={this.props.bulkActions.selectedMedia.size}
|
||||
totalCount={this.props.bulkActions.availableMediaIds.length}
|
||||
onBulkAction={this.props.bulkActions.handleBulkAction}
|
||||
onSelectAll={this.props.bulkActions.handleSelectAll}
|
||||
onDeselectAll={this.props.bulkActions.handleDeselectAll}
|
||||
>
|
||||
<ProfileMediaFilters 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} />
|
||||
<LazyLoadItemListAsync
|
||||
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
|
||||
requestUrl={this.state.requestUrl}
|
||||
hideAuthor={true}
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={isMediaAuthor}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isMediaAuthor}
|
||||
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
|
||||
selectedMedia={this.props.bulkActions.selectedMedia}
|
||||
onMediaSelection={this.props.bulkActions.handleMediaSelection}
|
||||
onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedByMe name={this.state.author.name} />
|
||||
) : null}
|
||||
</MediaListWrapper>
|
||||
</ProfilePagesContent>
|
||||
) : null,
|
||||
this.state.author && isMediaAuthor ? (
|
||||
<BulkActionsModals
|
||||
key="BulkActionsModals"
|
||||
{...this.props.bulkActions}
|
||||
selectedMediaIds={Array.from(this.props.bulkActions.selectedMedia)}
|
||||
csrfToken={this.props.bulkActions.getCsrfToken()}
|
||||
username={this.state.author.username}
|
||||
onConfirmCancel={this.props.bulkActions.handleConfirmCancel}
|
||||
onConfirmProceed={this.props.bulkActions.handleConfirmProceed}
|
||||
onPermissionModalCancel={this.props.bulkActions.handlePermissionModalCancel}
|
||||
onPermissionModalSuccess={this.props.bulkActions.handlePermissionModalSuccess}
|
||||
onPermissionModalError={this.props.bulkActions.handlePermissionModalError}
|
||||
onPlaylistModalCancel={this.props.bulkActions.handlePlaylistModalCancel}
|
||||
onPlaylistModalSuccess={this.props.bulkActions.handlePlaylistModalSuccess}
|
||||
onPlaylistModalError={this.props.bulkActions.handlePlaylistModalError}
|
||||
onChangeOwnerModalCancel={this.props.bulkActions.handleChangeOwnerModalCancel}
|
||||
onChangeOwnerModalSuccess={this.props.bulkActions.handleChangeOwnerModalSuccess}
|
||||
onChangeOwnerModalError={this.props.bulkActions.handleChangeOwnerModalError}
|
||||
onPublishStateModalCancel={this.props.bulkActions.handlePublishStateModalCancel}
|
||||
onPublishStateModalSuccess={this.props.bulkActions.handlePublishStateModalSuccess}
|
||||
onPublishStateModalError={this.props.bulkActions.handlePublishStateModalError}
|
||||
onCategoryModalCancel={this.props.bulkActions.handleCategoryModalCancel}
|
||||
onCategoryModalSuccess={this.props.bulkActions.handleCategoryModalSuccess}
|
||||
onCategoryModalError={this.props.bulkActions.handleCategoryModalError}
|
||||
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
|
||||
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
|
||||
onTagModalError={this.props.bulkActions.handleTagModalError}
|
||||
/>
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
onTagSelect(tag) {
|
||||
this.setState({ selectedTag: tag }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSortSelect(sortBy) {
|
||||
this.setState({ selectedSort: sortBy }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: sortBy,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onFiltersUpdate(updatedArgs) {
|
||||
const args = {
|
||||
media_type: null,
|
||||
upload_date: null,
|
||||
duration: null,
|
||||
publish_state: null,
|
||||
sort_by: null,
|
||||
ordering: null,
|
||||
t: null,
|
||||
};
|
||||
|
||||
switch (updatedArgs.media_type) {
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
args.media_type = updatedArgs.media_type;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (updatedArgs.upload_date) {
|
||||
case 'today':
|
||||
case 'this_week':
|
||||
case 'this_month':
|
||||
case 'this_year':
|
||||
args.upload_date = updatedArgs.upload_date;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle duration filter
|
||||
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
|
||||
args.duration = updatedArgs.duration;
|
||||
}
|
||||
|
||||
// Handle publish state filter
|
||||
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
|
||||
args.publish_state = updatedArgs.publish_state;
|
||||
}
|
||||
|
||||
switch (updatedArgs.sort_by) {
|
||||
case 'date_added_desc':
|
||||
// Default sorting, no need to add parameters
|
||||
break;
|
||||
case 'date_added_asc':
|
||||
args.ordering = 'asc';
|
||||
break;
|
||||
case 'alphabetically_asc':
|
||||
args.sort_by = 'title_asc';
|
||||
break;
|
||||
case 'alphabetically_desc':
|
||||
args.sort_by = 'title_desc';
|
||||
break;
|
||||
case 'plays_least':
|
||||
args.sort_by = 'views_asc';
|
||||
break;
|
||||
case 'plays_most':
|
||||
args.sort_by = 'views_desc';
|
||||
break;
|
||||
case 'likes_least':
|
||||
args.sort_by = 'likes_asc';
|
||||
break;
|
||||
case 'likes_most':
|
||||
args.sort_by = 'likes_desc';
|
||||
break;
|
||||
}
|
||||
|
||||
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
|
||||
args.t = updatedArgs.tag;
|
||||
}
|
||||
|
||||
const newArgs = [];
|
||||
|
||||
for (let arg in args) {
|
||||
if (null !== args[arg]) {
|
||||
newArgs.push(arg + '=' + args[arg]);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
|
||||
},
|
||||
function () {
|
||||
if (!this.state.author) {
|
||||
return;
|
||||
}
|
||||
|
||||
let requestUrl;
|
||||
|
||||
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;
|
||||
} else {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
this.state.author.id +
|
||||
'&show=shared_by_me' +
|
||||
this.state.filterArgs;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
requestUrl: requestUrl,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onResponseDataLoaded(responseData) {
|
||||
if (responseData && responseData.tags) {
|
||||
const tags = responseData.tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
this.setState({ availableTags: tags });
|
||||
}
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters =
|
||||
this.state.filterArgs &&
|
||||
(this.state.filterArgs.includes('media_type=') ||
|
||||
this.state.filterArgs.includes('upload_date=') ||
|
||||
this.state.filterArgs.includes('duration=') ||
|
||||
this.state.filterArgs.includes('publish_state='));
|
||||
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader
|
||||
key="ProfilePagesHeader"
|
||||
author={this.state.author}
|
||||
type="shared_by_me"
|
||||
onQueryChange={this.changeRequestQuery}
|
||||
onToggleFiltersClick={this.onToggleFiltersClick}
|
||||
onToggleTagsClick={this.onToggleTagsClick}
|
||||
onToggleSortingClick={this.onToggleSortingClick}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
hasActiveTags={this.state.selectedTag !== 'all'}
|
||||
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
|
||||
hideChannelBanner={inEmbeddedApp()}
|
||||
/>
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={this.state.title}
|
||||
className="items-list-ver"
|
||||
showBulkActions={isMediaAuthor}
|
||||
selectedCount={this.props.bulkActions.selectedMedia.size}
|
||||
totalCount={this.props.bulkActions.availableMediaIds.length}
|
||||
onBulkAction={this.props.bulkActions.handleBulkAction}
|
||||
onSelectAll={this.props.bulkActions.handleSelectAll}
|
||||
onDeselectAll={this.props.bulkActions.handleDeselectAll}
|
||||
>
|
||||
<ProfileMediaFilters
|
||||
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} />
|
||||
<LazyLoadItemListAsync
|
||||
key={`${this.state.requestUrl}-${this.props.bulkActions.listKey}`}
|
||||
requestUrl={this.state.requestUrl}
|
||||
hideAuthor={true}
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={isMediaAuthor}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
showSelection={isMediaAuthor}
|
||||
hasAnySelection={this.props.bulkActions.selectedMedia.size > 0}
|
||||
selectedMedia={this.props.bulkActions.selectedMedia}
|
||||
onMediaSelection={this.props.bulkActions.handleMediaSelection}
|
||||
onItemsUpdate={this.props.bulkActions.handleItemsUpdate}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedByMe name={this.state.author.name} />
|
||||
) : null}
|
||||
</MediaListWrapper>
|
||||
</ProfilePagesContent>
|
||||
) : null,
|
||||
this.state.author && isMediaAuthor ? (
|
||||
<BulkActionsModals
|
||||
key="BulkActionsModals"
|
||||
{...this.props.bulkActions}
|
||||
selectedMediaIds={Array.from(this.props.bulkActions.selectedMedia)}
|
||||
csrfToken={this.props.bulkActions.getCsrfToken()}
|
||||
username={this.state.author.username}
|
||||
onConfirmCancel={this.props.bulkActions.handleConfirmCancel}
|
||||
onConfirmProceed={this.props.bulkActions.handleConfirmProceed}
|
||||
onPermissionModalCancel={this.props.bulkActions.handlePermissionModalCancel}
|
||||
onPermissionModalSuccess={this.props.bulkActions.handlePermissionModalSuccess}
|
||||
onPermissionModalError={this.props.bulkActions.handlePermissionModalError}
|
||||
onPlaylistModalCancel={this.props.bulkActions.handlePlaylistModalCancel}
|
||||
onPlaylistModalSuccess={this.props.bulkActions.handlePlaylistModalSuccess}
|
||||
onPlaylistModalError={this.props.bulkActions.handlePlaylistModalError}
|
||||
onChangeOwnerModalCancel={this.props.bulkActions.handleChangeOwnerModalCancel}
|
||||
onChangeOwnerModalSuccess={this.props.bulkActions.handleChangeOwnerModalSuccess}
|
||||
onChangeOwnerModalError={this.props.bulkActions.handleChangeOwnerModalError}
|
||||
onPublishStateModalCancel={this.props.bulkActions.handlePublishStateModalCancel}
|
||||
onPublishStateModalSuccess={this.props.bulkActions.handlePublishStateModalSuccess}
|
||||
onPublishStateModalError={this.props.bulkActions.handlePublishStateModalError}
|
||||
onCategoryModalCancel={this.props.bulkActions.handleCategoryModalCancel}
|
||||
onCategoryModalSuccess={this.props.bulkActions.handleCategoryModalSuccess}
|
||||
onCategoryModalError={this.props.bulkActions.handleCategoryModalError}
|
||||
onTagModalCancel={this.props.bulkActions.handleTagModalCancel}
|
||||
onTagModalSuccess={this.props.bulkActions.handleTagModalSuccess}
|
||||
onTagModalError={this.props.bulkActions.handleTagModalError}
|
||||
/>
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
ProfileSharedByMePage.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
bulkActions: PropTypes.object.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
bulkActions: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
ProfileSharedByMePage.defaultProps = {
|
||||
title: 'Shared by me',
|
||||
title: 'Shared by me',
|
||||
};
|
||||
|
||||
// Wrap with HOC and export as named export for compatibility
|
||||
|
||||
@@ -10,364 +10,404 @@ import { LazyLoadItemListAsync } from '../components/item-list/LazyLoadItemListA
|
||||
import { ProfileMediaFilters } from '../components/search-filters/ProfileMediaFilters';
|
||||
import { ProfileMediaTags } from '../components/search-filters/ProfileMediaTags';
|
||||
import { ProfileMediaSorting } from '../components/search-filters/ProfileMediaSorting';
|
||||
import { translateString } from '../utils/helpers';
|
||||
import { inEmbeddedApp, translateString } from '../utils/helpers';
|
||||
|
||||
import { Page } from './_Page';
|
||||
|
||||
import '../components/profile-page/ProfilePage.scss';
|
||||
|
||||
function EmptySharedWithMe(props) {
|
||||
return (
|
||||
<LinksConsumer>
|
||||
{(links) => (
|
||||
<div className="empty-media empty-channel-media">
|
||||
<div className="welcome-title">No shared media</div>
|
||||
<div className="start-uploading">
|
||||
Media that others have shared with you will show up here.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LinksConsumer>
|
||||
);
|
||||
return (
|
||||
<LinksConsumer>
|
||||
{(links) => (
|
||||
<div className="empty-media empty-channel-media">
|
||||
<div className="welcome-title">No shared media</div>
|
||||
<div className="start-uploading">Media that others have shared with you will show up here.</div>
|
||||
</div>
|
||||
)}
|
||||
</LinksConsumer>
|
||||
);
|
||||
}
|
||||
|
||||
export class ProfileSharedWithMePage extends Page {
|
||||
constructor(props, pageSlug) {
|
||||
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me');
|
||||
constructor(props, pageSlug) {
|
||||
super(props, 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me');
|
||||
|
||||
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me';
|
||||
this.profilePageSlug = 'string' === typeof pageSlug ? pageSlug : 'author-shared-with-me';
|
||||
|
||||
this.state = {
|
||||
channelMediaCount: -1,
|
||||
author: ProfilePageStore.get('author-data'),
|
||||
uploadsPreviewItemsCount: 0,
|
||||
title: this.props.title,
|
||||
query: ProfilePageStore.get('author-query'),
|
||||
requestUrl: null,
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
filterArgs: '',
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
};
|
||||
this.state = {
|
||||
channelMediaCount: -1,
|
||||
author: ProfilePageStore.get('author-data'),
|
||||
uploadsPreviewItemsCount: 0,
|
||||
title: this.props.title,
|
||||
query: ProfilePageStore.get('author-query'),
|
||||
requestUrl: null,
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
filterArgs: '',
|
||||
availableTags: [],
|
||||
selectedTag: 'all',
|
||||
selectedSort: 'date_added_desc',
|
||||
};
|
||||
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
|
||||
this.getCountFunc = this.getCountFunc.bind(this);
|
||||
this.changeRequestQuery = this.changeRequestQuery.bind(this);
|
||||
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
|
||||
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
|
||||
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
|
||||
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
this.authorDataLoad = this.authorDataLoad.bind(this);
|
||||
this.onAuthorPreviewItemsCountCallback = this.onAuthorPreviewItemsCountCallback.bind(this);
|
||||
this.getCountFunc = this.getCountFunc.bind(this);
|
||||
this.changeRequestQuery = this.changeRequestQuery.bind(this);
|
||||
this.onToggleFiltersClick = this.onToggleFiltersClick.bind(this);
|
||||
this.onToggleTagsClick = this.onToggleTagsClick.bind(this);
|
||||
this.onToggleSortingClick = this.onToggleSortingClick.bind(this);
|
||||
this.onFiltersUpdate = this.onFiltersUpdate.bind(this);
|
||||
this.onTagSelect = this.onTagSelect.bind(this);
|
||||
this.onSortSelect = this.onSortSelect.bind(this);
|
||||
this.onResponseDataLoaded = this.onResponseDataLoaded.bind(this);
|
||||
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
ProfilePageActions.load_author_data();
|
||||
}
|
||||
|
||||
authorDataLoad() {
|
||||
const author = ProfilePageStore.get('author-data');
|
||||
|
||||
let requestUrl = this.state.requestUrl;
|
||||
|
||||
if (author) {
|
||||
if (this.state.query) {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me&q=' + encodeURIComponent(this.state.query) + this.state.filterArgs;
|
||||
} else {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + author.id + '&show=shared_with_me' + this.state.filterArgs;
|
||||
}
|
||||
ProfilePageStore.on('load-author-data', this.authorDataLoad);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
author: author,
|
||||
requestUrl: requestUrl,
|
||||
});
|
||||
}
|
||||
componentDidMount() {
|
||||
ProfilePageActions.load_author_data();
|
||||
}
|
||||
|
||||
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
|
||||
this.setState({
|
||||
uploadsPreviewItemsCount: totalAuthorPreviewItems,
|
||||
});
|
||||
}
|
||||
authorDataLoad() {
|
||||
const author = ProfilePageStore.get('author-data');
|
||||
|
||||
getCountFunc(count) {
|
||||
this.setState(
|
||||
{
|
||||
channelMediaCount: count,
|
||||
},
|
||||
() => {
|
||||
if (this.state.query) {
|
||||
let title = '';
|
||||
let requestUrl = this.state.requestUrl;
|
||||
|
||||
if (!count) {
|
||||
title = 'No results for "' + this.state.query + '"';
|
||||
} else if (1 === count) {
|
||||
title = '1 result for "' + this.state.query + '"';
|
||||
} else {
|
||||
title = count + ' results for "' + this.state.query + '"';
|
||||
}
|
||||
|
||||
this.setState({
|
||||
title: title,
|
||||
});
|
||||
if (author) {
|
||||
if (this.state.query) {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
author.id +
|
||||
'&show=shared_with_me&q=' +
|
||||
encodeURIComponent(this.state.query) +
|
||||
this.state.filterArgs;
|
||||
} else {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
author.id +
|
||||
'&show=shared_with_me' +
|
||||
this.state.filterArgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
changeRequestQuery(newQuery) {
|
||||
if (!this.state.author) {
|
||||
return;
|
||||
this.setState({
|
||||
author: author,
|
||||
requestUrl: requestUrl,
|
||||
});
|
||||
}
|
||||
|
||||
let requestUrl;
|
||||
|
||||
if (newQuery) {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me&q=' + encodeURIComponent(newQuery) + this.state.filterArgs;
|
||||
} else {
|
||||
requestUrl = ApiUrlContext._currentValue.media + '?author=' + this.state.author.id + '&show=shared_with_me' + this.state.filterArgs;
|
||||
onAuthorPreviewItemsCountCallback(totalAuthorPreviewItems) {
|
||||
this.setState({
|
||||
uploadsPreviewItemsCount: totalAuthorPreviewItems,
|
||||
});
|
||||
}
|
||||
|
||||
let title = this.state.title;
|
||||
getCountFunc(count) {
|
||||
this.setState(
|
||||
{
|
||||
channelMediaCount: count,
|
||||
},
|
||||
() => {
|
||||
if (this.state.query) {
|
||||
let title = '';
|
||||
|
||||
if ('' === newQuery) {
|
||||
title = this.props.title;
|
||||
if (!count) {
|
||||
title = 'No results for "' + this.state.query + '"';
|
||||
} else if (1 === count) {
|
||||
title = '1 result for "' + this.state.query + '"';
|
||||
} else {
|
||||
title = count + ' results for "' + this.state.query + '"';
|
||||
}
|
||||
|
||||
this.setState({
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
requestUrl: requestUrl,
|
||||
query: newQuery,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleFiltersClick() {
|
||||
this.setState({
|
||||
hiddenFilters: !this.state.hiddenFilters,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleTagsClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: !this.state.hiddenTags,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
onToggleSortingClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: !this.state.hiddenSorting,
|
||||
});
|
||||
}
|
||||
|
||||
onTagSelect(tag) {
|
||||
this.setState({ selectedTag: tag }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSortSelect(sortBy) {
|
||||
this.setState({ selectedSort: sortBy }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: sortBy,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onFiltersUpdate(updatedArgs) {
|
||||
const args = {
|
||||
media_type: null,
|
||||
upload_date: null,
|
||||
duration: null,
|
||||
publish_state: null,
|
||||
sort_by: null,
|
||||
ordering: null,
|
||||
t: null,
|
||||
};
|
||||
|
||||
switch (updatedArgs.media_type) {
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
args.media_type = updatedArgs.media_type;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (updatedArgs.upload_date) {
|
||||
case 'today':
|
||||
case 'this_week':
|
||||
case 'this_month':
|
||||
case 'this_year':
|
||||
args.upload_date = updatedArgs.upload_date;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle duration filter
|
||||
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
|
||||
args.duration = updatedArgs.duration;
|
||||
}
|
||||
|
||||
// Handle publish state filter
|
||||
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
|
||||
args.publish_state = updatedArgs.publish_state;
|
||||
}
|
||||
|
||||
switch (updatedArgs.sort_by) {
|
||||
case 'date_added_desc':
|
||||
// Default sorting, no need to add parameters
|
||||
break;
|
||||
case 'date_added_asc':
|
||||
args.ordering = 'asc';
|
||||
break;
|
||||
case 'alphabetically_asc':
|
||||
args.sort_by = 'title_asc';
|
||||
break;
|
||||
case 'alphabetically_desc':
|
||||
args.sort_by = 'title_desc';
|
||||
break;
|
||||
case 'plays_least':
|
||||
args.sort_by = 'views_asc';
|
||||
break;
|
||||
case 'plays_most':
|
||||
args.sort_by = 'views_desc';
|
||||
break;
|
||||
case 'likes_least':
|
||||
args.sort_by = 'likes_asc';
|
||||
break;
|
||||
case 'likes_most':
|
||||
args.sort_by = 'likes_desc';
|
||||
break;
|
||||
}
|
||||
|
||||
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
|
||||
args.t = updatedArgs.tag;
|
||||
}
|
||||
|
||||
const newArgs = [];
|
||||
|
||||
for (let arg in args) {
|
||||
if (null !== args[arg]) {
|
||||
newArgs.push(arg + '=' + args[arg]);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
|
||||
},
|
||||
function () {
|
||||
changeRequestQuery(newQuery) {
|
||||
if (!this.state.author) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
let requestUrl;
|
||||
|
||||
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;
|
||||
if (newQuery) {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
this.state.author.id +
|
||||
'&show=shared_with_me&q=' +
|
||||
encodeURIComponent(newQuery) +
|
||||
this.state.filterArgs;
|
||||
} 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;
|
||||
|
||||
if ('' === newQuery) {
|
||||
title = this.props.title;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
requestUrl: requestUrl,
|
||||
requestUrl: requestUrl,
|
||||
query: newQuery,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onResponseDataLoaded(responseData) {
|
||||
if (responseData && responseData.tags) {
|
||||
const tags = responseData.tags.split(',').map((tag) => tag.trim()).filter((tag) => tag);
|
||||
this.setState({ availableTags: tags });
|
||||
}
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
onToggleFiltersClick() {
|
||||
this.setState({
|
||||
hiddenFilters: !this.state.hiddenFilters,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
onToggleTagsClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: !this.state.hiddenTags,
|
||||
hiddenSorting: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = this.state.filterArgs && (
|
||||
this.state.filterArgs.includes('media_type=') ||
|
||||
this.state.filterArgs.includes('upload_date=') ||
|
||||
this.state.filterArgs.includes('duration=') ||
|
||||
this.state.filterArgs.includes('publish_state=')
|
||||
);
|
||||
onToggleSortingClick() {
|
||||
this.setState({
|
||||
hiddenFilters: true,
|
||||
hiddenTags: true,
|
||||
hiddenSorting: !this.state.hiddenSorting,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader
|
||||
key="ProfilePagesHeader"
|
||||
author={this.state.author}
|
||||
type="shared_with_me"
|
||||
onQueryChange={this.changeRequestQuery}
|
||||
onToggleFiltersClick={this.onToggleFiltersClick}
|
||||
onToggleTagsClick={this.onToggleTagsClick}
|
||||
onToggleSortingClick={this.onToggleSortingClick}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
hasActiveTags={this.state.selectedTag !== 'all'}
|
||||
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
|
||||
/>
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper
|
||||
title={this.state.title}
|
||||
className="items-list-ver"
|
||||
>
|
||||
<ProfileMediaFilters 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} />
|
||||
<LazyLoadItemListAsync
|
||||
key={this.state.requestUrl}
|
||||
requestUrl={this.state.requestUrl}
|
||||
hideAuthor={true}
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={false}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedWithMe name={this.state.author.name} />
|
||||
) : null}
|
||||
</MediaListWrapper>
|
||||
</ProfilePagesContent>
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
onTagSelect(tag) {
|
||||
this.setState({ selectedTag: tag }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: this.state.selectedSort,
|
||||
tag: tag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSortSelect(sortBy) {
|
||||
this.setState({ selectedSort: sortBy }, () => {
|
||||
this.onFiltersUpdate({
|
||||
media_type: this.state.filterArgs.match(/media_type=([^&]+)/)?.[1],
|
||||
upload_date: this.state.filterArgs.match(/upload_date=([^&]+)/)?.[1],
|
||||
duration: this.state.filterArgs.match(/duration=([^&]+)/)?.[1],
|
||||
publish_state: this.state.filterArgs.match(/publish_state=([^&]+)/)?.[1],
|
||||
sort_by: sortBy,
|
||||
tag: this.state.selectedTag,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onFiltersUpdate(updatedArgs) {
|
||||
const args = {
|
||||
media_type: null,
|
||||
upload_date: null,
|
||||
duration: null,
|
||||
publish_state: null,
|
||||
sort_by: null,
|
||||
ordering: null,
|
||||
t: null,
|
||||
};
|
||||
|
||||
switch (updatedArgs.media_type) {
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'image':
|
||||
case 'pdf':
|
||||
args.media_type = updatedArgs.media_type;
|
||||
break;
|
||||
}
|
||||
|
||||
switch (updatedArgs.upload_date) {
|
||||
case 'today':
|
||||
case 'this_week':
|
||||
case 'this_month':
|
||||
case 'this_year':
|
||||
args.upload_date = updatedArgs.upload_date;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle duration filter
|
||||
if (updatedArgs.duration && updatedArgs.duration !== 'all') {
|
||||
args.duration = updatedArgs.duration;
|
||||
}
|
||||
|
||||
// Handle publish state filter
|
||||
if (updatedArgs.publish_state && updatedArgs.publish_state !== 'all') {
|
||||
args.publish_state = updatedArgs.publish_state;
|
||||
}
|
||||
|
||||
switch (updatedArgs.sort_by) {
|
||||
case 'date_added_desc':
|
||||
// Default sorting, no need to add parameters
|
||||
break;
|
||||
case 'date_added_asc':
|
||||
args.ordering = 'asc';
|
||||
break;
|
||||
case 'alphabetically_asc':
|
||||
args.sort_by = 'title_asc';
|
||||
break;
|
||||
case 'alphabetically_desc':
|
||||
args.sort_by = 'title_desc';
|
||||
break;
|
||||
case 'plays_least':
|
||||
args.sort_by = 'views_asc';
|
||||
break;
|
||||
case 'plays_most':
|
||||
args.sort_by = 'views_desc';
|
||||
break;
|
||||
case 'likes_least':
|
||||
args.sort_by = 'likes_asc';
|
||||
break;
|
||||
case 'likes_most':
|
||||
args.sort_by = 'likes_desc';
|
||||
break;
|
||||
}
|
||||
|
||||
if (updatedArgs.tag && updatedArgs.tag !== 'all') {
|
||||
args.t = updatedArgs.tag;
|
||||
}
|
||||
|
||||
const newArgs = [];
|
||||
|
||||
for (let arg in args) {
|
||||
if (null !== args[arg]) {
|
||||
newArgs.push(arg + '=' + args[arg]);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
filterArgs: newArgs.length ? '&' + newArgs.join('&') : '',
|
||||
},
|
||||
function () {
|
||||
if (!this.state.author) {
|
||||
return;
|
||||
}
|
||||
|
||||
let requestUrl;
|
||||
|
||||
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;
|
||||
} else {
|
||||
requestUrl =
|
||||
ApiUrlContext._currentValue.media +
|
||||
'?author=' +
|
||||
this.state.author.id +
|
||||
'&show=shared_with_me' +
|
||||
this.state.filterArgs;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
requestUrl: requestUrl,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onResponseDataLoaded(responseData) {
|
||||
if (responseData && responseData.tags) {
|
||||
const tags = responseData.tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
this.setState({ availableTags: tags });
|
||||
}
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const authorData = ProfilePageStore.get('author-data');
|
||||
|
||||
const isMediaAuthor = authorData && authorData.username === MemberContext._currentValue.username;
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters =
|
||||
this.state.filterArgs &&
|
||||
(this.state.filterArgs.includes('media_type=') ||
|
||||
this.state.filterArgs.includes('upload_date=') ||
|
||||
this.state.filterArgs.includes('duration=') ||
|
||||
this.state.filterArgs.includes('publish_state='));
|
||||
|
||||
return [
|
||||
this.state.author ? (
|
||||
<ProfilePagesHeader
|
||||
key="ProfilePagesHeader"
|
||||
author={this.state.author}
|
||||
type="shared_with_me"
|
||||
onQueryChange={this.changeRequestQuery}
|
||||
onToggleFiltersClick={this.onToggleFiltersClick}
|
||||
onToggleTagsClick={this.onToggleTagsClick}
|
||||
onToggleSortingClick={this.onToggleSortingClick}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
hasActiveTags={this.state.selectedTag !== 'all'}
|
||||
hasActiveSort={this.state.selectedSort !== 'date_added_desc'}
|
||||
hideChannelBanner={inEmbeddedApp()}
|
||||
/>
|
||||
) : null,
|
||||
this.state.author ? (
|
||||
<ProfilePagesContent key="ProfilePagesContent">
|
||||
<MediaListWrapper title={this.state.title} className="items-list-ver">
|
||||
<ProfileMediaFilters
|
||||
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} />
|
||||
<LazyLoadItemListAsync
|
||||
key={this.state.requestUrl}
|
||||
requestUrl={this.state.requestUrl}
|
||||
hideAuthor={true}
|
||||
itemsCountCallback={this.state.requestUrl ? this.getCountFunc : null}
|
||||
hideViews={!PageStore.get('config-media-item').displayViews}
|
||||
hideDate={!PageStore.get('config-media-item').displayPublishDate}
|
||||
canEdit={false}
|
||||
onResponseDataLoaded={this.onResponseDataLoaded}
|
||||
/>
|
||||
{isMediaAuthor && 0 === this.state.channelMediaCount && !this.state.query ? (
|
||||
<EmptySharedWithMe name={this.state.author.name} />
|
||||
) : null}
|
||||
</MediaListWrapper>
|
||||
</ProfilePagesContent>
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
ProfileSharedWithMePage.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
ProfileSharedWithMePage.defaultProps = {
|
||||
title: 'Shared with me',
|
||||
title: 'Shared with me',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { PageStore, MediaPageStore } from '../utils/stores/';
|
||||
import { MediaPageActions } from '../utils/actions/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/';
|
||||
import ViewerError from '../components/media-page/ViewerError';
|
||||
import ViewerInfo from '../components/media-page/ViewerInfo';
|
||||
import ViewerSidebar from '../components/media-page/ViewerSidebar';
|
||||
@@ -10,102 +11,102 @@ import '../components/media-page/MediaPage.scss';
|
||||
const wideLayoutBreakpoint = 1216;
|
||||
|
||||
export class _MediaPage extends Page {
|
||||
constructor(props) {
|
||||
super(props, 'media');
|
||||
constructor(props) {
|
||||
super(props, 'media');
|
||||
|
||||
const isWideLayout = wideLayoutBreakpoint <= window.innerWidth;
|
||||
const isWideLayout = wideLayoutBreakpoint <= window.innerWidth;
|
||||
|
||||
this.state = {
|
||||
mediaLoaded: false,
|
||||
mediaLoadFailed: false,
|
||||
wideLayout: isWideLayout,
|
||||
infoAndSidebarViewType: !isWideLayout ? 0 : 1,
|
||||
viewerClassname: 'cf viewer-section viewer-wide',
|
||||
viewerNestedClassname: 'viewer-section-nested',
|
||||
pagePlaylistLoaded: false,
|
||||
};
|
||||
this.state = {
|
||||
mediaLoaded: false,
|
||||
mediaLoadFailed: false,
|
||||
wideLayout: isWideLayout,
|
||||
infoAndSidebarViewType: !isWideLayout ? 0 : 1,
|
||||
viewerClassname: 'cf viewer-section viewer-wide',
|
||||
viewerNestedClassname: 'viewer-section-nested',
|
||||
pagePlaylistLoaded: false,
|
||||
};
|
||||
|
||||
this.onWindowResize = this.onWindowResize.bind(this);
|
||||
this.onMediaLoad = this.onMediaLoad.bind(this);
|
||||
this.onMediaLoadError = this.onMediaLoadError.bind(this);
|
||||
this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this);
|
||||
this.onWindowResize = this.onWindowResize.bind(this);
|
||||
this.onMediaLoad = this.onMediaLoad.bind(this);
|
||||
this.onMediaLoadError = this.onMediaLoadError.bind(this);
|
||||
this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this);
|
||||
|
||||
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
|
||||
MediaPageStore.on('loaded_media_error', this.onMediaLoadError);
|
||||
MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad);
|
||||
}
|
||||
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
|
||||
MediaPageStore.on('loaded_media_error', this.onMediaLoadError);
|
||||
MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
MediaPageActions.loadMediaData();
|
||||
// FIXME: Is not neccessary to check on every window dimension for changes...
|
||||
PageStore.on('window_resize', this.onWindowResize);
|
||||
}
|
||||
componentDidMount() {
|
||||
MediaPageActions.loadMediaData();
|
||||
// FIXME: Is not neccessary to check on every window dimension for changes...
|
||||
PageStore.on('window_resize', this.onWindowResize);
|
||||
}
|
||||
|
||||
onPagePlaylistLoad() {
|
||||
this.setState({
|
||||
pagePlaylistLoaded: true,
|
||||
});
|
||||
}
|
||||
onPagePlaylistLoad() {
|
||||
this.setState({
|
||||
pagePlaylistLoaded: true,
|
||||
});
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
const isWideLayout = wideLayoutBreakpoint <= window.innerWidth;
|
||||
onWindowResize() {
|
||||
const isWideLayout = wideLayoutBreakpoint <= window.innerWidth;
|
||||
|
||||
this.setState({
|
||||
wideLayout: isWideLayout,
|
||||
infoAndSidebarViewType: !isWideLayout || (MediaPageStore.isVideo() && this.state.theaterMode) ? 0 : 1,
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
wideLayout: isWideLayout,
|
||||
infoAndSidebarViewType: !isWideLayout || (MediaPageStore.isVideo() && this.state.theaterMode) ? 0 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
onMediaLoad() {
|
||||
this.setState({ mediaLoaded: true });
|
||||
}
|
||||
onMediaLoad() {
|
||||
this.setState({ mediaLoaded: true });
|
||||
}
|
||||
|
||||
onMediaLoadError() {
|
||||
this.setState({ mediaLoadFailed: true });
|
||||
}
|
||||
onMediaLoadError() {
|
||||
this.setState({ mediaLoadFailed: true });
|
||||
}
|
||||
|
||||
viewerContainerContent() {
|
||||
return null;
|
||||
}
|
||||
viewerContainerContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
mediaType() {
|
||||
return null;
|
||||
}
|
||||
mediaType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
return this.state.mediaLoadFailed ? (
|
||||
<div className={this.state.viewerClassname}>
|
||||
<ViewerError />
|
||||
</div>
|
||||
) : (
|
||||
<div className={this.state.viewerClassname}>
|
||||
<div className="viewer-container" key="viewer-container">
|
||||
{this.state.mediaLoaded ? this.viewerContainerContent() : null}
|
||||
</div>
|
||||
<div key="viewer-section-nested" className={this.state.viewerNestedClassname}>
|
||||
{!this.state.infoAndSidebarViewType
|
||||
? [
|
||||
<ViewerInfo key="viewer-info" />,
|
||||
this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
]
|
||||
: [
|
||||
this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
<ViewerInfo key="viewer-info" />,
|
||||
]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
pageContent() {
|
||||
return this.state.mediaLoadFailed ? (
|
||||
<div className={this.state.viewerClassname}>
|
||||
<ViewerError />
|
||||
</div>
|
||||
) : (
|
||||
<div className={this.state.viewerClassname}>
|
||||
<div className="viewer-container" key="viewer-container">
|
||||
{this.state.mediaLoaded ? this.viewerContainerContent() : null}
|
||||
</div>
|
||||
<div key="viewer-section-nested" className={this.state.viewerNestedClassname}>
|
||||
{!this.state.infoAndSidebarViewType
|
||||
? [
|
||||
<ViewerInfo key="viewer-info" />,
|
||||
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
]
|
||||
: [
|
||||
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
<ViewerInfo key="viewer-info" />,
|
||||
]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
import { PageStore, MediaPageStore, VideoViewerStore } from '../utils/stores/';
|
||||
import { MediaPageActions } from '../utils/actions/';
|
||||
import { inEmbeddedApp } from '../utils/helpers/';
|
||||
import ViewerInfoVideo from '../components/media-page/ViewerInfoVideo';
|
||||
import ViewerError from '../components/media-page/ViewerError';
|
||||
import ViewerSidebar from '../components/media-page/ViewerSidebar';
|
||||
@@ -11,118 +12,119 @@ import _MediaPage from './_MediaPage';
|
||||
const wideLayoutBreakpoint = 1216;
|
||||
|
||||
export class _VideoMediaPage extends Page {
|
||||
constructor(props) {
|
||||
super(props, 'media');
|
||||
constructor(props) {
|
||||
super(props, 'media');
|
||||
|
||||
this.state = {
|
||||
wideLayout: wideLayoutBreakpoint <= window.innerWidth,
|
||||
mediaLoaded: false,
|
||||
mediaLoadFailed: false,
|
||||
isVideoMedia: false,
|
||||
theaterMode: false, // FIXME: Used only in case of video media, but is included in every media page code.
|
||||
pagePlaylistLoaded: false,
|
||||
pagePlaylistData: MediaPageStore.get('playlist-data'),
|
||||
};
|
||||
this.state = {
|
||||
wideLayout: wideLayoutBreakpoint <= window.innerWidth,
|
||||
mediaLoaded: false,
|
||||
mediaLoadFailed: false,
|
||||
isVideoMedia: false,
|
||||
theaterMode: false, // FIXME: Used only in case of video media, but is included in every media page code.
|
||||
pagePlaylistLoaded: false,
|
||||
pagePlaylistData: MediaPageStore.get('playlist-data'),
|
||||
};
|
||||
|
||||
this.onWindowResize = this.onWindowResize.bind(this);
|
||||
this.onMediaLoad = this.onMediaLoad.bind(this);
|
||||
this.onMediaLoadError = this.onMediaLoadError.bind(this);
|
||||
this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this);
|
||||
this.onWindowResize = this.onWindowResize.bind(this);
|
||||
this.onMediaLoad = this.onMediaLoad.bind(this);
|
||||
this.onMediaLoadError = this.onMediaLoadError.bind(this);
|
||||
this.onPagePlaylistLoad = this.onPagePlaylistLoad.bind(this);
|
||||
|
||||
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
|
||||
MediaPageStore.on('loaded_media_error', this.onMediaLoadError);
|
||||
MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
MediaPageActions.loadMediaData();
|
||||
// FIXME: Is not neccessary to check on every window dimension for changes...
|
||||
PageStore.on('window_resize', this.onWindowResize);
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.setState({
|
||||
wideLayout: wideLayoutBreakpoint <= window.innerWidth,
|
||||
});
|
||||
}
|
||||
|
||||
onPagePlaylistLoad() {
|
||||
this.setState({
|
||||
pagePlaylistLoaded: true,
|
||||
pagePlaylistData: MediaPageStore.get('playlist-data'),
|
||||
});
|
||||
}
|
||||
|
||||
onMediaLoad() {
|
||||
const isVideoMedia = 'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type');
|
||||
|
||||
if (isVideoMedia) {
|
||||
this.onViewerModeChange = this.onViewerModeChange.bind(this);
|
||||
|
||||
VideoViewerStore.on('changed_viewer_mode', this.onViewerModeChange);
|
||||
|
||||
this.setState({
|
||||
mediaLoaded: true,
|
||||
isVideoMedia: isVideoMedia,
|
||||
theaterMode: VideoViewerStore.get('in-theater-mode'),
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
mediaLoaded: true,
|
||||
isVideoMedia: isVideoMedia,
|
||||
});
|
||||
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
|
||||
MediaPageStore.on('loaded_media_error', this.onMediaLoadError);
|
||||
MediaPageStore.on('loaded_page_playlist_data', this.onPagePlaylistLoad);
|
||||
}
|
||||
}
|
||||
|
||||
onViewerModeChange() {
|
||||
this.setState({ theaterMode: VideoViewerStore.get('in-theater-mode') });
|
||||
}
|
||||
componentDidMount() {
|
||||
MediaPageActions.loadMediaData();
|
||||
// FIXME: Is not neccessary to check on every window dimension for changes...
|
||||
PageStore.on('window_resize', this.onWindowResize);
|
||||
}
|
||||
|
||||
onMediaLoadError(a) {
|
||||
this.setState({ mediaLoadFailed: true });
|
||||
}
|
||||
onWindowResize() {
|
||||
this.setState({
|
||||
wideLayout: wideLayoutBreakpoint <= window.innerWidth,
|
||||
});
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const viewerClassname = 'cf viewer-section' + (this.state.theaterMode ? ' theater-mode' : ' viewer-wide');
|
||||
const viewerNestedClassname = 'viewer-section-nested' + (this.state.theaterMode ? ' viewer-section' : '');
|
||||
onPagePlaylistLoad() {
|
||||
this.setState({
|
||||
pagePlaylistLoaded: true,
|
||||
pagePlaylistData: MediaPageStore.get('playlist-data'),
|
||||
});
|
||||
}
|
||||
|
||||
return this.state.mediaLoadFailed ? (
|
||||
<div className={viewerClassname}>
|
||||
<ViewerError />
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewerClassname}>
|
||||
{[
|
||||
<div className="viewer-container" key="viewer-container">
|
||||
{this.state.mediaLoaded && this.state.pagePlaylistLoaded
|
||||
? this.viewerContainerContent(MediaPageStore.get('media-data'))
|
||||
: null}
|
||||
</div>,
|
||||
<div key="viewer-section-nested" className={viewerNestedClassname}>
|
||||
{!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode)
|
||||
? [
|
||||
<ViewerInfoVideo key="viewer-info" />,
|
||||
this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
]
|
||||
: [
|
||||
this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
<ViewerInfoVideo key="viewer-info" />,
|
||||
onMediaLoad() {
|
||||
const isVideoMedia =
|
||||
'video' === MediaPageStore.get('media-type') || 'audio' === MediaPageStore.get('media-type');
|
||||
|
||||
if (isVideoMedia) {
|
||||
this.onViewerModeChange = this.onViewerModeChange.bind(this);
|
||||
|
||||
VideoViewerStore.on('changed_viewer_mode', this.onViewerModeChange);
|
||||
|
||||
this.setState({
|
||||
mediaLoaded: true,
|
||||
isVideoMedia: isVideoMedia,
|
||||
theaterMode: VideoViewerStore.get('in-theater-mode'),
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
mediaLoaded: true,
|
||||
isVideoMedia: isVideoMedia,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onViewerModeChange() {
|
||||
this.setState({ theaterMode: VideoViewerStore.get('in-theater-mode') });
|
||||
}
|
||||
|
||||
onMediaLoadError(a) {
|
||||
this.setState({ mediaLoadFailed: true });
|
||||
}
|
||||
|
||||
pageContent() {
|
||||
const viewerClassname = 'cf viewer-section' + (this.state.theaterMode ? ' theater-mode' : ' viewer-wide');
|
||||
const viewerNestedClassname = 'viewer-section-nested' + (this.state.theaterMode ? ' viewer-section' : '');
|
||||
|
||||
return this.state.mediaLoadFailed ? (
|
||||
<div className={viewerClassname}>
|
||||
<ViewerError />
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewerClassname}>
|
||||
{[
|
||||
<div className="viewer-container" key="viewer-container">
|
||||
{this.state.mediaLoaded && this.state.pagePlaylistLoaded
|
||||
? this.viewerContainerContent(MediaPageStore.get('media-data'))
|
||||
: null}
|
||||
</div>,
|
||||
<div key="viewer-section-nested" className={viewerNestedClassname}>
|
||||
{!this.state.wideLayout || (this.state.isVideoMedia && this.state.theaterMode)
|
||||
? [
|
||||
<ViewerInfoVideo key="viewer-info" />,
|
||||
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
]
|
||||
: [
|
||||
!inEmbeddedApp() && this.state.pagePlaylistLoaded ? (
|
||||
<ViewerSidebar
|
||||
key="viewer-sidebar"
|
||||
mediaId={MediaPageStore.get('media-id')}
|
||||
playlistData={MediaPageStore.get('playlist-data')}
|
||||
/>
|
||||
) : null,
|
||||
<ViewerInfoVideo key="viewer-info" />,
|
||||
]}
|
||||
</div>,
|
||||
]}
|
||||
</div>,
|
||||
]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,103 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { BrowserCache } from '../classes/';
|
||||
import { PageStore } from '../stores/';
|
||||
import { addClassname, removeClassname } from '../helpers/';
|
||||
import { addClassname, removeClassname, inEmbeddedApp } from '../helpers/';
|
||||
import SiteContext from './SiteContext';
|
||||
|
||||
let slidingSidebarTimeout;
|
||||
|
||||
function onSidebarVisibilityChange(visibleSidebar) {
|
||||
clearTimeout(slidingSidebarTimeout);
|
||||
clearTimeout(slidingSidebarTimeout);
|
||||
|
||||
addClassname(document.body, 'sliding-sidebar');
|
||||
|
||||
slidingSidebarTimeout = setTimeout(function () {
|
||||
if ('media' === PageStore.get('current-page')) {
|
||||
if (visibleSidebar) {
|
||||
addClassname(document.body, 'overflow-hidden');
|
||||
} else {
|
||||
removeClassname(document.body, 'overflow-hidden');
|
||||
}
|
||||
} else {
|
||||
if (!visibleSidebar || 767 < window.innerWidth) {
|
||||
removeClassname(document.body, 'overflow-hidden');
|
||||
} else {
|
||||
addClassname(document.body, 'overflow-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleSidebar) {
|
||||
addClassname(document.body, 'visible-sidebar');
|
||||
} else {
|
||||
removeClassname(document.body, 'visible-sidebar');
|
||||
}
|
||||
addClassname(document.body, 'sliding-sidebar');
|
||||
|
||||
slidingSidebarTimeout = setTimeout(function () {
|
||||
slidingSidebarTimeout = null;
|
||||
removeClassname(document.body, 'sliding-sidebar');
|
||||
}, 220);
|
||||
}, 20);
|
||||
if ('media' === PageStore.get('current-page')) {
|
||||
if (visibleSidebar) {
|
||||
addClassname(document.body, 'overflow-hidden');
|
||||
} else {
|
||||
removeClassname(document.body, 'overflow-hidden');
|
||||
}
|
||||
} else {
|
||||
if (!visibleSidebar || 767 < window.innerWidth) {
|
||||
removeClassname(document.body, 'overflow-hidden');
|
||||
} else {
|
||||
addClassname(document.body, 'overflow-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleSidebar) {
|
||||
addClassname(document.body, 'visible-sidebar');
|
||||
} else {
|
||||
removeClassname(document.body, 'visible-sidebar');
|
||||
}
|
||||
|
||||
slidingSidebarTimeout = setTimeout(function () {
|
||||
slidingSidebarTimeout = null;
|
||||
removeClassname(document.body, 'sliding-sidebar');
|
||||
}, 220);
|
||||
}, 20);
|
||||
}
|
||||
|
||||
export const LayoutContext = createContext();
|
||||
|
||||
export const LayoutProvider = ({ children }) => {
|
||||
const site = useContext(SiteContext);
|
||||
const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400);
|
||||
const site = useContext(SiteContext);
|
||||
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 [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
|
||||
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
|
||||
const enabledSidebar = Boolean(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar'));
|
||||
|
||||
const toggleMobileSearch = () => {
|
||||
setVisibleMobileSearch(!visibleMobileSearch);
|
||||
};
|
||||
const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
|
||||
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const newval = !visibleSidebar;
|
||||
onSidebarVisibilityChange(newval);
|
||||
setVisibleSidebar(newval);
|
||||
};
|
||||
const toggleMobileSearch = () => {
|
||||
setVisibleMobileSearch(!visibleMobileSearch);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleSidebar) {
|
||||
addClassname(document.body, 'visible-sidebar');
|
||||
} else {
|
||||
removeClassname(document.body, 'visible-sidebar');
|
||||
}
|
||||
if ('media' !== PageStore.get('current-page') && 1023 < window.innerWidth) {
|
||||
cache.set('visible-sidebar', visibleSidebar);
|
||||
}
|
||||
}, [visibleSidebar]);
|
||||
const toggleSidebar = () => {
|
||||
const newval = !visibleSidebar;
|
||||
onSidebarVisibilityChange(newval);
|
||||
setVisibleSidebar(newval);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
PageStore.once('page_init', () => {
|
||||
if ('media' === PageStore.get('current-page')) {
|
||||
setVisibleSidebar(false);
|
||||
removeClassname(document.body, 'visible-sidebar');
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isEmbeddedApp && visibleSidebar) {
|
||||
addClassname(document.body, 'visible-sidebar');
|
||||
} else {
|
||||
removeClassname(document.body, 'visible-sidebar');
|
||||
}
|
||||
|
||||
setVisibleSidebar(
|
||||
'media' !== PageStore.get('current-page') &&
|
||||
1023 < window.innerWidth &&
|
||||
(null === visibleSidebar || visibleSidebar)
|
||||
);
|
||||
}, []);
|
||||
if (!isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth) {
|
||||
cache.set('visible-sidebar', visibleSidebar);
|
||||
}
|
||||
}, [isEmbeddedApp, isMediaPage, visibleSidebar]);
|
||||
|
||||
const value = {
|
||||
enabledSidebar,
|
||||
visibleSidebar,
|
||||
setVisibleSidebar,
|
||||
visibleMobileSearch,
|
||||
toggleMobileSearch,
|
||||
toggleSidebar,
|
||||
};
|
||||
useEffect(() => {
|
||||
PageStore.once('page_init', () => {
|
||||
if (isEmbeddedApp || isMediaPage) {
|
||||
setVisibleSidebar(false);
|
||||
removeClassname(document.body, 'visible-sidebar');
|
||||
}
|
||||
});
|
||||
|
||||
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
|
||||
setVisibleSidebar(
|
||||
!isEmbeddedApp && !isMediaPage && 1023 < window.innerWidth && (null === visibleSidebar || visibleSidebar)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
enabledSidebar,
|
||||
visibleSidebar,
|
||||
setVisibleSidebar,
|
||||
visibleMobileSearch,
|
||||
toggleMobileSearch,
|
||||
toggleSidebar,
|
||||
};
|
||||
|
||||
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
|
||||
};
|
||||
|
||||
export const LayoutConsumer = LayoutContext.Consumer;
|
||||
|
||||
20
frontend/src/static/js/utils/helpers/embeddedApp.ts
Normal file
20
frontend/src/static/js/utils/helpers/embeddedApp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from './quickSort';
|
||||
export * from './requests';
|
||||
export { translateString } from './translate';
|
||||
export { replaceString } from './replacementStrings';
|
||||
export * from './embeddedApp';
|
||||
|
||||
@@ -3,64 +3,83 @@ import ReactDOM from 'react-dom';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LayoutProvider } from './contexts/LayoutContext';
|
||||
import { UserProvider } from './contexts/UserContext';
|
||||
import { inEmbeddedApp } from './helpers';
|
||||
|
||||
const AppProviders = ({ children }) => (
|
||||
<LayoutProvider>
|
||||
<ThemeProvider>
|
||||
<UserProvider>{children}</UserProvider>
|
||||
</ThemeProvider>
|
||||
</LayoutProvider>
|
||||
<LayoutProvider>
|
||||
<ThemeProvider>
|
||||
<UserProvider>{children}</UserProvider>
|
||||
</ThemeProvider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
|
||||
import { PageHeader, PageSidebar } from '../components/page-layout';
|
||||
|
||||
export function renderPage(idSelector, PageComponent) {
|
||||
const appHeader = document.getElementById('app-header');
|
||||
const appSidebar = document.getElementById('app-sidebar');
|
||||
const appContent = idSelector ? document.getElementById(idSelector) : undefined;
|
||||
if (inEmbeddedApp()) {
|
||||
globalThis.document.body.classList.add('embedded-app');
|
||||
globalThis.document.body.classList.remove('visible-sidebar');
|
||||
|
||||
if (appContent && PageComponent) {
|
||||
ReactDOM.render(
|
||||
<AppProviders>
|
||||
{appHeader ? ReactDOM.createPortal(<PageHeader />, appHeader) : null}
|
||||
{appSidebar ? ReactDOM.createPortal(<PageSidebar />, appSidebar) : null}
|
||||
<PageComponent />
|
||||
</AppProviders>,
|
||||
appContent
|
||||
);
|
||||
} else if (appHeader && appSidebar) {
|
||||
ReactDOM.render(
|
||||
<AppProviders>
|
||||
{ReactDOM.createPortal(<PageHeader />, appHeader)}
|
||||
<PageSidebar />
|
||||
</AppProviders>,
|
||||
appSidebar
|
||||
);
|
||||
} else if (appHeader) {
|
||||
ReactDOM.render(
|
||||
<LayoutProvider>
|
||||
<ThemeProvider>
|
||||
<UserProvider>
|
||||
<PageHeader />
|
||||
</UserProvider>
|
||||
</ThemeProvider>
|
||||
</LayoutProvider>,
|
||||
appSidebar
|
||||
);
|
||||
} else if (appSidebar) {
|
||||
ReactDOM.render(
|
||||
<AppProviders>
|
||||
<PageSidebar />
|
||||
</AppProviders>,
|
||||
appSidebar
|
||||
);
|
||||
}
|
||||
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 appSidebar = document.getElementById('app-sidebar');
|
||||
|
||||
if (appContent && PageComponent) {
|
||||
ReactDOM.render(
|
||||
<AppProviders>
|
||||
{appHeader ? ReactDOM.createPortal(<PageHeader />, appHeader) : null}
|
||||
{appSidebar ? ReactDOM.createPortal(<PageSidebar />, appSidebar) : null}
|
||||
<PageComponent />
|
||||
</AppProviders>,
|
||||
appContent
|
||||
);
|
||||
} else if (appHeader && appSidebar) {
|
||||
ReactDOM.render(
|
||||
<AppProviders>
|
||||
{ReactDOM.createPortal(<PageHeader />, appHeader)}
|
||||
<PageSidebar />
|
||||
</AppProviders>,
|
||||
appSidebar
|
||||
);
|
||||
} else if (appHeader) {
|
||||
ReactDOM.render(
|
||||
<LayoutProvider>
|
||||
<ThemeProvider>
|
||||
<UserProvider>
|
||||
<PageHeader />
|
||||
</UserProvider>
|
||||
</ThemeProvider>
|
||||
</LayoutProvider>,
|
||||
appSidebar
|
||||
);
|
||||
} else if (appSidebar) {
|
||||
ReactDOM.render(
|
||||
<AppProviders>
|
||||
<PageSidebar />
|
||||
</AppProviders>,
|
||||
appSidebar
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEmbedPage(idSelector, PageComponent) {
|
||||
const appContent = idSelector ? document.getElementById(idSelector) : undefined;
|
||||
const appContent = idSelector ? document.getElementById(idSelector) : undefined;
|
||||
|
||||
if (appContent && PageComponent) {
|
||||
ReactDOM.render(<PageComponent />, appContent);
|
||||
}
|
||||
if (appContent && PageComponent) {
|
||||
ReactDOM.render(<PageComponent />, appContent);
|
||||
}
|
||||
}
|
||||
|
||||
302
install-rhel.sh
Normal file
302
install-rhel.sh
Normal 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
140
install.sh
Normal 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"''
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
LTI 1.3 Integration for MediaCMS
|
||||
Enables integration with Learning Management Systems like Moodle
|
||||
"""
|
||||
|
||||
default_app_config = 'lti.apps.LtiConfig'
|
||||
461
lti/adapters.py
461
lti/adapters.py
@@ -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)
|
||||
239
lti/admin.py
239
lti/admin.py
@@ -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
|
||||
16
lti/apps.py
16
lti/apps.py
@@ -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
|
||||
@@ -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)}")
|
||||
@@ -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)
|
||||
347
lti/handlers.py
347
lti/handlers.py
@@ -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
|
||||
45
lti/keys.py
45
lti/keys.py
@@ -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()
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
218
lti/models.py
218
lti/models.py
@@ -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
|
||||
178
lti/services.py
178
lti/services.py
@@ -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
|
||||
28
lti/urls.py
28
lti/urls.py
@@ -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'),
|
||||
]
|
||||
704
lti/views.py
704
lti/views.py
@@ -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 '',
|
||||
}
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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!**
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!**
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
|
||||
/**
|
||||
* LTI Launch for MediaCMS Filter - Uses Moodle's LTI libraries like Kaltura
|
||||
*
|
||||
* @package filter_mediacmslti
|
||||
* @copyright 2026 MediaCMS
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . '/../../config.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/lib.php');
|
||||
require_once($CFG->dirroot . '/mod/lti/locallib.php');
|
||||
|
||||
global $SITE;
|
||||
|
||||
require_login();
|
||||
|
||||
$mediatoken = required_param('token', PARAM_ALPHANUMEXT);
|
||||
$courseid = optional_param('courseid', 0, PARAM_INT);
|
||||
$height = optional_param('height', 540, PARAM_INT);
|
||||
$width = optional_param('width', 960, PARAM_INT);
|
||||
|
||||
// Get filter configuration
|
||||
$mediacmsurl = get_config('filter_mediacmslti', 'mediacmsurl');
|
||||
$ltitoolid = get_config('filter_mediacmslti', 'ltitoolid');
|
||||
|
||||
if (empty($mediacmsurl) || empty($ltitoolid)) {
|
||||
die('Filter not configured');
|
||||
}
|
||||
|
||||
// Get the LTI tool type
|
||||
$type = $DB->get_record('lti_types', ['id' => $ltitoolid]);
|
||||
if (!$type) {
|
||||
die('LTI tool not found');
|
||||
}
|
||||
|
||||
// Set up context - if courseid is 0, use system context
|
||||
if (0 != $courseid) {
|
||||
$context = context_course::instance($courseid);
|
||||
$course = get_course($courseid);
|
||||
} else {
|
||||
$context = context_system::instance();
|
||||
$course = $SITE;
|
||||
}
|
||||
|
||||
// Set up page
|
||||
$PAGE->set_url(new moodle_url('/filter/mediacmslti/launch.php', [
|
||||
'token' => $mediatoken,
|
||||
'courseid' => $courseid,
|
||||
'width' => $width,
|
||||
'height' => $height
|
||||
]));
|
||||
$PAGE->set_context($context);
|
||||
$PAGE->set_pagelayout('embedded');
|
||||
|
||||
// Create a dummy LTI instance object (like Kaltura does)
|
||||
$instance = new stdClass();
|
||||
$instance->id = 0; // Dummy ID - not a real activity
|
||||
$instance->course = $course->id;
|
||||
$instance->typeid = $ltitoolid;
|
||||
$instance->name = 'MediaCMS video resource';
|
||||
$instance->instructorchoiceacceptgrades = 0;
|
||||
$instance->grade = 0;
|
||||
$instance->instructorchoicesendname = 1;
|
||||
$instance->instructorchoicesendemailaddr = 1;
|
||||
$instance->launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
|
||||
|
||||
// Set custom parameters to pass media token (like deep linking does)
|
||||
// This will be included in the LTI custom claims JWT
|
||||
$instance->instructorcustomparameters = "media_friendly_token=" . $mediatoken;
|
||||
|
||||
// Get type config (standard tool URL, no modifications needed)
|
||||
$typeconfig = lti_get_type_type_config($ltitoolid);
|
||||
|
||||
// Use Moodle's LTI launch function to initiate OIDC properly
|
||||
// Pass 0 as dummy cmid since we don't have a real course module
|
||||
$content = lti_initiate_login($course->id, 0, $instance, $typeconfig, null, 'MediaCMS video resource');
|
||||
|
||||
// Inject media_token as a hidden field in the OIDC login form
|
||||
// This allows MediaCMS to receive and store it in the state parameter
|
||||
$hidden_field = '<input type="hidden" name="media_token" value="' . htmlspecialchars($mediatoken, ENT_QUOTES) . '" />';
|
||||
|
||||
// Insert the hidden field before the closing </form> tag
|
||||
$content = str_replace('</form>', $hidden_field . '</form>', $content);
|
||||
|
||||
echo $OUTPUT->header();
|
||||
echo $content;
|
||||
echo $OUTPUT->footer();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user