Compare commits

...

18 Commits

Author SHA1 Message Date
Markos Gogoulos 8a59eb6977 docs: Moodle plugin doc (#1539) 2026-06-11 16:42:31 +03:00
Casper Tollund 7ee720fee4 docs: Moodle Plugin Documentation (#1537) 2026-06-11 16:08:54 +03:00
semantic-release-bot 5e83b9f43a chore(release): 8.2.1 [skip ci]
## [8.2.1](https://github.com/mediacms-io/mediacms/compare/v8.2.0...v8.2.1) (2026-06-07)

### Bug Fixes

* SAML provider add guard to skip empty mappings before iterating ([#1536](https://github.com/mediacms-io/mediacms/issues/1536)) ([9da6a85](https://github.com/mediacms-io/mediacms/commit/9da6a85ad86f5092edb96495eeb1cca22d5334bf))
2026-06-07 14:55:57 +00:00
Markos Gogoulos 9da6a85ad8 fix: SAML provider add guard to skip empty mappings before iterating (#1536) 2026-06-07 17:55:32 +03:00
semantic-release-bot 51b1097509 chore(release): 8.2.0 [skip ci]
## [8.2.0](https://github.com/mediacms-io/mediacms/compare/v8.1.3...v8.2.0) (2026-05-31)

### Features

* configure SP certificate and private key via SAMLConfiguration ([#1531](https://github.com/mediacms-io/mediacms/issues/1531)) ([95644dc](https://github.com/mediacms-io/mediacms/commit/95644dc9615f428191d9fda0847c1b91a0b094a5))
2026-05-31 13:17:18 +00:00
Markos Gogoulos 95644dc961 feat: configure SP certificate and private key via SAMLConfiguration (#1531) 2026-05-31 16:16:46 +03:00
semantic-release-bot a3fe375a83 chore(release): 8.1.3 [skip ci]
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)

### Bug Fixes

* django connection settings ([#1529](https://github.com/mediacms-io/mediacms/issues/1529)) ([e89c4a3](https://github.com/mediacms-io/mediacms/commit/e89c4a3c8523574b5852a434ed67e281b6290584))
* prestart.sh loaddata re-runs on every container restart ([#1502](https://github.com/mediacms-io/mediacms/issues/1502)) ([777b06b](https://github.com/mediacms-io/mediacms/commit/777b06bbebf141e5b1cb27e17533fe65d57eb6cd))
2026-05-19 18:41:29 +00:00
Ayana Oide 777b06bbeb fix: prestart.sh loaddata re-runs on every container restart (#1502) 2026-05-19 21:34:39 +03:00
Markos Gogoulos e89c4a3c85 fix: django connection settings (#1529) 2026-05-19 21:34:18 +03:00
semantic-release-bot 7a02d25d0b chore(release): 8.1.2 [skip ci]
## [8.1.2](https://github.com/mediacms-io/mediacms/compare/v8.1.1...v8.1.2) (2026-05-18)

### Bug Fixes

* remove redundant check ([#1528](https://github.com/mediacms-io/mediacms/issues/1528)) ([c7a673b](https://github.com/mediacms-io/mediacms/commit/c7a673bbbf46efc37621dc4a5109a85fc10e1317))
2026-05-18 11:45:18 +00:00
Markos Gogoulos c7a673bbbf fix: remove redundant check (#1528) 2026-05-18 14:44:40 +03:00
semantic-release-bot b0c0d9a83f chore(release): 8.1.1 [skip ci]
## [8.1.1](https://github.com/mediacms-io/mediacms/compare/v8.1.0...v8.1.1) (2026-05-18)

### Bug Fixes

* x-accell headers on uploaded poster ([#1526](https://github.com/mediacms-io/mediacms/issues/1526)) ([ae63a5a](https://github.com/mediacms-io/mediacms/commit/ae63a5af647c8865b96e6e50dda1ea9d29b5bd0b))
2026-05-18 11:25:57 +00:00
Markos Gogoulos ae63a5af64 fix: x-accell headers on uploaded poster (#1526) 2026-05-18 14:25:23 +03:00
semantic-release-bot 98d5d6af8b chore(release): 8.1.0 [skip ci]
## [8.1.0](https://github.com/mediacms-io/mediacms/compare/v8.0.8...v8.1.0) (2026-05-17)

### Features

* introduce x-accell headers ([9302559](https://github.com/mediacms-io/mediacms/commit/9302559d4bb3e4d0adb299ed37438b04c39e1864))
2026-05-17 07:32:43 +00:00
Markos Gogoulos 9302559d4b feat: introduce x-accell headers 2026-05-17 10:32:13 +03:00
Markos Gogoulos 279cccb980 chore(release): 8.0.8 [skip ci] 2026-05-13 21:15:15 +03:00
semantic-release-bot 25e91e9d5e chore(release): 8.0.8 [skip ci]
## [8.0.8](https://github.com/mediacms-io/mediacms/compare/v8.0.7...v8.0.8) (2026-05-13)

### Bug Fixes

* update documentation and fix smaller issues ([#1520](https://github.com/mediacms-io/mediacms/issues/1520)) ([d6a1151](https://github.com/mediacms-io/mediacms/commit/d6a11514e54b9341ec8a306a259adce6b4199d42))
2026-05-13 18:14:36 +00:00
Markos Gogoulos d6a11514e5 fix: update documentation and fix smaller issues (#1520) 2026-05-13 21:14:02 +03:00
37 changed files with 646 additions and 321 deletions
-1
View File
@@ -24,7 +24,6 @@ If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):** **Environment (please complete the following information):**
- OS: [e.g. Ubuntu Linux] - OS: [e.g. Ubuntu Linux]
- Installation method: [Docker install, or single server install]
- Browser, if applicable - Browser, if applicable
**Additional context** **Additional context**
+43
View File
@@ -1,5 +1,48 @@
# Changelog # Changelog
## [8.2.1](https://github.com/mediacms-io/mediacms/compare/v8.2.0...v8.2.1) (2026-06-07)
### Bug Fixes
* SAML provider add guard to skip empty mappings before iterating ([#1536](https://github.com/mediacms-io/mediacms/issues/1536)) ([9da6a85](https://github.com/mediacms-io/mediacms/commit/9da6a85ad86f5092edb96495eeb1cca22d5334bf))
## [8.2.0](https://github.com/mediacms-io/mediacms/compare/v8.1.3...v8.2.0) (2026-05-31)
### Features
* configure SP certificate and private key via SAMLConfiguration ([#1531](https://github.com/mediacms-io/mediacms/issues/1531)) ([95644dc](https://github.com/mediacms-io/mediacms/commit/95644dc9615f428191d9fda0847c1b91a0b094a5))
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)
### Bug Fixes
* django connection settings ([#1529](https://github.com/mediacms-io/mediacms/issues/1529)) ([e89c4a3](https://github.com/mediacms-io/mediacms/commit/e89c4a3c8523574b5852a434ed67e281b6290584))
* prestart.sh loaddata re-runs on every container restart ([#1502](https://github.com/mediacms-io/mediacms/issues/1502)) ([777b06b](https://github.com/mediacms-io/mediacms/commit/777b06bbebf141e5b1cb27e17533fe65d57eb6cd))
## [8.1.2](https://github.com/mediacms-io/mediacms/compare/v8.1.1...v8.1.2) (2026-05-18)
### Bug Fixes
* remove redundant check ([#1528](https://github.com/mediacms-io/mediacms/issues/1528)) ([c7a673b](https://github.com/mediacms-io/mediacms/commit/c7a673bbbf46efc37621dc4a5109a85fc10e1317))
## [8.1.1](https://github.com/mediacms-io/mediacms/compare/v8.1.0...v8.1.1) (2026-05-18)
### Bug Fixes
* x-accell headers on uploaded poster ([#1526](https://github.com/mediacms-io/mediacms/issues/1526)) ([ae63a5a](https://github.com/mediacms-io/mediacms/commit/ae63a5af647c8865b96e6e50dda1ea9d29b5bd0b))
## [8.1.0](https://github.com/mediacms-io/mediacms/compare/v8.0.8...v8.1.0) (2026-05-17)
### Features
* introduce x-accell headers ([9302559](https://github.com/mediacms-io/mediacms/commit/9302559d4bb3e4d0adb299ed37438b04c39e1864))
## [8.0.8](https://github.com/mediacms-io/mediacms/compare/v8.0.7...v8.0.8) (2026-05-13)
### Bug Fixes
* update documentation and fix smaller issues ([#1520](https://github.com/mediacms-io/mediacms/issues/1520)) ([d6a1151](https://github.com/mediacms-io/mediacms/commit/d6a11514e54b9341ec8a306a259adce6b4199d42))
## [8.0.7](https://github.com/mediacms-io/mediacms/compare/v8.0.6...v8.0.7) (2026-05-12) ## [8.0.7](https://github.com/mediacms-io/mediacms/compare/v8.0.6...v8.0.7) (2026-05-12)
### Bug Fixes ### Bug Fixes
+2 -2
View File
@@ -43,7 +43,7 @@ A demo is available at https://demo.mediacms.io
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9) - **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
- **Adaptive video streaming**: possible through HLS protocol - **Adaptive video streaming**: possible through HLS protocol
- **Subtitles/CC**: support for multilingual subtitle files - **Subtitles/CC**: support for multilingual subtitle files
- **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers - **Scalable transcoding**: transcoding through priorities.
- **Chunked file uploads**: for pausable/resumable upload of content - **Chunked file uploads**: for pausable/resumable upload of content
- **REST API**: Documented through Swagger - **REST API**: Documented through Swagger
- **Translation**: Most of the CMS is translated to a number of languages - **Translation**: Most of the CMS is translated to a number of languages
@@ -91,7 +91,6 @@ In order to support automatic transcriptions through Whisper, consider more CPUs
There are two ways to run MediaCMS, through Docker Compose and through installing it on a server via an automation script that installs and configures all needed services. Find the related pages: There are two ways to run MediaCMS, through Docker Compose and through installing it on a server via an automation script that installs and configures all needed services. Find the related pages:
- [Single Server](docs/admins_docs.md#2-server-installation) page
- [Docker Compose](docs/admins_docs.md#3-docker-installation) page - [Docker Compose](docs/admins_docs.md#3-docker-installation) page
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b). A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
@@ -105,6 +104,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
* [Transcoding](docs/transcoding.md) page * [Transcoding](docs/transcoding.md) page
* [Developer Experience](docs/dev_exp.md) page * [Developer Experience](docs/dev_exp.md) page
* [Media Permissions](docs/media_permissions.md) page * [Media Permissions](docs/media_permissions.md) page
* [Moodle Plugin](docs/moodle_plugin.md) page
## Technology ## Technology
+12
View File
@@ -3,7 +3,9 @@ from __future__ import absolute_import
import os import os
from celery import Celery from celery import Celery
from celery.signals import worker_process_init
from django.conf import settings from django.conf import settings
from django.db import connections
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings")
app = Celery("cms") app = Celery("cms")
@@ -20,3 +22,13 @@ app.conf.task_always_eager = settings.CELERY_TASK_ALWAYS_EAGER
app.conf.worker_prefetch_multiplier = 1 app.conf.worker_prefetch_multiplier = 1
@worker_process_init.connect
def close_db_pool_on_fork(**_):
# psycopg3's ConnectionPool is not fork-safe: children inherit dead sockets
# from the parent's pool and block on getconn() until PoolTimeout. Dispose
# the inherited pool here so each prefork child opens its own on first use.
# NB: plain conn.close() would only putconn() back into the broken pool.
for conn in connections.all():
conn.close_pool()
+28 -16
View File
@@ -202,6 +202,15 @@ THUMBNAIL_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/thumbnails/"
SUBTITLES_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/subtitles/" SUBTITLES_UPLOAD_DIR = f"{MEDIA_UPLOAD_DIR}/subtitles/"
HLS_DIR = os.path.join(MEDIA_ROOT, "hls/") HLS_DIR = os.path.join(MEDIA_ROOT, "hls/")
# Protect media files via nginx auth_request
# When True, nginx delegates authorization for /media/<protected>/... to a
# Django endpoint that checks the Media's state and the user's access.
USE_X_ACCEL_REDIRECT = True
# Subdirectories of MEDIA_ROOT that should be gated. "chunks" is intentionally
# omitted (upload state, not playback).
X_ACCEL_PROTECTED_PATHS = ["encoded", "hls", "original"]
X_ACCEL_AUTH_CACHE_SECONDS = 300
FFMPEG_COMMAND = "ffmpeg" # this is the path FFMPEG_COMMAND = "ffmpeg" # this is the path
FFPROBE_COMMAND = "ffprobe" # this is the path FFPROBE_COMMAND = "ffprobe" # this is the path
MP4HLS = "mp4hls" MP4HLS = "mp4hls"
@@ -266,21 +275,6 @@ CANNOT_ADD_MEDIA_MESSAGE = "User cannot add media, or maximum number of media up
# mp4hls command, part of Bento4 # mp4hls command, part of Bento4
MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls" MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls"
# highly experimental, related with remote workers
ADMIN_TOKEN = ""
# this is used by remote workers to push
# encodings once they are done
# USE_BASIC_HTTP = True
# BASIC_HTTP_USER_PAIR = ('user', 'password')
# specify basic auth user/password pair for use with the
# remote workers, if nginx basic auth is setup
# apache2-utils need be installed
# then run
# htpasswd -c /home/mediacms.io/mediacms/deploy/.htpasswd user
# and set a password
# edit /etc/nginx/sites-enabled/mediacms.io and
# uncomment the two lines related to htpasswd
AUTH_USER_MODEL = "users.User" AUTH_USER_MODEL = "users.User"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
@@ -413,7 +407,25 @@ LOGGING = {
}, },
} }
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}} DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mediacms",
"HOST": "127.0.0.1",
"PORT": "5432",
"USER": "mediacms",
"PASSWORD": "mediacms",
"OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 8,
"timeout": 10,
"max_lifetime": 30 * 60,
"max_idle": 10 * 60,
}
},
}
}
REDIS_LOCATION = "redis://127.0.0.1:6379/1" REDIS_LOCATION = "redis://127.0.0.1:6379/1"
+1 -1
View File
@@ -1 +1 @@
VERSION = "8.0.7" VERSION = "8.2.1"
+9 -1
View File
@@ -12,7 +12,15 @@ DATABASES = {
"PORT": os.getenv('POSTGRES_PORT', '5432'), "PORT": os.getenv('POSTGRES_PORT', '5432'),
"USER": os.getenv('POSTGRES_USER', 'mediacms'), "USER": os.getenv('POSTGRES_USER', 'mediacms'),
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'), "PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
"OPTIONS": {'pool': True}, "OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 8,
"timeout": 10,
"max_lifetime": 30 * 60,
"max_idle": 10 * 60,
}
},
} }
} }
+20 -2
View File
@@ -15,9 +15,27 @@ server {
location /static { location /static {
alias /home/mediacms.io/mediacms/static ; alias /home/mediacms.io/mediacms/static ;
} }
location = /_media_auth {
internal;
proxy_pass http://127.0.0.1:9000/api/v1/media-auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header X-Original-URI $request_uri;
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;
proxy_set_header Cookie $http_cookie;
}
location /media/original { location ~ ^/media/(encoded|hls|original)/(.*)$ {
alias /home/mediacms.io/mediacms/media_files/original; auth_request /_media_auth;
alias /home/mediacms.io/mediacms/media_files/$1/$2;
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';
} }
location /media { location /media {
+1 -1
View File
@@ -6,7 +6,7 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD:-$RANDOM_ADMIN_PASS}
if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then
echo "Running migrations service" echo "Running migrations service"
python manage.py migrate python manage.py migrate
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell` EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell 2>/dev/null | tail -1`
if [ "$EXISTING_INSTALLATION" = "True" ]; then if [ "$EXISTING_INSTALLATION" = "True" ]; then
echo "Loaddata has already run" echo "Loaddata has already run"
else else
+3 -65
View File
@@ -2,7 +2,6 @@
## Table of contents ## Table of contents
- [1. Welcome](#1-welcome) - [1. Welcome](#1-welcome)
- [2. Single Server Installaton](#2-single-server-installation)
- [3. Docker Installation](#3-docker-installation) - [3. Docker Installation](#3-docker-installation)
- [4. Docker Deployment options](#4-docker-deployment-options) - [4. Docker Deployment options](#4-docker-deployment-options)
- [5. Configuration](#5-configuration) - [5. Configuration](#5-configuration)
@@ -34,58 +33,6 @@
## 1. Welcome ## 1. Welcome
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications. This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
## 2. Single Server Installation
The core dependencies are python3, Django, celery, PostgreSQL, redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But the install.sh is only tested in Linux Ubuntu 24 and 22 versions.
Installation on an Ubuntu 22/24 system with git utility installed should be completed in a few minutes with the following steps.
Make sure you run it as user root, on a clear system, since the automatic script will install and configure the following services: Celery/PostgreSQL/Redis/Nginx and will override any existing settings.
```bash
mkdir /home/mediacms.io && cd /home/mediacms.io/
git clone https://github.com/mediacms-io/mediacms
cd /home/mediacms.io/mediacms/ && bash ./install.sh
```
The script will ask if you have a URL where you want to deploy MediaCMS, otherwise it will use localhost. If you provide a URL, it will use Let's Encrypt service to install a valid ssl certificate.
### Update
If you've used the above way to install MediaCMS, update with the following:
```bash
cd /home/mediacms.io/mediacms # enter mediacms directory
source /home/mediacms.io/bin/activate # use virtualenv
git pull # update code
pip install -r requirements.txt -U # run pip install to update
python manage.py migrate # run Django migrations
sudo systemctl restart mediacms celery_long celery_short # restart services
```
### Update from version 2 to version 3
Version 3 is using Django 4 and Celery 5, and needs a recent Python 3.x version. If you are updating from an older version, make sure Python is updated first. Version 2 could run on Python 3.6, but version 3 needs Python3.8 and higher.
The syntax for starting Celery has also changed, so you have to copy the celery related systemctl files and restart
```
# 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
# systemctl daemon-reload
# systemctl start celery_long celery_short celery_beat
```
### Configuration
Checkout the configuration section here.
### Maintenance
Database can be backed up with pg_dump and media_files on /home/mediacms.io/mediacms/media_files include original files and encoded/transcoded versions
## 3. Docker Installation ## 3. Docker Installation
@@ -220,14 +167,10 @@ Several options are available on `cms/settings.py`, most of the things that are
It is advisable to override any of them by adding it to `local_settings.py` . It is advisable to override any of them by adding it to `local_settings.py` .
In case of a the single server installation, add to `cms/local_settings.py` .
In case of a docker compose installation, add to `deploy/docker/local_settings.py` . This will automatically overwrite `cms/local_settings.py` . In case of a docker compose installation, add to `deploy/docker/local_settings.py` . This will automatically overwrite `cms/local_settings.py` .
Any change needs restart of MediaCMS in order to take effect. Any change needs restart of MediaCMS in order to take effect.
Single server installation: edit `cms/local_settings.py`, make a change and restart MediaCMS
```bash ```bash
#systemctl restart mediacms #systemctl restart mediacms
``` ```
@@ -795,14 +738,7 @@ Instructions contributed by @alberto98fx
On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings. On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings.
In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option
Enter the Django shell, example if you're using the Single Server installation: Enter the Django shell and inside the shell
```bash
source /home/mediacms.io/bin/activate
python manage.py shell
```
and inside the shell
```bash ```bash
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
@@ -1011,6 +947,8 @@ Select the SAML Configurations tab, create a new one and set:
3. **SSO URL**: 3. **SSO URL**:
4. **SLO URL**: 4. **SLO URL**:
5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS 5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS
6. **SP Certificate** (optional): SP x509 certificate (PEM). Enables encrypted/signed SAML communication. If set, the SP Private Key must also be provided, and the certificate is published in the SP metadata so the IDP can encrypt assertions to MediaCMS.
7. **SP Private Key** (optional): SP private key (PEM). Used to sign AuthnRequests/LogoutRequests and to decrypt assertions encrypted by the IDP. Required if SP Certificate is provided.
- Step 3: Set other Options - Step 3: Set other Options
1. **Email Settings**: 1. **Email Settings**:
Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

+277
View File
@@ -0,0 +1,277 @@
# MediaCMS plugin for Moodle
## Documentation and user guides
The MediaCMS plugin brings a fully featured video content management system directly into Moodle, empowering institutions and their users with powerful media tools — all within a familiar learning environment.
At its core, MediaCMS offers a robust transcoding, transcription, and translation engine, alongside a comprehensive toolbox tailored for administrators, lecturers, students, and staff alike. Its intuitive interface and streamlined workflows mean that most media files can be uploaded, reviewed, edited, and published quickly — with no transcoding delays and no steep learning curve.
This guide covers everything needed to get started: from installation and configuration to everyday use — with dedicated sections for administrators, lecturers, and students.
![My Media](images/Moodle-My-Media.png)
## 1. For Administrators
Administrators: Installation and configuration of MediaCMS plugin for Moodle
**Prerequisites**
The MediaCMS integration in Moodle should use the same domain as the Moodle portal (further explanation in note 1 below)
E.g. `moodle.organisation.org` versus `mediacms.organisation.org`
### 1.1 Configure MediaCMS `local_settings.py` settings
```
USE_IDENTITY_PROVIDERS = True
USE_RBAC = True
USE_LTI = True
```
Restart MediaCMS services.
### 1.2 Moodle: Add External Tool
Moodle > Site Administration > Plugins > Activity Modules > External tools > Manage Tools > Configure a Tool Manually:
**Tool Settings**
- **Name:** MediaCMS
- **Tool URL:** MediaCMS issuer URL + `/lti/launch/` (e.g. `https://mediacms.organisation.org/lti/launch/`)
- **LTI version:** LTI 1.3
- **Client ID:** will be provided once the tool is saved
- **Public key type:** Keyset URL
- **Public keyset:** MediaCMS issuer URL + `/lti/jwks/` (e.g. `https://mediacms.organisation.org/lti/jwks/`)
- **Initiate login URL:** MediaCMS issuer URL + `/lti/oidc/login/` (e.g. `https://mediacms.organisation.org/lti/oidc/login/`)
- **Redirection URI(s):** MediaCMS issuer URL + `/lti/launch/` (e.g. `https://mediacms.organisation.org/lti/launch/`)
- **Tool configuration usage:** Show in activity chooser and as a preconfigured tool
- **Default launch container:** Embed, without blocks + Supports Deep Linking
**Services**
- **IMS LTI Assignment and Grade Services:** Do not use this service
- **IMS LTI Names and Role Provisioning:** Use this service to retrieve members' information as per privacy settings
- **Tool Settings:** Use this service
**Privacy**
- **Share launcher's name with tool:** Always
- **Share launcher's email with tool:** Always
- **Accept grades from the tool:** As specified in Deep Linking definition or Delegate to teacher
Save Changes.
### 1.3 Moodle: Installation and configuration of MediaCMS plugin in Moodle
#### 1.3.1 Download plugin and install MediaCMS as an LTI tool
The plugin is available in the `lms-plugins/mediacms-moodle/dist/` directory of this repository. It will also be published to the [Moodle Plugins directory](https://moodle.org/plugins) shortly.
Two methods can be used:
- **A. Copy files from ZIP to the Moodle folder structure.** Benefit: This method can be automated.
- Place the zip file in `/var/www/moodle/public` and unzip (depending on your Moodle installation directory).
- Open Moodle and the installation should start automatically.
- **B. Install plugin via** Moodle > Site Administration > Install plugins. Benefit: Easy manual workflow.
#### 1.3.2 Configuration of plugin: Site Administration > ...
- [ ] Plugins > Filters > Manage Filters > MediaCMS > Enable, and place in top of list
- [ ] Plugins > Filters > MediaCMS >
- [ ] My Media link placement: Top Navigation or User Navigation (further explanation in note 2 below)
- [ ] MediaCMS URL: e.g. `mediacms.organisation.org`
- [ ] LTI Tool: e.g. `mediacms.organisation.org/lti/launch/`
- [ ] Share Embedded Media (whether embedded media can be accessed via My Media > Shared with Me) (note 3 below)
- [ ] Plugins > Text editors > TinyMCE > MediaCMS > sets default embed options for users
- [ ] Show video title (media title option on top of video)
- [ ] Link video title (media title option on top of video linking to full media playback options: download, comments etc.)
- [ ] Show user avatar (show user's icon picture on top of video)
**Notes:**
**Note 1.** As of Q2 2026 browser providers increasingly implement measures to prevent cross-site tracking, which also set limitations on how users can view embedded content from a system X (e.g. MediaCMS), that has been embedded in system Y (e.g. Moodle). To avoid these limitations, it is recommended to use the same domain for the configuration of MediaCMS as is used for Moodle.
**Note 2.** User Navigation is the user's icon in top right corner of Moodle interface, where user profile and preferences are found.
**Note 3.** "Share Embedded Media" configuration explained:
Users are automatically assigned permissions to embedded media in Moodle Activity/Resource, if they have access to a particular Moodle Activity / Resource. Publish State of the media is set to Private and Shared, whereby media cannot be shared outside Moodle by e.g. copying a link from Moodle and using it outside Moodle. These permissions are not automatically removed, if access to the Moodle Activity/Resource is removed.
Users listed as sharing partners can be removed manually via My Media > Bulk Actions > Course Cleanup > select course > "Remove present course permissions for all course members" > Proceed. Users can still access the media in Moodle Activities / Resources, if that access is given in Moodle, whereby user will again become sharing partner, if making use of that access.
**Option 1: Share Embedded Media = True**
With this configuration users, who have accessed embedded media, can also access this media via My Media > Shared with Me. This is possible in My Media in Moodle as well as My Media in the MediaCMS video portal.
**Option 2: Share Embedded Media = False**
Select this option, if users should not have access to embedded content under My Media > Shared with Me. Select this option, if strict access control should be handled for embedded content, and not rely on e.g. manual Course Cleanup procedures.
### 1.4 MediaCMS Administration: Add Moodle as an LTI platform
In MediaCMS, go to Administration > LTI 1.3 Integration > LTI Platforms > Add LTI Platform > add:
**Basic Information**
- **Name:** What makes sense in your context
- **Platform ID:** Moodle platform's issuer URL (iss claim, e.g. `https://moodle.organisation.org`)
- **Client ID:** get it from Moodle > Site Administration > Plugins > Activity Modules > External tools > Manage Tools > MediaCMS > Edit > Client ID
**OIDC Endpoints**
- **Auth login URL:** Moodle issuer URL + `/filter/mediacms/lti_auth.php` (e.g. `https://moodle.organisation.org/filter/mediacms/lti_auth.php`)
- **Auth token URL:** Moodle issuer URL + `/mod/lti/token.php` (e.g. `https://moodle.organisation.org/mod/lti/token.php`)
- **Auth audience:** Moodle issuer URL + `/mod/lti/certs.php` (e.g. `https://moodle.organisation.org/mod/lti/certs.php`)
**JWK Configuration**
- **Key set URL:** Issuer URL + `/mod/lti/certs.php` (e.g. `https://moodle.organisation.org/mod/lti/certs.php`)
**Deployment & Features**
- **Deployment IDs:** get it from Moodle > Site Administration > Plugins > Activity Modules > External tools > Manage Tools > MediaCMS > View configuration details
- **Enable NRPS:** yes
- **Enable deep linking:** yes
**Auto-Provision Settings**
- **Remove from groups on unenroll:** yes
Done. Installation can now be tested with the role as an administrator, lecturer, student or any other role in Moodle.
## 2. For Lecturers
Lecturers: Moodle workflows covered in the MediaCMS integration
### 1. Upload media to your My Media repository
Moodle > My Media > camera icon to the right (Add Media) > Upload > Drag and drop files / Browse your files
Video, audio, pictures and PDF files can be uploaded and shared with the course members, but only video and audio files can be embedded in Moodle Activities / Resources.
### 2. Record media to your My Media repository
Make simple recordings via your browser, and add them directly to your My Media repository:
Moodle > My Media > camera icon to the right (Add Media) > Record > Record Screen with Audio (computers) / Record Video (mobiles)
Apple iPhone iOS only supports 10 minutes recording in 480p, whereas Android based systems have fewer restrictions.
### 3. Edit media
Moodle > My Media > particular media > Edit Media (pencil icon on top of media thumbnail) > ...
- **3.1 Metadata:** Edit title and description, add tags, set date, poster image, thumbnail image, enable comments
- **3.2 Trim:** Remove part of media, or split media into several new media files
- **3.3 Captions:** Automatically create transcriptions and/or English translations, or manually upload transcription files (`.srt` / `.vtt`)
- **3.4 Chapters:** Create chapters in media, which are displayed in the media player
- **3.5 Publish:** Share the media with courses, and/or make the media unlisted for publishing outside Moodle
- **3.6 Replace:** Add new media to the media instance, but retain all metadata, captions, chapters etc.
### 4. Share media with other users in Moodle or MediaCMS
Moodle > My Media > select media > Bulk Actions > Share with 1. Co-Viewers OR 2. Co-Editors OR 3. Co-Owners OR 4. Course Members
- **Note 1:** Select one or several users, with whom you want to share media, giving them viewer permissions
- **Note 2:** Similar, but giving users Editor permissions (they can edit the media and media's metadata)
- **Note 3:** Similar, but giving users Co-editor permissions (they can do everything the owner can do, except delete the media)
- **Note 4:** Select courses with which members you want to share media, giving them permissions according to permissions in the course (Students become Viewers, and Lecturers and similar roles become Co-Owners)
Alternatively, use My Media > individual media > Publish > select courses, which will have the same effect as Bulk Actions > Share with Course Members (Note 4).
Sharing media with Course Members will automatically list the media under that Course. A link to all listed course media can be accessed under the media's full viewing page. Users will only see course listings for courses, where they are members.
### 5. Embed media in Moodle course activities / resources
Two basic workflows are supported:
**Workflow A.** In the context of the media player, copy URL from the viewing page and paste it into the editing area of the text editor:
Moodle > My Media > click to view media > right click in viewing area > Copy Video URL > go to course activity / resource > activity edit mode > in the Content area > paste URL into the text editor > media will be displayed > click Edit to adjust settings for media > Insert > Save and Display
**Workflow B.** In the context of a course activity / resource, use the text editor to embed MediaCMS media:
Any course activity / resource > activate Editing > in the Content area > text editor > click green play icon: Insert MediaCMS media > select media > adjust parameters > Insert > Save and Display
If adjustments need to be applied, click edit over the media when in edit mode of the Moodle activity / resource. For media inserted via a text link, edit mode can be accessed by clicking the media link when in edit mode of the Moodle activity / resource, followed by clicking the green play icon in the editor "Insert MediaCMS media".
**Embedding parameters:**
- **Show title:** A title will be displayed on top of media
- **Show user avatar:** Shows user's icon picture on top of video
- **Link title:** Users can click on the title text over the media to get access to full media viewing, e.g. download, commenting, bookmarking (playlists), if these features have been activated by owner. Media will open in a new browser tab.
- **Insert text link only:** A link to the media will be displayed in page, instead of the media being visually embedded. User gets access to the same media viewing page, in a new tab, as described under 3.5.
- **Dimensions:** User can set maximum dimensions, which will automatically adapt to the size of the browser window / device display.
Relevant embedding parameters will be saved in a browser cookie, so that the configuration is remembered for next time a media is embedded.
Media will get the Publish state: Shared, when inserted in the activity / resource, and media will automatically be shared with specific users, when they access the Moodle activity / resource.
If administrator has activated a specific configuration (Share Embedded Media), users will be able to view the embedded shared media content under My Media > Shared with Me.
### 6. Link to, or embed media outside Moodle
To publish an external link to the media: My Media > click to view the media > right-click the viewing window > copy URL or embed code > add this to your external portal or client.
### 7. Bookmark and collect video in Playlists
Full media viewing page > Save > Save to existing Playlist, or create new Playlist for the bookmarked media.
Access your Playlists under My Media > Playlists.
### 8. Bulk Actions
Other actions available for all users:
Handle media settings for many media items in one go: Enable, disable, delete Comment and Enable or Disable Download. Manage media, such as change Publish State (Private, Shared, Unlisted), change ownership of media, or copy or delete media.
### 9. Course Cleanup
Remove existing permissions and delete comments under media in course:
Moodle > My Media > Bulk Actions > Course Cleanup
Administrators and lecturers can make use of the Bulk Action > Course Cleanup without selecting any media, whereby the cleanup will apply to all media embedded in course activities / resources, and media Shared with Course Members / Publish to the course. If only selecting specific media, the cleanup will only apply to the selected media.
#### 9.1 Moodle > My Media > Bulk Actions > Course Cleanup > select course > Remove present course permissions for all course members
If selecting this option, all users that have accessed the embedded course media will no longer be listed as sharing partners. Similarly, if media has been shared via Shared with Course Members / Publish to Course, these users will also be removed as sharing partners.
Be aware that this action does not remove user's access to media, if users still have access to the Moodle activity / resource, and the media is still embedded in that activity / resource.
#### 9.2 Moodle > My Media > Bulk Actions > Course Cleanup > select course > Remove Comments
If selecting this option, all comments added to the embedded course media, or media shared with course members, will be deleted.
Course Cleanup can ideally be used after a term, where a course has ended, and a clean sheet will provide better overview. E.g. media can be reused from course to course, without comments from previous courses getting in the way.
### 10. Provisioning of courses and users explained
A course in Moodle corresponds to a category in MediaCMS, and Moodle course roles are mapped to MediaCMS category roles:
- Student → Viewer
- Teacher → Manager
A course category in MediaCMS is created along these workflows:
- Moodle > My Media > Bulk Actions > Share with Course Members > course is added > clicking Proceed
- Moodle > My Media > media > Publish > course is added to list > clicking Publish Media
- Moodle > Course > course element (e.g. page) > Edit > TinyMCE editor > clicking Insert MediaCMS Media
Users are added individually to the course category group in MediaCMS when accessing embedded media in Moodle element or My Media in Moodle, or when content is shared with course members.
Automatic continuous synchronisation of courses and users has not yet been established, e.g. via NRPS. A future version may include this. Instead, ad hoc course membership and role synchronisation is happening for a specific user, when the user clicks on My Media.
As such, if the user is removed from a course, or gets a different role, this is synced to course category group in MediaCMS, when user clicks on My Media in Moodle.
## 3. For Students
Students: Moodle workflows covered with the MediaCMS integration
Almost all the workflows supported for lecturers are also supported for students. This goes for the following workflows described above for lecturers:
1. Upload Media
2. Edit Media
3. Share media with other users
4. Embed media in Moodle course activities / resources
5. Link to, or embed media outside Moodle
6. Bookmark and collect media in Playlists
7. Bulk Actions
**Note 4:** Students can only embed media in Moodle activities / resources, where they have the permissions to do so via permissions set in Moodle.
**Note 7:** With regard to Bulk Actions, students do not have access to Course Cleanup.
+4 -2
View File
@@ -120,9 +120,11 @@ class MediaList(APIView):
operation_description='Delete media for MediaCMS managers and reviewers', operation_description='Delete media for MediaCMS managers and reviewers',
) )
def delete(self, request, format=None): def delete(self, request, format=None):
if not is_mediacms_manager(request.user):
return Response({"detail": "bad permissions"}, status=status.HTTP_403_FORBIDDEN)
tokens = request.GET.get("tokens") tokens = request.GET.get("tokens")
if tokens: if tokens:
tokens = tokens.split(",") tokens = [t for t in tokens.split(",") if t][:50]
Media.objects.filter(friendly_token__in=tokens).delete() Media.objects.filter(friendly_token__in=tokens).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@@ -177,7 +179,7 @@ class CommentList(APIView):
def delete(self, request, format=None): def delete(self, request, format=None):
comment_ids = request.GET.get("comment_ids") comment_ids = request.GET.get("comment_ids")
if comment_ids: if comment_ids:
comments = comment_ids.split(",") comments = [c for c in comment_ids.split(",") if c][:50]
Comment.objects.filter(uid__in=comments).delete() Comment.objects.filter(uid__in=comments).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
+13 -9
View File
@@ -453,17 +453,21 @@ def kill_ffmpeg_process(filepath):
filepath: Path to the file being processed by ffmpeg filepath: Path to the file being processed by ffmpeg
Returns: Returns:
subprocess.CompletedProcess: Result of the kill command bool: True if the lookup ran, False if input was unusable
""" """
if not filepath: if not filepath or not isinstance(filepath, str):
return False return False
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) ps = subprocess.run(["ps", "aux"], stdout=subprocess.PIPE, check=False)
pid = result.stdout.decode("utf-8").strip() except OSError:
if pid: return False
cmd = "kill -9 %s" % pid for line in ps.stdout.decode("utf-8", "replace").splitlines():
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) if "ffmpeg" not in line or filepath not in line or "grep" in line:
return result continue
parts = line.split()
if len(parts) > 1 and parts[1].isdigit():
subprocess.run(["kill", "-9", parts[1]], check=False)
return True
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"): def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
-4
View File
@@ -6,7 +6,6 @@ from django.core.files import File
from django.db import models from django.db import models
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse
from .. import helpers from .. import helpers
from .utils import ( from .utils import (
@@ -136,9 +135,6 @@ class Encoding(models.Model):
def __str__(self): def __str__(self):
return f"{self.profile.name}-{self.media.title}" return f"{self.profile.name}-{self.media.title}"
def get_absolute_url(self):
return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
@receiver(post_save, sender=Encoding) @receiver(post_save, sender=Encoding)
def encoding_file_save(sender, instance, created, **kwargs): def encoding_file_save(sender, instance, created, **kwargs):
+6 -6
View File
@@ -10,7 +10,7 @@ from django.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVectorField
from django.core.files import File from django.core.files import File
from django.db import models from django.db import models, transaction
from django.db.models import Func, Value from django.db.models import Func, Value
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
@@ -536,7 +536,9 @@ class Media(models.Model):
from .. import tasks from .. import tasks
tasks.produce_sprite_from_video.delay(self.friendly_token) # Defer until the surrounding transaction commits so the worker can
# actually find the Media row. Runs immediately if not in a tx.
transaction.on_commit(lambda token=self.friendly_token: tasks.produce_sprite_from_video.delay(token))
return True return True
def encode(self, profiles=[], force=True, chunkize=True): def encode(self, profiles=[], force=True, chunkize=True):
@@ -559,9 +561,8 @@ class Media(models.Model):
profiles.remove(profile) profiles.remove(profile)
encoding = Encoding(media=self, profile=profile) encoding = Encoding(media=self, profile=profile)
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
tasks.encode_media.apply_async( tasks.encode_media.apply_async(
args=[self.friendly_token, profile.id, encoding.id, enc_url], args=[self.friendly_token, profile.id, encoding.id],
kwargs={"force": force}, kwargs={"force": force},
priority=0, priority=0,
) )
@@ -575,13 +576,12 @@ class Media(models.Model):
continue continue
encoding = Encoding(media=self, profile=profile) encoding = Encoding(media=self, profile=profile)
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE: if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
priority = 9 priority = 9
else: else:
priority = 0 priority = 0
tasks.encode_media.apply_async( tasks.encode_media.apply_async(
args=[self.friendly_token, profile.id, encoding.id, enc_url], args=[self.friendly_token, profile.id, encoding.id],
kwargs={"force": force}, kwargs={"force": force},
priority=priority, priority=priority,
) )
+2 -5
View File
@@ -171,8 +171,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
continue continue
encoding = Encoding(media=media, profile=profile) encoding = Encoding(media=media, profile=profile)
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url() encode_media.delay(friendly_token, profile.id, encoding.id, force=force)
encode_media.delay(friendly_token, profile.id, encoding.id, enc_url, force=force)
return False return False
chunks = [os.path.join(cwd, ch) for ch in chunks] chunks = [os.path.join(cwd, ch) for ch in chunks]
@@ -202,13 +201,12 @@ def chunkize_media(self, friendly_token, profiles, force=True):
) )
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE: if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
priority = 0 priority = 0
else: else:
priority = 9 priority = 9
encode_media.apply_async( encode_media.apply_async(
args=[friendly_token, profile.id, encoding.id, enc_url], args=[friendly_token, profile.id, encoding.id],
kwargs={"force": force, "chunk": True, "chunk_file_path": chunk}, kwargs={"force": force, "chunk": True, "chunk_file_path": chunk},
priority=priority, priority=priority,
) )
@@ -246,7 +244,6 @@ def encode_media(
friendly_token, friendly_token,
profile_id, profile_id,
encoding_id, encoding_id,
encoding_url,
force=True, force=True,
chunk=False, chunk=False,
chunk_file_path="", chunk_file_path="",
+1 -5
View File
@@ -61,11 +61,6 @@ urlpatterns = [
views.MediaDetail.as_view(), views.MediaDetail.as_view(),
name="api_get_media", name="api_get_media",
), ),
re_path(
r"^api/v1/media/encoding/(?P<encoding_id>[\w]*)$",
views.EncodingDetail.as_view(),
name="api_get_encoding",
),
re_path(r"^api/v1/search$", views.MediaSearch.as_view()), re_path(r"^api/v1/search$", views.MediaSearch.as_view()),
re_path( re_path(
rf"^api/v1/media/{friendly_token}/share$", rf"^api/v1/media/{friendly_token}/share$",
@@ -111,6 +106,7 @@ urlpatterns = [
re_path(r"^api/v1/tasks$", views.TasksList.as_view()), re_path(r"^api/v1/tasks$", views.TasksList.as_view()),
re_path(r"^api/v1/tasks/$", views.TasksList.as_view()), re_path(r"^api/v1/tasks/$", views.TasksList.as_view()),
re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()), re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()),
re_path(r"^api/v1/media-auth$", views.media_auth, name="media_auth"),
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"), re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
re_path(r"^manage/media$", views.manage_media, name="manage_media"), re_path(r"^manage/media$", views.manage_media, name="manage_media"),
re_path(r"^manage/users$", views.manage_users, name="manage_users"), re_path(r"^manage/users$", views.manage_users, name="manage_users"),
+2 -1
View File
@@ -3,13 +3,14 @@
from .auth import custom_login_view, saml_metadata # noqa: F401 from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401 from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401 from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401 from .encoding import EncodeProfileList # noqa: F401
from .media import MediaActions # noqa: F401 from .media import MediaActions # noqa: F401
from .media import MediaBulkUserActions # noqa: F401 from .media import MediaBulkUserActions # noqa: F401
from .media import MediaDetail # noqa: F401 from .media import MediaDetail # noqa: F401
from .media import MediaList # noqa: F401 from .media import MediaList # noqa: F401
from .media import MediaSearch # noqa: F401 from .media import MediaSearch # noqa: F401
from .media import media_share # noqa: F401 from .media import media_share # noqa: F401
from .media_auth import media_auth # noqa: F401
from .pages import about # noqa: F401 from .pages import about # noqa: F401
from .pages import add_subtitle # noqa: F401 from .pages import add_subtitle # noqa: F401
from .pages import approval_required # noqa: F401 from .pages import approval_required # noqa: F401
+1 -158
View File
@@ -1,168 +1,11 @@
from django.conf import settings
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from ..helpers import produce_ffmpeg_commands from ..models import EncodeProfile
from ..models import EncodeProfile, Encoding
from ..serializers import EncodeProfileSerializer from ..serializers import EncodeProfileSerializer
class EncodingDetail(APIView):
"""Experimental. This View is used by remote workers
Needs heavy testing and documentation.
"""
permission_classes = (permissions.IsAdminUser,)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(auto_schema=None)
def post(self, request, encoding_id):
ret = {}
force = request.data.get("force", False)
task_id = request.data.get("task_id", False)
action = request.data.get("action", "")
chunk = request.data.get("chunk", False)
chunk_file_path = request.data.get("chunk_file_path", "")
encoding_status = request.data.get("status", "")
progress = request.data.get("progress", "")
commands = request.data.get("commands", "")
logs = request.data.get("logs", "")
retries = request.data.get("retries", "")
worker = request.data.get("worker", "")
temp_file = request.data.get("temp_file", "")
total_run_time = request.data.get("total_run_time", "")
if action == "start":
try:
encoding = Encoding.objects.get(id=encoding_id)
media = encoding.media
profile = encoding.profile
except BaseException:
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
# TODO: break chunk True/False logic here
if (
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).count()
> 1 # noqa
and force is False # noqa
):
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
else:
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).exclude(id=encoding.id).delete()
encoding.status = "running"
if task_id:
encoding.task_id = task_id
encoding.save()
if chunk:
original_media_path = chunk_file_path
original_media_md5sum = encoding.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url
else:
original_media_path = media.media_file.path
original_media_md5sum = media.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url
ret["original_media_url"] = original_media_url
ret["original_media_path"] = original_media_path
ret["original_media_md5sum"] = original_media_md5sum
# generating the commands here, and will replace these with temporary
# files created on the remote server
tf = "TEMP_FILE_REPLACE"
tfpass = "TEMP_FPASS_FILE_REPLACE"
ffmpeg_commands = produce_ffmpeg_commands(
original_media_path,
media.media_info,
resolution=profile.resolution,
codec=profile.codec,
output_filename=tf,
pass_file=tfpass,
chunk=chunk,
)
if not ffmpeg_commands:
encoding.delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
ret["duration"] = media.duration
ret["ffmpeg_commands"] = ffmpeg_commands
ret["profile_extension"] = profile.extension
return Response(ret, status=status.HTTP_201_CREATED)
elif action == "update_fields":
try:
encoding = Encoding.objects.get(id=encoding_id)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
to_update = ["size", "update_date"]
if encoding_status:
encoding.status = encoding_status
to_update.append("status")
if progress:
encoding.progress = progress
to_update.append("progress")
if logs:
encoding.logs = logs
to_update.append("logs")
if commands:
encoding.commands = commands
to_update.append("commands")
if task_id:
encoding.task_id = task_id
to_update.append("task_id")
if total_run_time:
encoding.total_run_time = total_run_time
to_update.append("total_run_time")
if worker:
encoding.worker = worker
to_update.append("worker")
if temp_file:
encoding.temp_file = temp_file
to_update.append("temp_file")
if retries:
encoding.retries = retries
to_update.append("retries")
try:
encoding.save(update_fields=to_update)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"status": "success"}, status=status.HTTP_201_CREATED)
@swagger_auto_schema(auto_schema=None)
def put(self, request, encoding_id, format=None):
encoding_file = request.data["file"]
encoding = Encoding.objects.filter(id=encoding_id).first()
if not encoding:
return Response(
{"detail": "encoding does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
encoding.media_file = encoding_file
encoding.save()
return Response({"detail": "ok"}, status=status.HTTP_201_CREATED)
class EncodeProfileList(APIView): class EncodeProfileList(APIView):
"""List encode profiles""" """List encode profiles"""
+7 -3
View File
@@ -660,7 +660,9 @@ class MediaBulkUserActions(APIView):
# Prioritize category_uids # Prioritize category_uids
if category_uids: if category_uids:
categories = Category.objects.filter(uid__in=category_uids) requested = Category.objects.filter(uid__in=category_uids)
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
categories = Category.objects.filter(id__in=allowed_ids)
elif lti_context_id: elif lti_context_id:
# Filter categories by lti_context_id and ensure they ARE RBAC categories # Filter categories by lti_context_id and ensure they ARE RBAC categories
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True) potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
@@ -691,9 +693,11 @@ class MediaBulkUserActions(APIView):
if not category_uids: if not category_uids:
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
categories = Category.objects.filter(uid__in=category_uids) requested = Category.objects.filter(uid__in=category_uids)
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
categories = Category.objects.filter(id__in=allowed_ids)
if not categories: if not categories:
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0 removed_count = 0
for category in categories: for category in categories:
+125
View File
@@ -0,0 +1,125 @@
import re
from urllib.parse import unquote
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from ..methods import is_mediacms_editor
from ..models import Media
UID_RE = re.compile(r"[0-9a-f]{32}")
THUMBNAILS_PREFIX = "original/thumbnails/"
def _ttl():
return getattr(settings, "X_ACCEL_AUTH_CACHE_SECONDS", 300)
def _extract_uid(uri):
if not uri:
return None
match = UID_RE.search(uri)
return match.group(0) if match else None
def _relpath_from_uri(uri):
path = unquote(uri.split("?", 1)[0])
media_url = settings.MEDIA_URL
if path.startswith(media_url):
return path[len(media_url) :]
return None
def _lookup_uid_by_path(relpath):
path_key = f"xaccel:path:{relpath}"
cached = cache.get(path_key)
if cached is not None:
return cached or None
parts = relpath.split("/", 4)
if len(parts) < 5 or parts[2] != "user":
cache.set(path_key, "", _ttl())
return None
username = parts[3]
row = Media.objects.filter(user__username=username).filter(Q(uploaded_thumbnail=relpath) | Q(uploaded_poster=relpath)).values("uid").first()
uid_hex = row["uid"].hex if row else ""
cache.set(path_key, uid_hex, _ttl())
return uid_hex or None
def _lookup_state(uid):
"""Return (state, owner_id) for a uid, or (None, None) if missing.
Cached on uid alone since state/ownership do not depend on the requester.
Uses .values() rather than .only() because Media.__init__ touches deferred
file fields, which would otherwise recurse via refresh_from_db.
"""
state_key = f"xaccel:state:{uid}"
cached = cache.get(state_key)
if cached is not None:
return cached
row = Media.objects.filter(uid=uid).values("state", "user_id").first()
value = (row["state"], row["user_id"]) if row else (None, None)
cache.set(state_key, value, _ttl())
return value
def _decide(uid, user):
state, owner_id = _lookup_state(uid)
if state is None:
return False
if state in ("public", "unlisted"):
return True
# private
if not user.is_authenticated:
return False
if owner_id == user.id:
return True
if is_mediacms_editor(user):
return True
# RBAC / MediaPermission path needs a full Media instance.
try:
media = Media.objects.get(uid=uid)
except Media.DoesNotExist:
return False
return user.has_member_access_to_media(media)
@csrf_exempt
@require_GET
def media_auth(request):
"""Authorize a protected media request from nginx auth_request.
nginx passes the original request URI in the X-Original-URI header. The
Media.uid (32 hex chars, no dashes) is embedded somewhere in that URI for
every protected path. No uid => deny. Unknown uid => deny.
"""
if not getattr(settings, "USE_X_ACCEL_REDIRECT", True):
return HttpResponse(status=204)
uri = request.META.get("HTTP_X_ORIGINAL_URI", "")
uid = _extract_uid(uri)
if not uid:
# User-uploaded thumbnails/posters don't have the uid in the filename.
# Fall back to a per-path lookup, scoped to /original/thumbnails/.
relpath = _relpath_from_uri(uri)
if relpath and relpath.startswith(THUMBNAILS_PREFIX):
uid = _lookup_uid_by_path(relpath)
if not uid:
return HttpResponse(status=403)
user = request.user
cache_key = f"xaccel:auth:{uid}:{user.id if user.is_authenticated else 'anon'}"
cached = cache.get(cache_key)
if cached is None:
allowed = _decide(uid, user)
cache.set(cache_key, allowed, _ttl())
else:
allowed = cached
return HttpResponse(status=204 if allowed else 403)
@@ -1,5 +1,5 @@
// check templates/config/installation/translations.html for more // check templates/config/installation/translations.html for more
export function translateString(str) { export function translateString(str) {
return window.TRANSLATION?.[str] || str; return window.TRANSLATION?.[str] ?? str;
} }
-9
View File
@@ -1,16 +1,7 @@
from django.apps import AppConfig from django.apps import AppConfig
from .keys import ensure_keys_exist
class LtiConfig(AppConfig): class LtiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'lti' name = 'lti'
verbose_name = 'LTI 1.3 Integration' verbose_name = 'LTI 1.3 Integration'
def ready(self):
"""Initialize LTI app - ensure keys exist"""
try:
ensure_keys_exist()
except Exception:
pass
-7
View File
@@ -21,10 +21,3 @@ def get_jwks():
""" """
public_key = load_public_key() public_key = load_public_key()
return {'keys': [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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mediacms", "name": "mediacms",
"version": "8.0.7", "version": "8.2.1",
"devDependencies": { "devDependencies": {
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
+12 -5
View File
@@ -1,5 +1,6 @@
import base64 import base64
import logging import logging
import re
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
@@ -22,7 +23,10 @@ class SAMLAccountAdapter(DefaultSocialAccountAdapter):
def populate_user(self, request, sociallogin, data): def populate_user(self, request, sociallogin, data):
user = sociallogin.user user = sociallogin.user
user.username = sociallogin.account.uid raw_uid = sociallogin.account.uid or ""
# Match the user URL pattern in users/urls.py: only [\w@._-] is reverse-able.
sanitized = re.sub(r"[^\w.@-]", "_", raw_uid, flags=re.ASCII)
user.username = sanitized[:150] if sanitized else raw_uid
for item in ["name", "first_name", "last_name"]: for item in ["name", "first_name", "last_name"]:
if data.get(item): if data.get(item):
setattr(user, item, data[item]) setattr(user, item, data[item])
@@ -69,7 +73,7 @@ def perform_user_actions(user, social_account, common_fields=None):
if social_app: if social_app:
saml_configuration = social_app.saml_configurations.first() saml_configuration = social_app.saml_configurations.first()
add_user_logo(user, extra_data) add_user_logo(user, extra_data, saml_configuration)
handle_role_mapping(user, extra_data, social_app, saml_configuration) handle_role_mapping(user, extra_data, social_app, saml_configuration)
if saml_configuration and saml_configuration.save_saml_response_logs: if saml_configuration and saml_configuration.save_saml_response_logs:
handle_saml_logs_save(user, extra_data, social_app) handle_saml_logs_save(user, extra_data, social_app)
@@ -77,10 +81,13 @@ def perform_user_actions(user, social_account, common_fields=None):
return user return user
def add_user_logo(user, extra_data): def add_user_logo(user, extra_data, saml_configuration=None):
# use the attribute name configured in the SAML Configuration, falling
# back to "jpegPhoto" when it is left empty
logo_key = (saml_configuration.user_logo if saml_configuration and saml_configuration.user_logo else None) or "jpegPhoto"
try: try:
if extra_data.get("jpegPhoto") and user.logo.name in ["userlogos/user.jpg", "", None]: if extra_data.get(logo_key) and user.logo.name in ["userlogos/user.jpg", "", None]:
base64_string = extra_data.get("jpegPhoto")[0] base64_string = extra_data.get(logo_key)[0]
image_data = base64.b64decode(base64_string) image_data = base64.b64decode(base64_string)
image_content = ContentFile(image_data) image_content = ContentFile(image_data)
user.logo.save('user.jpg', image_content, save=True) user.logo.save('user.jpg', image_content, save=True)
+1 -1
View File
@@ -51,7 +51,7 @@ class SAMLConfigurationAdmin(admin.ModelAdmin):
search_fields = ['social_app__name', 'idp_id', 'sp_metadata_url'] search_fields = ['social_app__name', 'idp_id', 'sp_metadata_url']
fieldsets = [ fieldsets = [
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert']}), ('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert', 'sp_cert', 'sp_private_key']}),
('URLs', {'fields': ['sso_url', 'slo_url', 'sp_metadata_url']}), ('URLs', {'fields': ['sso_url', 'slo_url', 'sp_metadata_url']}),
('Group Management', {'fields': ['remove_from_groups', 'save_saml_response_logs']}), ('Group Management', {'fields': ['remove_from_groups', 'save_saml_response_logs']}),
('Attribute Mapping', {'fields': ['uid', 'name', 'email', 'groups', 'first_name', 'last_name', 'user_logo', 'role']}), ('Attribute Mapping', {'fields': ['uid', 'name', 'email', 'groups', 'first_name', 'last_name', 'user_logo', 'role']}),
+15 -1
View File
@@ -18,14 +18,28 @@ class CustomSAMLProvider(SAMLProvider):
provider_config = self.app.settings provider_config = self.app.settings
raw_attributes = data.get_attributes() raw_attributes = data.get_attributes()
# get_attributes() keys attributes by their full Name. Some IdPs send
# certain attributes only under their FriendlyName, so fall back to the
# FriendlyName-keyed attributes when a Name lookup misses. The Name
# lookup is always preferred, so attributes that already resolve are
# unaffected.
try:
friendly_attributes = data.get_friendlyname_attributes()
except AttributeError:
friendly_attributes = {}
attributes = {} attributes = {}
attribute_mapping = provider_config.get("attribute_mapping", self.default_attribute_mapping) attribute_mapping = provider_config.get("attribute_mapping", self.default_attribute_mapping)
# map configured provider attributes # map configured provider attributes
for key, provider_keys in attribute_mapping.items(): for key, provider_keys in attribute_mapping.items():
# skip mappings left empty/None in the SAML Configuration
if not provider_keys:
continue
if isinstance(provider_keys, str): if isinstance(provider_keys, str):
provider_keys = [provider_keys] provider_keys = [provider_keys]
for provider_key in provider_keys: for provider_key in provider_keys:
attribute_list = raw_attributes.get(provider_key, None) attribute_list = raw_attributes.get(provider_key)
if attribute_list is None:
attribute_list = friendly_attributes.get(provider_key)
# if more than one keys, get them all comma separated # if more than one keys, get them all comma separated
if attribute_list is not None and len(attribute_list) > 1: if attribute_list is not None and len(attribute_list) > 1:
attributes[key] = ",".join(attribute_list) attributes[key] = ",".join(attribute_list)
+5 -9
View File
@@ -53,16 +53,12 @@ def build_sp_config(request, provider_config, org):
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT, "binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
}, },
} }
if _sp_config.get("x509cert"):
sp_config["x509cert"] = _sp_config["x509cert"]
if _sp_config.get("private_key"):
sp_config["privateKey"] = _sp_config["private_key"]
avd = provider_config.get("advanced", {}) avd = provider_config.get("advanced", {})
if avd.get("x509cert") is not None:
sp_config["x509cert"] = avd["x509cert"]
if avd.get("x509cert_new"):
sp_config["x509certNew"] = avd["x509cert_new"]
if avd.get("private_key") is not None:
sp_config["privateKey"] = avd["private_key"]
if avd.get("name_id_format") is not None: if avd.get("name_id_format") is not None:
sp_config["NameIDFormat"] = avd["name_id_format"] sp_config["NameIDFormat"] = avd["name_id_format"]
+3 -1
View File
@@ -154,7 +154,9 @@ sls = SLSView.as_view()
class MetadataView(SAMLViewMixin, View): class MetadataView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug): def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug) provider = self.get_provider(organization_slug)
config = build_saml_config(self.request, provider.app.settings, organization_slug) custom_configuration = provider.app.saml_configurations.first()
provider_config = custom_configuration.saml_provider_settings if custom_configuration else provider.app.settings
config = build_saml_config(self.request, provider_config, organization_slug)
saml_settings = OneLogin_Saml2_Settings(settings=config, sp_validation_only=True) saml_settings = OneLogin_Saml2_Settings(settings=config, sp_validation_only=True)
metadata = saml_settings.get_sp_metadata() metadata = saml_settings.get_sp_metadata()
errors = saml_settings.validate_metadata(metadata) errors = saml_settings.validate_metadata(metadata)
@@ -0,0 +1,23 @@
# Generated by Django 5.2.6 on 2026-05-31 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saml_auth', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='samlconfiguration',
name='sp_cert',
field=models.TextField(blank=True, help_text='SP x509cert (PEM). Optional; required if SP private key is set.', null=True),
),
migrations.AddField(
model_name='samlconfiguration',
name='sp_private_key',
field=models.TextField(blank=True, help_text='SP private key (PEM). Optional; required if SP certificate is set.', null=True),
),
]
+11
View File
@@ -14,6 +14,8 @@ class SAMLConfiguration(models.Model):
# Certificates # Certificates
idp_cert = models.TextField(help_text='x509cert') idp_cert = models.TextField(help_text='x509cert')
sp_cert = models.TextField(blank=True, null=True, help_text='SP x509cert (PEM). Optional; required if SP private key is set.')
sp_private_key = models.TextField(blank=True, null=True, help_text='SP private key (PEM). Optional; required if SP certificate is set.')
# Attribute Mapping Fields # Attribute Mapping Fields
uid = models.CharField(max_length=100, help_text='eg eduPersonPrincipalName') uid = models.CharField(max_length=100, help_text='eg eduPersonPrincipalName')
@@ -49,6 +51,11 @@ class SAMLConfiguration(models.Model):
if existing_conf.exists(): if existing_conf.exists():
raise ValidationError({'social_app': 'Cannot create configuration for the same social app because one configuration already exists.'}) raise ValidationError({'social_app': 'Cannot create configuration for the same social app because one configuration already exists.'})
if self.sp_cert and not self.sp_private_key:
raise ValidationError({'sp_private_key': 'Required when SP certificate is provided.'})
if self.sp_private_key and not self.sp_cert:
raise ValidationError({'sp_cert': 'Required when SP private key is provided.'})
super().clean() super().clean()
@property @property
@@ -56,6 +63,10 @@ class SAMLConfiguration(models.Model):
# provide settings in a way for Social App SAML provider # provide settings in a way for Social App SAML provider
provider_settings = {} provider_settings = {}
provider_settings["sp"] = {"entity_id": self.sp_metadata_url} provider_settings["sp"] = {"entity_id": self.sp_metadata_url}
if self.sp_cert:
provider_settings["sp"]["x509cert"] = self.sp_cert
if self.sp_private_key:
provider_settings["sp"]["private_key"] = self.sp_private_key
provider_settings["idp"] = {"slo_url": self.slo_url, "sso_url": self.sso_url, "x509cert": self.idp_cert, "entity_id": self.idp_id} provider_settings["idp"] = {"slo_url": self.slo_url, "sso_url": self.sso_url, "x509cert": self.idp_cert, "entity_id": self.idp_id}
provider_settings["attribute_mapping"] = { provider_settings["attribute_mapping"] = {
+1 -1
View File
@@ -2,4 +2,4 @@
exclude = .git,*migrations* exclude = .git,*migrations*
max-line-length = 119 max-line-length = 119
#ignore=F401,F403,E501,W503 #ignore=F401,F403,E501,W503
ignore=E501 ignore=E501,E203
-1
View File
@@ -34,7 +34,6 @@ class TestImports(TestCase):
from files.views import CommentDetail # noqa: F401 from files.views import CommentDetail # noqa: F401
from files.views import CommentList # noqa: F401 from files.views import CommentList # noqa: F401
from files.views import EncodeProfileList # noqa: F401 from files.views import EncodeProfileList # noqa: F401
from files.views import EncodingDetail # noqa: F401
from files.views import MediaActions # noqa: F401 from files.views import MediaActions # noqa: F401
from files.views import MediaBulkUserActions # noqa: F401 from files.views import MediaBulkUserActions # noqa: F401
from files.views import MediaDetail # noqa: F401 from files.views import MediaDetail # noqa: F401
+2 -2
View File
@@ -7,8 +7,8 @@ from django.utils.translation import gettext_lazy as _
@deconstructible @deconstructible
class ASCIIUsernameValidator(validators.RegexValidator): class ASCIIUsernameValidator(validators.RegexValidator):
regex = r"^[\w.@]+$" regex = r"^[\w.@-]+$"
message = _("Enter a valid username. This value may contain only " "English letters and numbers") message = _("Enter a valid username. This value may contain only English letters, numbers, and '_', '.', '@', '-'.")
flags = re.ASCII flags = re.ASCII
+14
View File
@@ -1,5 +1,8 @@
from allauth.account.adapter import get_adapter
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
@@ -275,6 +278,11 @@ class UserList(APIView):
if not all([username, password, email, name]): if not all([username, password, email, name]):
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
try:
username = get_adapter().clean_username(username, shallow=True)
except DjangoValidationError as e:
return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists(): if User.objects.filter(username=username).exists():
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
@@ -369,9 +377,15 @@ class UserDetail(APIView):
if action == "change_password": if action == "change_password":
# Permission to edit user is already checked by self.get_user -> self.check_object_permissions # Permission to edit user is already checked by self.get_user -> self.check_object_permissions
if user.is_superuser and not request.user.is_superuser:
raise PermissionDenied("You do not have permission to change a superuser's password.")
password = request.data.get("password") password = request.data.get("password")
if not password: if not password:
return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
try:
validate_password(password, user=user)
except DjangoValidationError as exc:
return Response({"detail": list(exc.messages)}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(password) user.set_password(password)
user.save() user.save()