Compare commits

...

16 Commits

Author SHA1 Message Date
semantic-release-bot a3fe375a83 chore(release): 8.1.3 [skip ci]
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)

### Bug Fixes

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

### Bug Fixes

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

### Bug Fixes

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

### Features

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

### Bug Fixes

* update documentation and fix smaller issues ([#1520](https://github.com/mediacms-io/mediacms/issues/1520)) ([d6a1151](https://github.com/mediacms-io/mediacms/commit/d6a11514e54b9341ec8a306a259adce6b4199d42))
2026-05-13 18:14:36 +00:00
Markos Gogoulos d6a11514e5 fix: update documentation and fix smaller issues (#1520) 2026-05-13 21:14:02 +03:00
semantic-release-bot c7a1d60d73 chore(release): 8.0.7 [skip ci]
## [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))
2026-05-12 11:08:33 +00:00
Markos Gogoulos 6ee5bef6ce fix: bring related items back (#1515) 2026-05-12 14:07:36 +03:00
semantic-release-bot 2e01000559 chore(release): 8.0.6 [skip ci]
## [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))
2026-05-11 12:26:03 +00:00
Markos Gogoulos 4f11addcfd fix: better place secret key settings 2026-05-11 15:25:23 +03:00
33 changed files with 363 additions and 331 deletions
-1
View File
@@ -24,7 +24,6 @@ If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):** **Environment (please complete the following information):**
- OS: [e.g. Ubuntu Linux] - OS: [e.g. Ubuntu Linux]
- Installation method: [Docker install, or single server install]
- Browser, if applicable - Browser, if applicable
**Additional context** **Additional context**
+1
View File
@@ -38,3 +38,4 @@ 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
.secret_key.lock
+43
View File
@@ -1,5 +1,48 @@
# Changelog # Changelog
## [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) ## [8.0.5](https://github.com/mediacms-io/mediacms/compare/v8.0.4...v8.0.5) (2026-05-11)
### Bug Fixes ### Bug Fixes
+1 -2
View File
@@ -43,7 +43,7 @@ A demo is available at https://demo.mediacms.io
- **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9) - **Multiple transcoding profiles**: sane defaults for multiple dimensions (144p, 240p, 360p, 480p, 720p, 1080p) and multiple profiles (h264, h265, vp9)
- **Adaptive video streaming**: possible through HLS protocol - **Adaptive video streaming**: possible through HLS protocol
- **Subtitles/CC**: support for multilingual subtitle files - **Subtitles/CC**: support for multilingual subtitle files
- **Scalable transcoding**: transcoding through priorities. Experimental support for remote workers - **Scalable transcoding**: transcoding through priorities.
- **Chunked file uploads**: for pausable/resumable upload of content - **Chunked file uploads**: for pausable/resumable upload of content
- **REST API**: Documented through Swagger - **REST API**: Documented through Swagger
- **Translation**: Most of the CMS is translated to a number of languages - **Translation**: Most of the CMS is translated to a number of languages
@@ -91,7 +91,6 @@ In order to support automatic transcriptions through Whisper, consider more CPUs
There are two ways to run MediaCMS, through Docker Compose and through installing it on a server via an automation script that installs and configures all needed services. Find the related pages: There are two ways to run MediaCMS, through Docker Compose and through installing it on a server via an automation script that installs and configures all needed services. Find the related pages:
- [Single Server](docs/admins_docs.md#2-server-installation) page
- [Docker Compose](docs/admins_docs.md#3-docker-installation) page - [Docker Compose](docs/admins_docs.md#3-docker-installation) page
A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b). A complete guide can be found on the blog post [How to self-host and share your videos in 2021](https://medium.com/@MediaCMS.io/how-to-self-host-and-share-your-videos-in-2021-14067e3b291b).
+12
View File
@@ -3,7 +3,9 @@ from __future__ import absolute_import
import os import os
from celery import Celery from celery import Celery
from celery.signals import worker_process_init
from django.conf import settings from django.conf import settings
from django.db import connections
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings")
app = Celery("cms") app = Celery("cms")
@@ -20,3 +22,13 @@ app.conf.task_always_eager = settings.CELERY_TASK_ALWAYS_EAGER
app.conf.worker_prefetch_multiplier = 1 app.conf.worker_prefetch_multiplier = 1
@worker_process_init.connect
def close_db_pool_on_fork(**_):
# psycopg3's ConnectionPool is not fork-safe: children inherit dead sockets
# from the parent's pool and block on getconn() until PoolTimeout. Dispose
# the inherited pool here so each prefork child opens its own on first use.
# NB: plain conn.close() would only putconn() back into the broken pool.
for conn in connections.all():
conn.close_pool()
+41 -41
View File
@@ -1,7 +1,6 @@
import os import os
from celery.schedules import crontab from celery.schedules import crontab
from django.core.management.utils import get_random_secret_key
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
DEBUG = False DEBUG = False
@@ -172,30 +171,19 @@ REST_FRAMEWORK = {
} }
# Set the SECRET_KEY env var in production. If unset, a fresh random key is # In docker, deploy/docker/entrypoint.sh ensures the SECRET_KEY env var is
# generated or read from a .secret_key file to ensure all workers share the same key. # set (generating .secret_key once on first start if needed). Outside docker,
def get_secret_key(): # either set SECRET_KEY in the environment or create a .secret_key file at the
key = os.getenv('SECRET_KEY') # project root, e.g.:
if key: # python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' > .secret_key
return key SECRET_KEY = os.getenv('SECRET_KEY')
if not SECRET_KEY:
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _secret_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.secret_key')
secret_path = os.path.join(base_dir, '.secret_key') if os.path.exists(_secret_path):
with open(_secret_path) as _f:
if os.path.exists(secret_path): SECRET_KEY = _f.read().strip()
with open(secret_path) as f: if not SECRET_KEY:
return f.read().strip() raise RuntimeError("SECRET_KEY is not set. Set the SECRET_KEY env var or create a .secret_key file at the project root.")
key = get_random_secret_key()
try:
with open(secret_path, 'w') as f:
f.write(key)
except Exception:
pass
return key
SECRET_KEY = get_secret_key()
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__)))
@@ -214,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"
@@ -278,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 = "/"
@@ -425,7 +407,25 @@ LOGGING = {
}, },
} }
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}} DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mediacms",
"HOST": "127.0.0.1",
"PORT": "5432",
"USER": "mediacms",
"PASSWORD": "mediacms",
"OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 8,
"timeout": 10,
"max_lifetime": 30 * 60,
"max_idle": 10 * 60,
}
},
}
}
REDIS_LOCATION = "redis://127.0.0.1:6379/1" REDIS_LOCATION = "redis://127.0.0.1:6379/1"
+1 -1
View File
@@ -1 +1 @@
VERSION = "8.0.4" VERSION = "8.1.2"
+25
View File
@@ -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 "$@"
+9 -1
View File
@@ -12,7 +12,15 @@ DATABASES = {
"PORT": os.getenv('POSTGRES_PORT', '5432'), "PORT": os.getenv('POSTGRES_PORT', '5432'),
"USER": os.getenv('POSTGRES_USER', 'mediacms'), "USER": os.getenv('POSTGRES_USER', 'mediacms'),
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'), "PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
"OPTIONS": {'pool': True}, "OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 8,
"timeout": 10,
"max_lifetime": 30 * 60,
"max_idle": 10 * 60,
}
},
} }
} }
+20 -2
View File
@@ -15,9 +15,27 @@ server {
location /static { location /static {
alias /home/mediacms.io/mediacms/static ; alias /home/mediacms.io/mediacms/static ;
} }
location = /_media_auth {
internal;
proxy_pass http://127.0.0.1:9000/api/v1/media-auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header Cookie $http_cookie;
}
location /media/original { location ~ ^/media/(encoded|hls|original)/(.*)$ {
alias /home/mediacms.io/mediacms/media_files/original; auth_request /_media_auth;
alias /home/mediacms.io/mediacms/media_files/$1/$2;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
} }
location /media { location /media {
+1 -1
View File
@@ -6,7 +6,7 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD:-$RANDOM_ADMIN_PASS}
if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then
echo "Running migrations service" echo "Running migrations service"
python manage.py migrate python manage.py migrate
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell` EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell 2>/dev/null | tail -1`
if [ "$EXISTING_INSTALLATION" = "True" ]; then if [ "$EXISTING_INSTALLATION" = "True" ]; then
echo "Loaddata has already run" echo "Loaddata has already run"
else else
+1 -65
View File
@@ -2,7 +2,6 @@
## Table of contents ## Table of contents
- [1. Welcome](#1-welcome) - [1. Welcome](#1-welcome)
- [2. Single Server Installaton](#2-single-server-installation)
- [3. Docker Installation](#3-docker-installation) - [3. Docker Installation](#3-docker-installation)
- [4. Docker Deployment options](#4-docker-deployment-options) - [4. Docker Deployment options](#4-docker-deployment-options)
- [5. Configuration](#5-configuration) - [5. Configuration](#5-configuration)
@@ -34,58 +33,6 @@
## 1. Welcome ## 1. Welcome
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications. This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
## 2. Single Server Installation
The core dependencies are python3, Django, celery, PostgreSQL, redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But the install.sh is only tested in Linux Ubuntu 24 and 22 versions.
Installation on an Ubuntu 22/24 system with git utility installed should be completed in a few minutes with the following steps.
Make sure you run it as user root, on a clear system, since the automatic script will install and configure the following services: Celery/PostgreSQL/Redis/Nginx and will override any existing settings.
```bash
mkdir /home/mediacms.io && cd /home/mediacms.io/
git clone https://github.com/mediacms-io/mediacms
cd /home/mediacms.io/mediacms/ && bash ./install.sh
```
The script will ask if you have a URL where you want to deploy MediaCMS, otherwise it will use localhost. If you provide a URL, it will use Let's Encrypt service to install a valid ssl certificate.
### Update
If you've used the above way to install MediaCMS, update with the following:
```bash
cd /home/mediacms.io/mediacms # enter mediacms directory
source /home/mediacms.io/bin/activate # use virtualenv
git pull # update code
pip install -r requirements.txt -U # run pip install to update
python manage.py migrate # run Django migrations
sudo systemctl restart mediacms celery_long celery_short # restart services
```
### Update from version 2 to version 3
Version 3 is using Django 4 and Celery 5, and needs a recent Python 3.x version. If you are updating from an older version, make sure Python is updated first. Version 2 could run on Python 3.6, but version 3 needs Python3.8 and higher.
The syntax for starting Celery has also changed, so you have to copy the celery related systemctl files and restart
```
# cp deploy/local_install/celery_long.service /etc/systemd/system/celery_long.service
# cp deploy/local_install/celery_short.service /etc/systemd/system/celery_short.service
# cp deploy/local_install/celery_beat.service /etc/systemd/system/celery_beat.service
# systemctl daemon-reload
# systemctl start celery_long celery_short celery_beat
```
### Configuration
Checkout the configuration section here.
### Maintenance
Database can be backed up with pg_dump and media_files on /home/mediacms.io/mediacms/media_files include original files and encoded/transcoded versions
## 3. Docker Installation ## 3. Docker Installation
@@ -220,14 +167,10 @@ Several options are available on `cms/settings.py`, most of the things that are
It is advisable to override any of them by adding it to `local_settings.py` . It is advisable to override any of them by adding it to `local_settings.py` .
In case of a the single server installation, add to `cms/local_settings.py` .
In case of a docker compose installation, add to `deploy/docker/local_settings.py` . This will automatically overwrite `cms/local_settings.py` . In case of a docker compose installation, add to `deploy/docker/local_settings.py` . This will automatically overwrite `cms/local_settings.py` .
Any change needs restart of MediaCMS in order to take effect. Any change needs restart of MediaCMS in order to take effect.
Single server installation: edit `cms/local_settings.py`, make a change and restart MediaCMS
```bash ```bash
#systemctl restart mediacms #systemctl restart mediacms
``` ```
@@ -795,14 +738,7 @@ Instructions contributed by @alberto98fx
On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings. On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings.
In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option
Enter the Django shell, example if you're using the Single Server installation: Enter the Django shell and inside the shell
```bash
source /home/mediacms.io/bin/activate
python manage.py shell
```
and inside the shell
```bash ```bash
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
+4 -2
View File
@@ -120,9 +120,11 @@ class MediaList(APIView):
operation_description='Delete media for MediaCMS managers and reviewers', operation_description='Delete media for MediaCMS managers and reviewers',
) )
def delete(self, request, format=None): def delete(self, request, format=None):
if not is_mediacms_manager(request.user):
return Response({"detail": "bad permissions"}, status=status.HTTP_403_FORBIDDEN)
tokens = request.GET.get("tokens") tokens = request.GET.get("tokens")
if tokens: if tokens:
tokens = tokens.split(",") tokens = [t for t in tokens.split(",") if t][:50]
Media.objects.filter(friendly_token__in=tokens).delete() Media.objects.filter(friendly_token__in=tokens).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@@ -177,7 +179,7 @@ class CommentList(APIView):
def delete(self, request, format=None): def delete(self, request, format=None):
comment_ids = request.GET.get("comment_ids") comment_ids = request.GET.get("comment_ids")
if comment_ids: if comment_ids:
comments = comment_ids.split(",") comments = [c for c in comment_ids.split(",") if c][:50]
Comment.objects.filter(uid__in=comments).delete() Comment.objects.filter(uid__in=comments).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
+13 -9
View File
@@ -453,17 +453,21 @@ def kill_ffmpeg_process(filepath):
filepath: Path to the file being processed by ffmpeg filepath: Path to the file being processed by ffmpeg
Returns: Returns:
subprocess.CompletedProcess: Result of the kill command bool: True if the lookup ran, False if input was unusable
""" """
if not filepath: if not filepath or not isinstance(filepath, str):
return False return False
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) ps = subprocess.run(["ps", "aux"], stdout=subprocess.PIPE, check=False)
pid = result.stdout.decode("utf-8").strip() except OSError:
if pid: return False
cmd = "kill -9 %s" % pid for line in ps.stdout.decode("utf-8", "replace").splitlines():
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) if "ffmpeg" not in line or filepath not in line or "grep" in line:
return result continue
parts = line.split()
if len(parts) > 1 and parts[1].isdigit():
subprocess.run(["kill", "-9", parts[1]], check=False)
return True
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"): def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
-4
View File
@@ -6,7 +6,6 @@ from django.core.files import File
from django.db import models from django.db import models
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse
from .. import helpers from .. import helpers
from .utils import ( from .utils import (
@@ -136,9 +135,6 @@ class Encoding(models.Model):
def __str__(self): def __str__(self):
return f"{self.profile.name}-{self.media.title}" return f"{self.profile.name}-{self.media.title}"
def get_absolute_url(self):
return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
@receiver(post_save, sender=Encoding) @receiver(post_save, sender=Encoding)
def encoding_file_save(sender, instance, created, **kwargs): def encoding_file_save(sender, instance, created, **kwargs):
+6 -6
View File
@@ -10,7 +10,7 @@ from django.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVectorField
from django.core.files import File from django.core.files import File
from django.db import models from django.db import models, transaction
from django.db.models import Func, Value from django.db.models import Func, Value
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
@@ -536,7 +536,9 @@ class Media(models.Model):
from .. import tasks from .. import tasks
tasks.produce_sprite_from_video.delay(self.friendly_token) # Defer until the surrounding transaction commits so the worker can
# actually find the Media row. Runs immediately if not in a tx.
transaction.on_commit(lambda token=self.friendly_token: tasks.produce_sprite_from_video.delay(token))
return True return True
def encode(self, profiles=[], force=True, chunkize=True): def encode(self, profiles=[], force=True, chunkize=True):
@@ -559,9 +561,8 @@ class Media(models.Model):
profiles.remove(profile) profiles.remove(profile)
encoding = Encoding(media=self, profile=profile) encoding = Encoding(media=self, profile=profile)
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
tasks.encode_media.apply_async( tasks.encode_media.apply_async(
args=[self.friendly_token, profile.id, encoding.id, enc_url], args=[self.friendly_token, profile.id, encoding.id],
kwargs={"force": force}, kwargs={"force": force},
priority=0, priority=0,
) )
@@ -575,13 +576,12 @@ class Media(models.Model):
continue continue
encoding = Encoding(media=self, profile=profile) encoding = Encoding(media=self, profile=profile)
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE: if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
priority = 9 priority = 9
else: else:
priority = 0 priority = 0
tasks.encode_media.apply_async( tasks.encode_media.apply_async(
args=[self.friendly_token, profile.id, encoding.id, enc_url], args=[self.friendly_token, profile.id, encoding.id],
kwargs={"force": force}, kwargs={"force": force},
priority=priority, priority=priority,
) )
+2 -5
View File
@@ -171,8 +171,7 @@ def chunkize_media(self, friendly_token, profiles, force=True):
continue continue
encoding = Encoding(media=media, profile=profile) encoding = Encoding(media=media, profile=profile)
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url() encode_media.delay(friendly_token, profile.id, encoding.id, force=force)
encode_media.delay(friendly_token, profile.id, encoding.id, enc_url, force=force)
return False return False
chunks = [os.path.join(cwd, ch) for ch in chunks] chunks = [os.path.join(cwd, ch) for ch in chunks]
@@ -202,13 +201,12 @@ def chunkize_media(self, friendly_token, profiles, force=True):
) )
encoding.save() encoding.save()
enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE: if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
priority = 0 priority = 0
else: else:
priority = 9 priority = 9
encode_media.apply_async( encode_media.apply_async(
args=[friendly_token, profile.id, encoding.id, enc_url], args=[friendly_token, profile.id, encoding.id],
kwargs={"force": force, "chunk": True, "chunk_file_path": chunk}, kwargs={"force": force, "chunk": True, "chunk_file_path": chunk},
priority=priority, priority=priority,
) )
@@ -246,7 +244,6 @@ def encode_media(
friendly_token, friendly_token,
profile_id, profile_id,
encoding_id, encoding_id,
encoding_url,
force=True, force=True,
chunk=False, chunk=False,
chunk_file_path="", chunk_file_path="",
+1 -5
View File
@@ -61,11 +61,6 @@ urlpatterns = [
views.MediaDetail.as_view(), views.MediaDetail.as_view(),
name="api_get_media", name="api_get_media",
), ),
re_path(
r"^api/v1/media/encoding/(?P<encoding_id>[\w]*)$",
views.EncodingDetail.as_view(),
name="api_get_encoding",
),
re_path(r"^api/v1/search$", views.MediaSearch.as_view()), re_path(r"^api/v1/search$", views.MediaSearch.as_view()),
re_path( re_path(
rf"^api/v1/media/{friendly_token}/share$", rf"^api/v1/media/{friendly_token}/share$",
@@ -111,6 +106,7 @@ urlpatterns = [
re_path(r"^api/v1/tasks$", views.TasksList.as_view()), re_path(r"^api/v1/tasks$", views.TasksList.as_view()),
re_path(r"^api/v1/tasks/$", views.TasksList.as_view()), re_path(r"^api/v1/tasks/$", views.TasksList.as_view()),
re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()), re_path(r"^api/v1/tasks/(?P<friendly_token>[\w|\W]*)$", views.TaskDetail.as_view()),
re_path(r"^api/v1/media-auth$", views.media_auth, name="media_auth"),
re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"), re_path(r"^manage/comments$", views.manage_comments, name="manage_comments"),
re_path(r"^manage/media$", views.manage_media, name="manage_media"), re_path(r"^manage/media$", views.manage_media, name="manage_media"),
re_path(r"^manage/users$", views.manage_users, name="manage_users"), re_path(r"^manage/users$", views.manage_users, name="manage_users"),
+2 -1
View File
@@ -3,13 +3,14 @@
from .auth import custom_login_view, saml_metadata # noqa: F401 from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401 from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401 from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401 from .encoding import EncodeProfileList # noqa: F401
from .media import MediaActions # noqa: F401 from .media import MediaActions # noqa: F401
from .media import MediaBulkUserActions # noqa: F401 from .media import MediaBulkUserActions # noqa: F401
from .media import MediaDetail # noqa: F401 from .media import MediaDetail # noqa: F401
from .media import MediaList # noqa: F401 from .media import MediaList # noqa: F401
from .media import MediaSearch # noqa: F401 from .media import MediaSearch # noqa: F401
from .media import media_share # noqa: F401 from .media import media_share # noqa: F401
from .media_auth import media_auth # noqa: F401
from .pages import about # noqa: F401 from .pages import about # noqa: F401
from .pages import add_subtitle # noqa: F401 from .pages import add_subtitle # noqa: F401
from .pages import approval_required # noqa: F401 from .pages import approval_required # noqa: F401
+1 -158
View File
@@ -1,168 +1,11 @@
from django.conf import settings
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from ..helpers import produce_ffmpeg_commands from ..models import EncodeProfile
from ..models import EncodeProfile, Encoding
from ..serializers import EncodeProfileSerializer from ..serializers import EncodeProfileSerializer
class EncodingDetail(APIView):
"""Experimental. This View is used by remote workers
Needs heavy testing and documentation.
"""
permission_classes = (permissions.IsAdminUser,)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(auto_schema=None)
def post(self, request, encoding_id):
ret = {}
force = request.data.get("force", False)
task_id = request.data.get("task_id", False)
action = request.data.get("action", "")
chunk = request.data.get("chunk", False)
chunk_file_path = request.data.get("chunk_file_path", "")
encoding_status = request.data.get("status", "")
progress = request.data.get("progress", "")
commands = request.data.get("commands", "")
logs = request.data.get("logs", "")
retries = request.data.get("retries", "")
worker = request.data.get("worker", "")
temp_file = request.data.get("temp_file", "")
total_run_time = request.data.get("total_run_time", "")
if action == "start":
try:
encoding = Encoding.objects.get(id=encoding_id)
media = encoding.media
profile = encoding.profile
except BaseException:
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
# TODO: break chunk True/False logic here
if (
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).count()
> 1 # noqa
and force is False # noqa
):
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
else:
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).exclude(id=encoding.id).delete()
encoding.status = "running"
if task_id:
encoding.task_id = task_id
encoding.save()
if chunk:
original_media_path = chunk_file_path
original_media_md5sum = encoding.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url
else:
original_media_path = media.media_file.path
original_media_md5sum = media.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url
ret["original_media_url"] = original_media_url
ret["original_media_path"] = original_media_path
ret["original_media_md5sum"] = original_media_md5sum
# generating the commands here, and will replace these with temporary
# files created on the remote server
tf = "TEMP_FILE_REPLACE"
tfpass = "TEMP_FPASS_FILE_REPLACE"
ffmpeg_commands = produce_ffmpeg_commands(
original_media_path,
media.media_info,
resolution=profile.resolution,
codec=profile.codec,
output_filename=tf,
pass_file=tfpass,
chunk=chunk,
)
if not ffmpeg_commands:
encoding.delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
ret["duration"] = media.duration
ret["ffmpeg_commands"] = ffmpeg_commands
ret["profile_extension"] = profile.extension
return Response(ret, status=status.HTTP_201_CREATED)
elif action == "update_fields":
try:
encoding = Encoding.objects.get(id=encoding_id)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
to_update = ["size", "update_date"]
if encoding_status:
encoding.status = encoding_status
to_update.append("status")
if progress:
encoding.progress = progress
to_update.append("progress")
if logs:
encoding.logs = logs
to_update.append("logs")
if commands:
encoding.commands = commands
to_update.append("commands")
if task_id:
encoding.task_id = task_id
to_update.append("task_id")
if total_run_time:
encoding.total_run_time = total_run_time
to_update.append("total_run_time")
if worker:
encoding.worker = worker
to_update.append("worker")
if temp_file:
encoding.temp_file = temp_file
to_update.append("temp_file")
if retries:
encoding.retries = retries
to_update.append("retries")
try:
encoding.save(update_fields=to_update)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"status": "success"}, status=status.HTTP_201_CREATED)
@swagger_auto_schema(auto_schema=None)
def put(self, request, encoding_id, format=None):
encoding_file = request.data["file"]
encoding = Encoding.objects.filter(id=encoding_id).first()
if not encoding:
return Response(
{"detail": "encoding does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
encoding.media_file = encoding_file
encoding.save()
return Response({"detail": "ok"}, status=status.HTTP_201_CREATED)
class EncodeProfileList(APIView): class EncodeProfileList(APIView):
"""List encode profiles""" """List encode profiles"""
+7 -3
View File
@@ -660,7 +660,9 @@ class MediaBulkUserActions(APIView):
# Prioritize category_uids # Prioritize category_uids
if category_uids: if category_uids:
categories = Category.objects.filter(uid__in=category_uids) requested = Category.objects.filter(uid__in=category_uids)
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
categories = Category.objects.filter(id__in=allowed_ids)
elif lti_context_id: elif lti_context_id:
# Filter categories by lti_context_id and ensure they ARE RBAC categories # Filter categories by lti_context_id and ensure they ARE RBAC categories
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True) potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
@@ -691,9 +693,11 @@ class MediaBulkUserActions(APIView):
if not category_uids: if not category_uids:
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
categories = Category.objects.filter(uid__in=category_uids) requested = Category.objects.filter(uid__in=category_uids)
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
categories = Category.objects.filter(id__in=allowed_ids)
if not categories: if not categories:
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0 removed_count = 0
for category in categories: for category in categories:
+125
View File
@@ -0,0 +1,125 @@
import re
from urllib.parse import unquote
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from ..methods import is_mediacms_editor
from ..models import Media
UID_RE = re.compile(r"[0-9a-f]{32}")
THUMBNAILS_PREFIX = "original/thumbnails/"
def _ttl():
return getattr(settings, "X_ACCEL_AUTH_CACHE_SECONDS", 300)
def _extract_uid(uri):
if not uri:
return None
match = UID_RE.search(uri)
return match.group(0) if match else None
def _relpath_from_uri(uri):
path = unquote(uri.split("?", 1)[0])
media_url = settings.MEDIA_URL
if path.startswith(media_url):
return path[len(media_url) :]
return None
def _lookup_uid_by_path(relpath):
path_key = f"xaccel:path:{relpath}"
cached = cache.get(path_key)
if cached is not None:
return cached or None
parts = relpath.split("/", 4)
if len(parts) < 5 or parts[2] != "user":
cache.set(path_key, "", _ttl())
return None
username = parts[3]
row = Media.objects.filter(user__username=username).filter(Q(uploaded_thumbnail=relpath) | Q(uploaded_poster=relpath)).values("uid").first()
uid_hex = row["uid"].hex if row else ""
cache.set(path_key, uid_hex, _ttl())
return uid_hex or None
def _lookup_state(uid):
"""Return (state, owner_id) for a uid, or (None, None) if missing.
Cached on uid alone since state/ownership do not depend on the requester.
Uses .values() rather than .only() because Media.__init__ touches deferred
file fields, which would otherwise recurse via refresh_from_db.
"""
state_key = f"xaccel:state:{uid}"
cached = cache.get(state_key)
if cached is not None:
return cached
row = Media.objects.filter(uid=uid).values("state", "user_id").first()
value = (row["state"], row["user_id"]) if row else (None, None)
cache.set(state_key, value, _ttl())
return value
def _decide(uid, user):
state, owner_id = _lookup_state(uid)
if state is None:
return False
if state in ("public", "unlisted"):
return True
# private
if not user.is_authenticated:
return False
if owner_id == user.id:
return True
if is_mediacms_editor(user):
return True
# RBAC / MediaPermission path needs a full Media instance.
try:
media = Media.objects.get(uid=uid)
except Media.DoesNotExist:
return False
return user.has_member_access_to_media(media)
@csrf_exempt
@require_GET
def media_auth(request):
"""Authorize a protected media request from nginx auth_request.
nginx passes the original request URI in the X-Original-URI header. The
Media.uid (32 hex chars, no dashes) is embedded somewhere in that URI for
every protected path. No uid => deny. Unknown uid => deny.
"""
if not getattr(settings, "USE_X_ACCEL_REDIRECT", True):
return HttpResponse(status=204)
uri = request.META.get("HTTP_X_ORIGINAL_URI", "")
uid = _extract_uid(uri)
if not uid:
# User-uploaded thumbnails/posters don't have the uid in the filename.
# Fall back to a per-path lookup, scoped to /original/thumbnails/.
relpath = _relpath_from_uri(uri)
if relpath and relpath.startswith(THUMBNAILS_PREFIX):
uid = _lookup_uid_by_path(relpath)
if not uid:
return HttpResponse(status=403)
user = request.user
cache_key = f"xaccel:auth:{uid}:{user.id if user.is_authenticated else 'anon'}"
cached = cache.get(cache_key)
if cached is None:
allowed = _decide(uid, user)
cache.set(cache_key, allowed, _ttl())
else:
allowed = cached
return HttpResponse(status=204 if allowed else 403)
@@ -1,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;
} }
-9
View File
@@ -1,16 +1,7 @@
from django.apps import AppConfig from django.apps import AppConfig
from .keys import ensure_keys_exist
class LtiConfig(AppConfig): class LtiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'lti' name = 'lti'
verbose_name = 'LTI 1.3 Integration' verbose_name = 'LTI 1.3 Integration'
def ready(self):
"""Initialize LTI app - ensure keys exist"""
try:
ensure_keys_exist()
except Exception:
pass
-7
View File
@@ -21,10 +21,3 @@ def get_jwks():
""" """
public_key = load_public_key() public_key = load_public_key()
return {'keys': [public_key]} return {'keys': [public_key]}
def ensure_keys_exist():
"""Ensure key pair exists in database, generate if not"""
from .models import LTIToolKeys
LTIToolKeys.get_or_create_keys()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mediacms", "name": "mediacms",
"version": "8.0.5", "version": "8.1.3",
"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",
+5 -1
View File
@@ -1,5 +1,6 @@
import base64 import base64
import logging import logging
import re
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
@@ -22,7 +23,10 @@ class SAMLAccountAdapter(DefaultSocialAccountAdapter):
def populate_user(self, request, sociallogin, data): def populate_user(self, request, sociallogin, data):
user = sociallogin.user user = sociallogin.user
user.username = sociallogin.account.uid raw_uid = sociallogin.account.uid or ""
# Match the user URL pattern in users/urls.py: only [\w@._-] is reverse-able.
sanitized = re.sub(r"[^\w.@-]", "_", raw_uid, flags=re.ASCII)
user.username = sanitized[:150] if sanitized else raw_uid
for item in ["name", "first_name", "last_name"]: for item in ["name", "first_name", "last_name"]:
if data.get(item): if data.get(item):
setattr(user, item, data[item]) setattr(user, item, data[item])
+1 -1
View File
@@ -2,4 +2,4 @@
exclude = .git,*migrations* exclude = .git,*migrations*
max-line-length = 119 max-line-length = 119
#ignore=F401,F403,E501,W503 #ignore=F401,F403,E501,W503
ignore=E501 ignore=E501,E203
+1 -1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -34,7 +34,6 @@ class TestImports(TestCase):
from files.views import CommentDetail # noqa: F401 from files.views import CommentDetail # noqa: F401
from files.views import CommentList # noqa: F401 from files.views import CommentList # noqa: F401
from files.views import EncodeProfileList # noqa: F401 from files.views import EncodeProfileList # noqa: F401
from files.views import EncodingDetail # noqa: F401
from files.views import MediaActions # noqa: F401 from files.views import MediaActions # noqa: F401
from files.views import MediaBulkUserActions # noqa: F401 from files.views import MediaBulkUserActions # noqa: F401
from files.views import MediaDetail # noqa: F401 from files.views import MediaDetail # noqa: F401
+2 -2
View File
@@ -7,8 +7,8 @@ from django.utils.translation import gettext_lazy as _
@deconstructible @deconstructible
class ASCIIUsernameValidator(validators.RegexValidator): class ASCIIUsernameValidator(validators.RegexValidator):
regex = r"^[\w.@]+$" regex = r"^[\w.@-]+$"
message = _("Enter a valid username. This value may contain only " "English letters and numbers") message = _("Enter a valid username. This value may contain only English letters, numbers, and '_', '.', '@', '-'.")
flags = re.ASCII flags = re.ASCII
+14
View File
@@ -1,5 +1,8 @@
from allauth.account.adapter import get_adapter
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
@@ -275,6 +278,11 @@ class UserList(APIView):
if not all([username, password, email, name]): if not all([username, password, email, name]):
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
try:
username = get_adapter().clean_username(username, shallow=True)
except DjangoValidationError as e:
return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists(): if User.objects.filter(username=username).exists():
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
@@ -369,9 +377,15 @@ class UserDetail(APIView):
if action == "change_password": if action == "change_password":
# Permission to edit user is already checked by self.get_user -> self.check_object_permissions # Permission to edit user is already checked by self.get_user -> self.check_object_permissions
if user.is_superuser and not request.user.is_superuser:
raise PermissionDenied("You do not have permission to change a superuser's password.")
password = request.data.get("password") password = request.data.get("password")
if not password: if not password:
return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
try:
validate_password(password, user=user)
except DjangoValidationError as exc:
return Response({"detail": list(exc.messages)}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(password) user.set_password(password)
user.save() user.save()