mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-06-11 10:57:35 -04:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a59eb6977 | |||
| 7ee720fee4 | |||
| 5e83b9f43a | |||
| 9da6a85ad8 | |||
| 51b1097509 | |||
| 95644dc961 | |||
| a3fe375a83 | |||
| 777b06bbeb | |||
| e89c4a3c85 | |||
| 7a02d25d0b | |||
| c7a673bbbf | |||
| b0c0d9a83f | |||
| ae63a5af64 | |||
| 98d5d6af8b | |||
| 9302559d4b | |||
| 279cccb980 | |||
| 25e91e9d5e | |||
| d6a11514e5 | |||
| c7a1d60d73 | |||
| 6ee5bef6ce | |||
| 2e01000559 | |||
| 4f11addcfd | |||
| b11f2f561c | |||
| b6da9c4662 | |||
| 10c0782fe0 | |||
| 318dad0e5d | |||
| 09ead87884 | |||
| 559977f9bc |
@@ -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**
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
|||||||
static/chapters_editor/videos/sample-video.mp3
|
static/chapters_editor/videos/sample-video.mp3
|
||||||
static/video_editor/videos/sample-video.mp3
|
static/video_editor/videos/sample-video.mp3
|
||||||
templates/todo-MS4.md
|
templates/todo-MS4.md
|
||||||
|
.secret_key
|
||||||
|
.secret_key.lock
|
||||||
|
|||||||
@@ -1,5 +1,78 @@
|
|||||||
# 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)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bring related items back ([#1515](https://github.com/mediacms-io/mediacms/issues/1515)) ([6ee5bef](https://github.com/mediacms-io/mediacms/commit/6ee5bef6ce31cf849941f65d0817e53b8f03362f))
|
||||||
|
|
||||||
|
## [8.0.6](https://github.com/mediacms-io/mediacms/compare/v8.0.5...v8.0.6) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* better place secret key settings ([4f11add](https://github.com/mediacms-io/mediacms/commit/4f11addcfd6657e7e63eed0570b1d4d9bca75698))
|
||||||
|
|
||||||
|
## [8.0.5](https://github.com/mediacms-io/mediacms/compare/v8.0.4...v8.0.5) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add secret key to gitignore ([b6da9c4](https://github.com/mediacms-io/mediacms/commit/b6da9c4662b3fba234b8dc69700ffa44fced7482))
|
||||||
|
|
||||||
|
## [8.0.4](https://github.com/mediacms-io/mediacms/compare/v8.0.3...v8.0.4) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* docker compose settings, provide key ([318dad0](https://github.com/mediacms-io/mediacms/commit/318dad0e5d2512d68068c019eb87f942f83318e9))
|
||||||
|
|
||||||
|
## [8.0.3](https://github.com/mediacms-io/mediacms/compare/v8.0.2...v8.0.3) (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* secret key ([559977f](https://github.com/mediacms-io/mediacms/commit/559977f9bc74412739784926862b94a558e6fd84))
|
||||||
|
|
||||||
## [8.0.2](https://github.com/mediacms-io/mediacms/compare/v8.0.1...v8.0.2) (2026-05-11)
|
## [8.0.2](https://github.com/mediacms-io/mediacms/compare/v8.0.1...v8.0.2) (2026-05-11)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+41
-18
@@ -171,8 +171,19 @@ REST_FRAMEWORK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = "2dii4cog7k=5n37$fz)8dst)kg(s3&10)^qa*gv(kk+nv-z&cu"
|
# In docker, deploy/docker/entrypoint.sh ensures the SECRET_KEY env var is
|
||||||
# TODO: this needs to be changed!
|
# set (generating .secret_key once on first start if needed). Outside docker,
|
||||||
|
# either set SECRET_KEY in the environment or create a .secret_key file at the
|
||||||
|
# project root, e.g.:
|
||||||
|
# python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' > .secret_key
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||||
|
if not SECRET_KEY:
|
||||||
|
_secret_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.secret_key')
|
||||||
|
if os.path.exists(_secret_path):
|
||||||
|
with open(_secret_path) as _f:
|
||||||
|
SECRET_KEY = _f.read().strip()
|
||||||
|
if not SECRET_KEY:
|
||||||
|
raise RuntimeError("SECRET_KEY is not set. Set the SECRET_KEY env var or create a .secret_key file at the project root.")
|
||||||
|
|
||||||
TEMP_DIRECTORY = "/tmp" # Don't use a temp directory inside BASE_DIR!!!
|
TEMP_DIRECTORY = "/tmp" # Don't use a temp directory inside BASE_DIR!!!
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -191,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"
|
||||||
@@ -255,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 = "/"
|
||||||
@@ -402,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
@@ -1 +1 @@
|
|||||||
VERSION = "8.0.1"
|
VERSION = "8.2.1"
|
||||||
|
|||||||
@@ -35,4 +35,29 @@ find /home/mediacms.io/mediacms ! \( -path "*.git*" -o -name "package-lock.json"
|
|||||||
|
|
||||||
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
|
chmod +x /home/mediacms.io/mediacms/deploy/docker/start.sh /home/mediacms.io/mediacms/deploy/docker/prestart.sh
|
||||||
|
|
||||||
|
# Generate or read SECRET_KEY once, shared across all containers via the
|
||||||
|
# host-mounted project volume. Atomic create-or-read so parallel container
|
||||||
|
# starts (web + celery_worker + celery_beat + migrations) can't race.
|
||||||
|
# Uses `mkdir` as the lock primitive (POSIX-atomic, no dependency on flock).
|
||||||
|
SECRET_KEY_FILE="${SECRET_KEY_FILE:-/home/mediacms.io/mediacms/.secret_key}"
|
||||||
|
SECRET_KEY_LOCK="${SECRET_KEY_FILE}.lock"
|
||||||
|
|
||||||
|
if [ -z "${SECRET_KEY:-}" ]; then
|
||||||
|
if [ ! -s "$SECRET_KEY_FILE" ]; then
|
||||||
|
# Spin-acquire the lock. mkdir is atomic; first caller wins, others retry.
|
||||||
|
while ! mkdir "$SECRET_KEY_LOCK" 2>/dev/null; do
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
# Re-check inside the lock: another container may have just written it.
|
||||||
|
if [ ! -s "$SECRET_KEY_FILE" ]; then
|
||||||
|
python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' > "$SECRET_KEY_FILE"
|
||||||
|
chown www-data:www-data "$SECRET_KEY_FILE"
|
||||||
|
chmod 600 "$SECRET_KEY_FILE"
|
||||||
|
echo "entrypoint.sh: generated new SECRET_KEY at $SECRET_KEY_FILE"
|
||||||
|
fi
|
||||||
|
rmdir "$SECRET_KEY_LOCK"
|
||||||
|
fi
|
||||||
|
export SECRET_KEY="$(cat "$SECRET_KEY_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import os
|
|||||||
|
|
||||||
FRONTEND_HOST = os.getenv('FRONTEND_HOST', 'http://localhost')
|
FRONTEND_HOST = os.getenv('FRONTEND_HOST', 'http://localhost')
|
||||||
PORTAL_NAME = os.getenv('PORTAL_NAME', 'MediaCMS')
|
PORTAL_NAME = os.getenv('PORTAL_NAME', 'MediaCMS')
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY', 'ma!s3^b-cw!f#7s6s0m3*jx77a@riw(7701**(r=ww%w!2+yk2')
|
|
||||||
REDIS_LOCATION = os.getenv('REDIS_LOCATION', 'redis://redis:6379/1')
|
REDIS_LOCATION = os.getenv('REDIS_LOCATION', 'redis://redis:6379/1')
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
@@ -13,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,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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
@@ -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)"):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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"""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,4 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { MediaPageStore } from '../../utils/stores/';
|
||||||
|
import { AutoPlay } from './AutoPlay';
|
||||||
|
import { RelatedMedia } from './RelatedMedia';
|
||||||
import PlaylistView from './PlaylistView';
|
import PlaylistView from './PlaylistView';
|
||||||
|
|
||||||
export default class ViewerSidebar extends React.PureComponent {
|
export default class ViewerSidebar extends React.PureComponent {
|
||||||
@@ -9,6 +12,7 @@ export default class ViewerSidebar extends React.PureComponent {
|
|||||||
playlistData: props.playlistData,
|
playlistData: props.playlistData,
|
||||||
isPlaylistPage: !!props.playlistData,
|
isPlaylistPage: !!props.playlistData,
|
||||||
activeItem: 0,
|
activeItem: 0,
|
||||||
|
mediaType: MediaPageStore.get('media-type'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.playlistData) {
|
if (props.playlistData) {
|
||||||
@@ -23,6 +27,21 @@ export default class ViewerSidebar extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onMediaLoad = this.onMediaLoad.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
MediaPageStore.on('loaded_media_data', this.onMediaLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
MediaPageStore.removeListener('loaded_media_data', this.onMediaLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMediaLoad() {
|
||||||
|
this.setState({
|
||||||
|
mediaType: MediaPageStore.get('media-type'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -30,7 +49,10 @@ export default class ViewerSidebar extends React.PureComponent {
|
|||||||
<div className="viewer-sidebar">
|
<div className="viewer-sidebar">
|
||||||
{this.state.isPlaylistPage ? (
|
{this.state.isPlaylistPage ? (
|
||||||
<PlaylistView activeItem={this.state.activeItem} playlistData={this.props.playlistData} />
|
<PlaylistView activeItem={this.state.activeItem} playlistData={this.props.playlistData} />
|
||||||
|
) : 'video' === this.state.mediaType || 'audio' === this.state.mediaType ? (
|
||||||
|
<AutoPlay />
|
||||||
) : null}
|
) : null}
|
||||||
|
<RelatedMedia hideFirst={!this.state.isPlaylistPage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mediacms",
|
"name": "mediacms",
|
||||||
"version": "8.0.2",
|
"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
@@ -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
@@ -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']}),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"] = {
|
||||||
|
|||||||
@@ -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
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user