Compare commits

..

40 Commits

Author SHA1 Message Date
Markos Gogoulos
ab4d9d67df fix: issue with import (#1245) 2025-04-07 18:36:38 +03:00
Markos Gogoulos
f7a2f049bd Update README.md (#1244) 2025-04-05 12:52:29 +03:00
Markos Gogoulos
05414f66c7 feat: RBAC + SAML support 2025-04-05 12:44:21 +03:00
Markos Gogoulos
8fecccce1c feat: move docker compose files 2025-03-18 19:21:39 +02:00
Markos Gogoulos
2a7123ca0b feat: move docker compose files in dir (#1231) 2025-03-18 19:05:04 +02:00
Yiannis Christodoulou
20f305e69e feat: Frontend Dependencies Upgrade +Fix Timestamps in videos 2025-03-18 19:01:50 +02:00
Markos Gogoulos
d1fda05fdc fix: flake8 2025-03-09 20:50:07 +02:00
Markos Gogoulos
a02e0a8a66 fix: flake8 2025-03-09 20:48:09 +02:00
Markos Gogoulos
21f76dbb6e feat: playlist optimizations (#1216) 2025-03-09 20:44:04 +02:00
Markos Gogoulos
50e9f3103f feat: better support for subtitles (#1215) 2025-03-09 20:29:26 +02:00
Markos Gogoulos
0b9a203123 revert head changes 2025-02-13 20:31:19 +02:00
Sven-Thorsten Dietrich
5cbd815496 fix: Fix Docker WARN: FromAsCasing (#1196)
Fixes: L27 'as' and 'FROM' keywords' casing do not match

Signed-off-by: Sven-Thorsten Dietrich <thebigcorporation@gmail.com>
2025-02-13 13:57:14 +02:00
Markos Gogoulos
3a8cacc847 feat: Bulk fixes (#1195)
remove ckeditor - not in use
add more strict default password validators
set Django admin as configurable URL
add nginx HSTS and CSP headers
enable moving from private to unlisted in the PORTAL_WORKFLOW private
on default comments listing, show only comments for public media
in case of a private media, dont expose any unneeded metadata
2025-02-13 13:41:53 +02:00
Markos Gogoulos
5402ee7bc5 fix: crispy forms (#1194) 2025-02-12 14:27:27 +02:00
Markos Gogoulos
a6a2b50c8d remove redundant message 2025-02-10 22:25:24 +02:00
Markos Gogoulos
23e48a8bb7 remove redundant message 2025-02-10 22:24:42 +02:00
Markos Gogoulos
313cd9cbc6 fix issue with static files in dev 2025-02-10 22:04:14 +02:00
Markos Gogoulos
0392dbe1ed feat: lib updates and more (#1187)
This PR performs the following changes:

- update python in Dockerfile
- updates all python libraries (including Django)
- makes md5sum calculation redundant by default
- updates Postgresql used in Docker. For new installations this is ok. For existing ones this is backwards incompatible, and restore through pg_dump+pg_restore has to be performed in order to utilize Postgres 17 (since its not compatible with previous versions and will complain)
- fixes issues with HLS, and adds test to ensure they won't happen again
- allows . and @ in usernames
2025-02-10 11:34:00 +02:00
Markos Gogoulos
a7562c244e fix: black (#1164) 2025-01-22 20:16:21 +02:00
Sonu Kumar
d2ee12087c feat: Re-Arranged Subtitles by first letter of language (#863) 2025-01-22 19:25:47 +02:00
aleensd
6db01932e1 feat: fullscreen images slideshow (#1120) 2025-01-22 19:02:01 +02:00
Markos Gogoulos
53d8215346 feat: comment URL (#1163) 2025-01-22 14:55:43 +02:00
David
1b960b28f8 feat: Update insert login_required middleware to allow auth middleware (#1082) 2025-01-22 14:54:25 +02:00
Markos Gogoulos
02d9188aa1 feat: increase task limit (#1161) 2025-01-22 11:22:07 +02:00
Meng Sen
8d9a4618f0 feat: support for reading environment variables on Docker 2024-11-22 18:35:47 +02:00
aleensd
cf93a77802 feat: integrate react-pdf-viewer for PDF display (#1112) 2024-11-21 12:15:25 +02:00
Markos Gogoulos
5a1e4f25ed fix: issue with create_hls 2024-11-20 15:04:27 +02:00
Markos Gogoulos
9fc7597e73 fix: GH action 2024-11-20 13:47:29 +02:00
Markos Gogoulos
9b3e0250d4 fix: GH action 2024-11-20 13:36:58 +02:00
Markos Gogoulos
1384471745 fix: flake8 2024-11-20 13:29:37 +02:00
Markos Gogoulos
29b362c8ce fix: flake8 2024-11-20 13:27:57 +02:00
Markos Gogoulos
b8ee2e9fb8 fix: pre-commit 2024-11-20 13:17:25 +02:00
Markos Gogoulos
99be0f07dd feat: edit slideshow_url results 2024-11-18 15:48:02 +02:00
Markos Gogoulos
27d1660192 feat: provide slideshow media for images (#1108) 2024-11-14 12:22:23 +02:00
Nathan Hyde
98adb22205 Fixes mediacms-io/mediacms#1046 - submition => submission (#1094) 2024-10-30 19:49:26 +02:00
yatesdr
673ddeb5bd feat: Set option for allowing only certain domains to register (#1086) 2024-10-19 14:51:20 +03:00
Markos Gogoulos
aa8a2d92dc Feat/check input (#1089)
* docs: instructions to set frames per seconds on sprites

* feat: add more validation

* remove reduntant line
2024-10-19 14:17:19 +03:00
Kaiwalya Koparkar
6bbd4c2809 feat: Added Elestio as one-click deploy option (#1055) 2024-10-08 10:44:44 +03:00
Markos Gogoulos
c4148bd504 feat: semantic release 2024-10-07 09:10:21 +03:00
Markos Gogoulos
ea8b2af26f fix: remove duplicate setting 2024-10-04 16:40:53 +03:00
265 changed files with 49127 additions and 47122 deletions

View File

@@ -13,10 +13,10 @@ jobs:
uses: actions/checkout@v1
- name: Build the Stack
run: docker-compose -f docker-compose-dev.yaml build
run: docker compose -f docker-compose-dev.yaml build
- name: Start containers
run: docker-compose -f docker-compose-dev.yaml up -d
run: docker compose -f docker-compose-dev.yaml up -d
- name: List containers
run: docker ps
@@ -26,10 +26,10 @@ jobs:
shell: bash
- name: Run Django Tests
run: docker-compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest
run: docker compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest
# Run with coverage, saves report on htmlcov dir
# run: docker-compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest --cov --cov-report=html --cov-config=.coveragerc
- name: Tear down the Stack
run: docker-compose -f docker-compose-dev.yaml down
run: docker compose -f docker-compose-dev.yaml down

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ static/mptt/
static/rest_framework/
static/drf-yasg
cms/local_settings.py
deploy/docker/local_settings.py
deploy/docker/local_settings.py
yt.readme.md

View File

@@ -1,70 +1,88 @@
FROM python:3.11.4-bookworm AS compile-image
FROM python:3.13-bookworm AS build-image
SHELL ["/bin/bash", "-c"]
# Set up virtualenv
ENV VIRTUAL_ENV=/home/mediacms.io
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV PIP_NO_CACHE_DIR=1
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && cd /home/mediacms.io && python3 -m venv $VIRTUAL_ENV
# Install dependencies:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . /home/mediacms.io/mediacms
WORKDIR /home/mediacms.io/mediacms
RUN wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d ../bento4 && \
mv ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/* ../bento4/ && \
rm -rf ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux && \
rm -rf ../bento4/docs && \
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
############ RUNTIME IMAGE ############
FROM python:3.11.4-bookworm as runtime-image
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# See: https://github.com/celery/celery/issues/6285#issuecomment-715316219
ENV CELERY_APP='cms'
# Use these to toggle which processes supervisord should run
ENV ENABLE_UWSGI='yes'
ENV ENABLE_NGINX='yes'
ENV ENABLE_CELERY_BEAT='yes'
ENV ENABLE_CELERY_SHORT='yes'
ENV ENABLE_CELERY_LONG='yes'
ENV ENABLE_MIGRATIONS='yes'
# Set up virtualenv
ENV VIRTUAL_ENV=/home/mediacms.io
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY --chown=www-data:www-data --from=compile-image /home/mediacms.io /home/mediacms.io
RUN apt-get update -y && apt-get -y upgrade && apt-get install --no-install-recommends \
supervisor nginx imagemagick procps wget xz-utils -y && \
# Install system dependencies needed for downloading and extracting
RUN apt-get update -y && \
apt-get install -y --no-install-recommends wget xz-utils unzip && \
rm -rf /var/lib/apt/lists/* && \
apt-get purge --auto-remove && \
apt-get clean
# Install ffmpeg
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz && \
mkdir -p ffmpeg-tmp && \
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C ffmpeg-tmp && \
cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
# Install Bento4 in the specified location
RUN mkdir -p /home/mediacms.io/bento4 && \
wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d /home/mediacms.io/bento4 && \
mv /home/mediacms.io/bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/* /home/mediacms.io/bento4/ && \
rm -rf /home/mediacms.io/bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux && \
rm -rf /home/mediacms.io/bento4/docs && \
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
############ RUNTIME IMAGE ############
FROM python:3.13-bookworm AS runtime_image
SHELL ["/bin/bash", "-c"]
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV CELERY_APP='cms'
ENV VIRTUAL_ENV=/home/mediacms.io
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install runtime system dependencies
RUN apt-get update -y && \
apt-get -y upgrade && \
apt-get install --no-install-recommends supervisor nginx imagemagick procps libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \
rm -rf /var/lib/apt/lists/* && \
apt-get purge --auto-remove && \
apt-get clean
# Copy ffmpeg and Bento4 from build image
COPY --from=build-image /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=build-image /usr/local/bin/ffprobe /usr/local/bin/ffprobe
COPY --from=build-image /usr/local/bin/qt-faststart /usr/local/bin/qt-faststart
COPY --from=build-image /home/mediacms.io/bento4 /home/mediacms.io/bento4
# Set up virtualenv
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && \
cd /home/mediacms.io && \
python3 -m venv $VIRTUAL_ENV
# Install Python dependencies
COPY requirements.txt requirements-dev.txt ./
ARG DEVELOPMENT_MODE=False
RUN pip install --no-cache-dir -r requirements.txt && \
if [ "$DEVELOPMENT_MODE" = "True" ]; then \
echo "Installing development dependencies..." && \
pip install --no-cache-dir -r requirements-dev.txt; \
fi
# Copy application files
COPY . /home/mediacms.io/mediacms
WORKDIR /home/mediacms.io/mediacms
# required for sprite thumbnail generation for large video files
COPY deploy/docker/policy.xml /etc/ImageMagick-6/policy.xml
# Set process control environment variables
ENV ENABLE_UWSGI='yes' \
ENABLE_NGINX='yes' \
ENABLE_CELERY_BEAT='yes' \
ENABLE_CELERY_SHORT='yes' \
ENABLE_CELERY_LONG='yes' \
ENABLE_MIGRATIONS='yes'
EXPOSE 9000 80
RUN chmod +x ./deploy/docker/entrypoint.sh
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
CMD ["./deploy/docker/start.sh"]

View File

@@ -1,73 +0,0 @@
FROM python:3.11.4-bookworm AS compile-image
SHELL ["/bin/bash", "-c"]
# Set up virtualenv
ENV VIRTUAL_ENV=/home/mediacms.io
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ENV PIP_NO_CACHE_DIR=1
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && cd /home/mediacms.io && python3 -m venv $VIRTUAL_ENV
# Install dependencies:
COPY requirements.txt .
COPY requirements-dev.txt .
RUN pip install -r requirements.txt
RUN pip install -r requirements-dev.txt
COPY . /home/mediacms.io/mediacms
WORKDIR /home/mediacms.io/mediacms
RUN wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d ../bento4 && \
mv ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/* ../bento4/ && \
rm -rf ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux && \
rm -rf ../bento4/docs && \
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
############ RUNTIME IMAGE ############
FROM python:3.11.4-bookworm as runtime-image
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# See: https://github.com/celery/celery/issues/6285#issuecomment-715316219
ENV CELERY_APP='cms'
# Use these to toggle which processes supervisord should run
ENV ENABLE_UWSGI='yes'
ENV ENABLE_NGINX='yes'
ENV ENABLE_CELERY_BEAT='yes'
ENV ENABLE_CELERY_SHORT='yes'
ENV ENABLE_CELERY_LONG='yes'
ENV ENABLE_MIGRATIONS='yes'
# Set up virtualenv
ENV VIRTUAL_ENV=/home/mediacms.io
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY --chown=www-data:www-data --from=compile-image /home/mediacms.io /home/mediacms.io
RUN apt-get update -y && apt-get -y upgrade && apt-get install --no-install-recommends \
supervisor nginx imagemagick procps wget xz-utils -y && \
rm -rf /var/lib/apt/lists/* && \
apt-get purge --auto-remove && \
apt-get clean
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz && \
mkdir -p ffmpeg-tmp && \
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C ffmpeg-tmp && \
cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
WORKDIR /home/mediacms.io/mediacms
EXPOSE 9000 80
RUN chmod +x ./deploy/docker/entrypoint.sh
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
CMD ["./deploy/docker/start.sh"]

View File

@@ -10,6 +10,10 @@ admin-shell:
fi
build-frontend:
docker-compose -f docker-compose-dev.yaml exec frontend npm run dist
docker compose -f docker-compose-dev.yaml exec frontend npm run dist
cp -r frontend/dist/static/* static/
docker-compose -f docker-compose-dev.yaml restart web
docker compose -f docker-compose-dev.yaml restart web
test:
docker compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest

View File

@@ -23,11 +23,13 @@ A demo is available at https://demo.mediacms.io
## Features
- **Complete control over your data**: host it yourself!
- **Support for multiple publishing workflows**: public, private, unlisted and custom
- **Modern technologies**: Django/Python/Celery, React.
- **Support for multiple publishing workflows**: public, private, unlisted and custom
- **Multiple media types support**: video, audio, image, pdf
- **Multiple media classification options**: categories, tags and custom
- **Multiple media sharing options**: social media share, videos embed code generation
- **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media
- **SAML support**: with ability to add mappings to system roles and groups
- **Easy media searching**: enriched with live search functionality
- **Playlists for audio and video content**: create playlists, add and reorder content
- **Responsive design**: including light and dark themes
@@ -43,7 +45,6 @@ A demo is available at https://demo.mediacms.io
- **REST API**: Documented through Swagger
- **Translation**: Most of the CMS is translated to a number of languages
## Example cases
- **Schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
@@ -68,7 +69,12 @@ Copyright Markos Gogoulos.
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
### Commercial Hostings
**Elestio**
You can deploy MediaCMS on Elestio using one-click deployment. Elestio supports MediaCMS by providing revenue share so go ahead and click below to deploy and use MediaCMS.
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/mediacms)
## Hardware considerations

View File

View File

View File

@@ -0,0 +1,86 @@
from django.apps import AppConfig
from django.conf import settings
from django.contrib import admin
class AdminCustomizationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_customizations'
def ready(self):
original_get_app_list = admin.AdminSite.get_app_list
def get_app_list(self, request, app_label=None):
"""Custom get_app_list"""
app_list = original_get_app_list(self, request, app_label)
# To see the list:
# print([a.get('app_label') for a in app_list])
email_model = None
rbac_group_model = None
identity_providers_user_log_model = None
identity_providers_login_option = None
auth_app = None
rbac_app = None
socialaccount_app = None
for app in app_list:
if app['app_label'] == 'users':
auth_app = app
elif app['app_label'] == 'account':
for model in app['models']:
if model['object_name'] == 'EmailAddress':
email_model = model
elif app['app_label'] == 'rbac':
if not getattr(settings, 'USE_RBAC', False):
continue
rbac_app = app
for model in app['models']:
if model['object_name'] == 'RBACGroup':
rbac_group_model = model
elif app['app_label'] == 'identity_providers':
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
continue
models_to_check = list(app['models'])
for model in models_to_check:
if model['object_name'] == 'IdentityProviderUserLog':
identity_providers_user_log_model = model
if model['object_name'] == 'LoginOption':
identity_providers_login_option = model
elif app['app_label'] == 'socialaccount':
socialaccount_app = app
if email_model and auth_app:
auth_app['models'].append(email_model)
if rbac_group_model and rbac_app and auth_app:
auth_app['models'].append(rbac_group_model)
if identity_providers_login_option and socialaccount_app:
socialaccount_app['models'].append(identity_providers_login_option)
if identity_providers_user_log_model and socialaccount_app:
socialaccount_app['models'].append(identity_providers_user_log_model)
# 2. don't include the following apps
apps_to_hide = ['authtoken', 'auth', 'account', 'saml_auth', 'rbac']
if not getattr(settings, 'USE_RBAC', False):
apps_to_hide.append('rbac')
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
apps_to_hide.append('socialaccount')
app_list = [app for app in app_list if app['app_label'] not in apps_to_hide]
# 3. change the ordering
app_order = {
'files': 1,
'users': 2,
'socialaccount': 3,
'rbac': 5,
}
app_list.sort(key=lambda x: app_order.get(x['app_label'], 999))
return app_list
admin.AdminSite.get_app_list = get_app_list

View File

View File

View File

View File

@@ -4,30 +4,36 @@ import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'allauth',
'allauth.account',
'allauth.socialaccount',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'rest_framework',
'rest_framework.authtoken',
'imagekit',
'files.apps.FilesConfig',
'users.apps.UsersConfig',
'actions.apps.ActionsConfig',
'debug_toolbar',
'mptt',
'crispy_forms',
'uploader.apps.UploaderConfig',
'djcelery_email',
'ckeditor',
'drf_yasg',
'corsheaders',
"admin_customizations",
"django.contrib.auth",
"allauth",
"allauth.account",
"allauth.socialaccount",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"jazzmin",
"django.contrib.admin",
"django.contrib.sites",
"rest_framework",
"rest_framework.authtoken",
"imagekit",
"files.apps.FilesConfig",
"users.apps.UsersConfig",
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"debug_toolbar",
"mptt",
"crispy_forms",
"crispy_bootstrap5",
"uploader.apps.UploaderConfig",
"djcelery_email",
"drf_yasg",
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
"corsheaders",
]
MIDDLEWARE = [
@@ -41,9 +47,10 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',
"allauth.account.middleware.AccountMiddleware",
]
DEBUG = True
CORS_ORIGIN_ALLOW_ALL = True
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static/'),)
STATIC_ROOT = None
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)
STATIC_ROOT = os.path.join(BASE_DIR, 'static_collected')

View File

@@ -9,7 +9,6 @@ DEBUG = False
# is also shown on several places as emails
PORTAL_NAME = "MediaCMS"
PORTAL_DESCRIPTION = ""
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Europe/London"
# who can add media
@@ -112,11 +111,11 @@ TIME_TO_ACTION_ANONYMOUS = 10 * 60
# django-allauth settings
ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_LOGIN_METHODS = {"username", "email"}
ACCOUNT_EMAIL_REQUIRED = True # new users need to specify email
ACCOUNT_EMAIL_VERIFICATION = "optional" # 'mandatory' 'none'
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
ACCOUNT_USERNAME_MIN_LENGTH = "4"
ACCOUNT_USERNAME_MIN_LENGTH = 4
ACCOUNT_ADAPTER = "users.adapter.MyAccountAdapter"
ACCOUNT_SIGNUP_FORM_CLASS = "users.forms.SignupForm"
ACCOUNT_USERNAME_VALIDATORS = "users.validators.custom_username_validators"
@@ -124,13 +123,15 @@ ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = True
ACCOUNT_LOGIN_ON_PASSWORD_RESET = True
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
ACCOUNT_LOGIN_ATTEMPTS_LIMIT = 20
ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT = 5
# registration won't be open, might also consider to remove links for register
USERS_CAN_SELF_REGISTER = True
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"]
# Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"]
# Empty list disables.
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = []
# django rest settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
@@ -227,11 +228,11 @@ POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY = ""
CANNOT_ADD_MEDIA_MESSAGE = ""
# mp4hls command, part of Bendo4
# mp4hls command, part of Bento4
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 = "c2b8e1838b6128asd333ddc5e24"
ADMIN_TOKEN = ""
# this is used by remote workers to push
# encodings once they are done
# USE_BASIC_HTTP = True
@@ -246,35 +247,6 @@ ADMIN_TOKEN = "c2b8e1838b6128asd333ddc5e24"
# uncomment the two lines related to htpasswd
CKEDITOR_CONFIGS = {
"default": {
"toolbar": "Custom",
"width": "100%",
"toolbar_Custom": [
["Styles"],
["Format"],
["Bold", "Italic", "Underline"],
["HorizontalRule"],
[
"NumberedList",
"BulletedList",
"-",
"Outdent",
"Indent",
"-",
"JustifyLeft",
"JustifyCenter",
"JustifyRight",
"JustifyBlock",
],
["Link", "Unlink"],
["Image"],
["RemoveFormat", "Source"],
],
}
}
AUTH_USER_MODEL = "users.User"
LOGIN_REDIRECT_URL = "/"
@@ -284,7 +256,7 @@ AUTHENTICATION_BACKENDS = (
)
INSTALLED_APPS = [
"django.contrib.admin",
"admin_customizations",
"django.contrib.auth",
"allauth",
"allauth.account",
@@ -293,6 +265,8 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"jazzmin",
"django.contrib.admin",
"django.contrib.sites",
"rest_framework",
"rest_framework.authtoken",
@@ -300,13 +274,17 @@ INSTALLED_APPS = [
"files.apps.FilesConfig",
"users.apps.UsersConfig",
"actions.apps.ActionsConfig",
"rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig",
"debug_toolbar",
"mptt",
"crispy_forms",
"crispy_bootstrap5",
"uploader.apps.UploaderConfig",
"djcelery_email",
"ckeditor",
"drf_yasg",
"allauth.socialaccount.providers.saml",
"saml_auth.apps.SamlAuthConfig",
]
MIDDLEWARE = [
@@ -319,6 +297,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "cms.urls"
@@ -346,11 +325,15 @@ WSGI_APPLICATION = "cms.wsgi.application"
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
"OPTIONS": {
"user_attributes": ("username", "email", "first_name", "last_name"),
"max_similarity": 0.7,
},
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {
"min_length": 5,
"min_length": 7,
},
},
{
@@ -462,54 +445,11 @@ CELERY_TASK_ALWAYS_EAGER = False
if os.environ.get("TESTING"):
CELERY_TASK_ALWAYS_EAGER = True
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
# ALLOWED_HOSTS needs a url/ip
ALLOWED_HOSTS.append(FRONTEND_HOST.replace("http://", "").replace("https://", ""))
except ImportError:
# local_settings not in use
pass
if "http" not in FRONTEND_HOST:
# FRONTEND_HOST needs a http:// preffix
FRONTEND_HOST = f"http://{FRONTEND_HOST}" # noqa
if LOCAL_INSTALL:
SSL_FRONTEND_HOST = FRONTEND_HOST.replace("http", "https")
else:
SSL_FRONTEND_HOST = FRONTEND_HOST
if GLOBAL_LOGIN_REQUIRED:
# this should go after the AuthenticationMiddleware middleware
MIDDLEWARE.insert(5, "login_required.middleware.LoginRequiredMiddleware")
LOGIN_REQUIRED_IGNORE_PATHS = [
r'/accounts/login/$',
r'/accounts/logout/$',
r'/accounts/signup/$',
r'/accounts/password/.*/$',
r'/accounts/confirm-email/.*/$',
r'/api/v[0-9]+/',
]
# if True, only show original, don't perform any action on videos
DO_NOT_TRANSCODE_VIDEO = False
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# the following is related to local development using docker
# and docker-compose-dev.yaml
try:
DEVELOPMENT_MODE = os.environ.get("DEVELOPMENT_MODE")
if DEVELOPMENT_MODE:
# keep a dev_settings.py file for local overrides
from .dev_settings import * # noqa
except ImportError:
pass
LANGUAGES = [
('ar', _('Arabic')),
('bn', _('Bengali')),
@@ -531,3 +471,76 @@ LANGUAGES = [
]
LANGUAGE_CODE = 'en' # default language
SPRITE_NUM_SECS = 10
# number of seconds for sprite image.
# If you plan to change this, you must also follow the instructions on admin_docs.md
# to change the equivalent value in ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and then re-build frontend
# how many images will be shown on the slideshow
SLIDESHOW_ITEMS = 30
# this calculation is redundant most probably, setting as an option
CALCULATE_MD5SUM = False
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# allow option to override the default admin url
# keep the trailing slash
DJANGO_ADMIN_URL = "admin/"
# this are used around a number of places and will need to be well documented!!!
USE_SAML = False
USE_RBAC = False
USE_IDENTITY_PROVIDERS = False
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
# ALLOWED_HOSTS needs a url/ip
ALLOWED_HOSTS.append(FRONTEND_HOST.replace("http://", "").replace("https://", ""))
except ImportError:
# local_settings not in use
pass
if "http" not in FRONTEND_HOST:
# FRONTEND_HOST needs a http:// preffix
FRONTEND_HOST = f"http://{FRONTEND_HOST}" # noqa
if LOCAL_INSTALL:
SSL_FRONTEND_HOST = FRONTEND_HOST.replace("http", "https")
else:
SSL_FRONTEND_HOST = FRONTEND_HOST
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True
PYSUBS_COMMAND = "pysubs2"
# the following is related to local development using docker
# and docker-compose-dev.yaml
try:
DEVELOPMENT_MODE = os.environ.get("DEVELOPMENT_MODE")
if DEVELOPMENT_MODE:
# keep a dev_settings.py file for local overrides
from .dev_settings import * # noqa
except ImportError:
pass
if GLOBAL_LOGIN_REQUIRED:
# this should go after the AuthenticationMiddleware middleware
MIDDLEWARE.insert(6, "login_required.middleware.LoginRequiredMiddleware")
LOGIN_REQUIRED_IGNORE_PATHS = [
r'/accounts/login/$',
r'/accounts/logout/$',
r'/accounts/signup/$',
r'/accounts/password/.*/$',
r'/accounts/confirm-email/.*/$',
# r'/api/v[0-9]+/',
]

View File

@@ -1,4 +1,5 @@
import debug_toolbar
from django.conf import settings
from django.conf.urls import include
from django.contrib import admin
from django.urls import path, re_path
@@ -25,8 +26,12 @@ urlpatterns = [
re_path(r"^", include("users.urls")),
re_path(r"^accounts/", include("allauth.urls")),
re_path(r"^api-auth/", include("rest_framework.urls")),
path("admin/", admin.site.urls),
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
]
admin.site.site_header = "MediaCMS Admin"
admin.site.site_title = "MediaCMS"
admin.site.index_title = "Admin"

1
cms/version.py Normal file
View File

@@ -0,0 +1 @@
VERSION = "5.0.1"

View File

@@ -1,5 +0,0 @@
from pytest_factoryboy import register
from tests.users.factories import UserFactory
register(UserFactory)

75
deic_setup_notes.md Normal file
View File

@@ -0,0 +1,75 @@
# MediaCMS: Document Changes for DEIC
## Configuration Changes
The following changes are required in `deploy/docker/local_settings.py`:
```python
# default workflow
PORTAL_WORKFLOW = 'private'
# Authentication Settings
# these two are necessary so that users cannot register through system accounts. They can only register through identity providers
REGISTER_ALLOWED = False
USERS_CAN_SELF_REGISTER = False
USE_RBAC = True
USE_SAML = True
USE_IDENTITY_PROVIDERS = True
# Proxy and SSL Settings
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# SAML Configuration
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
ACCOUNT_USERNAME_VALIDATORS = "users.validators.less_restrictive_username_validators"
SOCIALACCOUNT_PROVIDERS = {
"saml": {
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
}
}
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_EMAIL_REQUIRED = False
# if set to strict, user is created with the email from the saml provider without
# checking if the email is already on the system
# however if this is ommited, and user tries to login with an email that already exists on
# the system, then they get to the ugly form where it suggests they add a username/email/name
ACCOUNT_PREVENT_ENUMERATION = 'strict'
```
## SAML Configuration Steps
### Step 1: Add SAML Identity Provider
1. Navigate to Admin panel
2. Select "Identity Provider"
3. Configure as follows:
- **Provider**: saml # ensure this is set with lower case!
- **Provider ID**: `wayf.wayf.dk`
- **IDP Config Name**: `Deic` (or preferred name)
- **Client ID**: `wayf_dk` (important: defines the URL, e.g., `https://deic.mediacms.io/accounts/saml/wayf_dk`)
- **Site**: Set the default one
### Step 2: Add SAML Configuration
Can be set through the SAML Configurations tab:
1. **IDP ID**: Must be a URL, e.g., `https://wayf.wayf.dk`
2. **IDP Certificate**: x509cert from your SAML provider
3. **SSO URL**: `https://wayf.wayf.dk/saml2/idp/SSOService2.php`
4. **SLO URL**: `https://wayf.wayf.dk/saml2/idp/SingleLogoutService.php`
5. **SP Metadata URL**: The metadata URL set for the SP, e.g., `https://deic.mediacms.io/saml/metadata`. This should point to the URL of the SP and is autogenerated
### Step 3: Set the other Options
1. **Email Settings**:
- `verified_email`: When enabled, emails from SAML responses will be marked as verified
- `Remove from groups`: When enabled, user is removed from a group after login, if they have been removed from the group on the IDP
2. **Global Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in MediaCMS
3. **Group Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in groups that user will be added
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS

View File

@@ -7,6 +7,7 @@ ln -sf /dev/stdout /var/log/nginx/mediacms.io.access.log && ln -sf /dev/stderr /
cp /home/mediacms.io/mediacms/deploy/docker/local_settings.py /home/mediacms.io/mediacms/cms/local_settings.py
mkdir -p /home/mediacms.io/mediacms/{logs,media_files/hls}
touch /home/mediacms.io/mediacms/logs/debug.log

View File

@@ -1,17 +1,18 @@
FRONTEND_HOST = 'http://localhost'
PORTAL_NAME = 'MediaCMS'
SECRET_KEY = 'ma!s3^b-cw!f#7s6s0m3*jx77a@riw(7701**(r=ww%w!2+yk2'
POSTGRES_HOST = 'db'
REDIS_LOCATION = "redis://redis:6379/1"
import os
FRONTEND_HOST = os.getenv('FRONTEND_HOST', 'http://localhost')
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')
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mediacms",
"HOST": POSTGRES_HOST,
"PORT": "5432",
"USER": "mediacms",
"PASSWORD": "mediacms",
"NAME": os.getenv('POSTGRES_NAME', 'mediacms'),
"HOST": os.getenv('POSTGRES_HOST', 'db'),
"PORT": os.getenv('POSTGRES_PORT', '5432'),
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
}
}
@@ -31,4 +32,4 @@ CELERY_RESULT_BACKEND = BROKER_URL
MP4HLS_COMMAND = "/home/mediacms.io/bento4/bin/mp4hls"
DEBUG = False
DEBUG = os.getenv('DEBUG', 'False') == 'True'

99
deploy/docker/policy.xml Normal file
View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)*>
<!ATTLIST policymap xmlns CDATA #FIXED ''>
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
]>
<!--
Configure ImageMagick policies.
Domains include system, delegate, coder, filter, path, or resource.
Rights include none, read, write, execute and all. Use | to combine them,
for example: "read | write" to permit read from, or write to, a path.
Use a glob expression as a pattern.
Suppose we do not want users to process MPEG video images:
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
Here we do not want users reading images from HTTP:
<policy domain="coder" rights="none" pattern="HTTP" />
The /repository file system is restricted to read only. We use a glob
expression to match all paths that start with /repository:
<policy domain="path" rights="read" pattern="/repository/*" />
Lets prevent users from executing any image filters:
<policy domain="filter" rights="none" pattern="*" />
Any large image is cached to disk rather than memory:
<policy domain="resource" name="area" value="1GP"/>
Use the default system font unless overwridden by the application:
<policy domain="system" name="font" value="/usr/share/fonts/favorite.ttf"/>
Define arguments for the memory, map, area, width, height and disk resources
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
exceeds policy maximum so memory limit is 1GB).
Rules are processed in order. Here we want to restrict ImageMagick to only
read or write a small subset of proven web-safe image types:
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="filter" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
-->
<policymap>
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
<policy domain="resource" name="memory" value="1GiB"/>
<policy domain="resource" name="map" value="30GiB"/>
<policy domain="resource" name="width" value="16MP"/>
<policy domain="resource" name="height" value="16MP"/>
<!-- <policy domain="resource" name="list-length" value="128"/> -->
<policy domain="resource" name="area" value="40GP"/>
<policy domain="resource" name="disk" value="100GiB"/>
<!-- <policy domain="resource" name="file" value="768"/> -->
<!-- <policy domain="resource" name="thread" value="4"/> -->
<!-- <policy domain="resource" name="throttle" value="0"/> -->
<!-- <policy domain="resource" name="time" value="3600"/> -->
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/>
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- <policy domain="system" name="font" value="/path/to/font.ttf"/> -->
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
<!-- <policy domain="system" name="shred" value="2"/> -->
<!-- <policy domain="system" name="precision" value="6"/> -->
<!-- not needed due to the need to use explicitly by mvg: -->
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
<!-- use curl -->
<policy domain="delegate" rights="none" pattern="URL" />
<policy domain="delegate" rights="none" pattern="HTTPS" />
<policy domain="delegate" rights="none" pattern="HTTP" />
<!-- in order to avoid to get image with password text -->
<policy domain="path" rights="none" pattern="@*"/>
<!-- disable ghostscript format types -->
<policy domain="coder" rights="none" pattern="PS" />
<policy domain="coder" rights="none" pattern="PS2" />
<policy domain="coder" rights="none" pattern="PS3" />
<policy domain="coder" rights="none" pattern="EPS" />
<policy domain="coder" rights="none" pattern="PDF" />
<policy domain="coder" rights="none" pattern="XPS" />
</policymap>

View File

@@ -49,7 +49,7 @@ server {
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve secp521r1:secp384r1;
ssl_prefer_server_ciphers on;

View File

@@ -4,13 +4,23 @@ services:
migrations:
build:
context: .
dockerfile: ./Dockerfile-dev
dockerfile: ./Dockerfile
args:
- DEVELOPMENT_MODE=True
image: mediacms/mediacms-dev:latest
volumes:
- ./:/home/mediacms.io/mediacms/
command: "python manage.py migrate"
command: "./deploy/docker/prestart.sh"
environment:
DEVELOPMENT_MODE: "True"
DEVELOPMENT_MODE: True
ENABLE_UWSGI: 'no'
ENABLE_NGINX: 'no'
ENABLE_CELERY_SHORT: 'no'
ENABLE_CELERY_LONG: 'no'
ENABLE_CELERY_BEAT: 'no'
ADMIN_USER: 'admin'
ADMIN_EMAIL: 'admin@localhost'
ADMIN_PASSWORD: 'admin'
restart: on-failure
depends_on:
redis:
@@ -18,7 +28,7 @@ services:
db:
condition: service_healthy
frontend:
image: node:14
image: node:20
volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
working_dir: /home/mediacms.io/mediacms/frontend/
@@ -30,16 +40,10 @@ services:
depends_on:
- web
web:
build:
context: .
dockerfile: ./Dockerfile-dev
image: mediacms/mediacms-dev:latest
command: "python manage.py runserver 0.0.0.0:80"
environment:
DEVELOPMENT_MODE: "True"
ADMIN_USER: 'admin'
ADMIN_PASSWORD: 'admin'
ADMIN_EMAIL: 'admin@localhost'
DEVELOPMENT_MODE: True
ports:
- "80:80"
volumes:
@@ -47,7 +51,7 @@ services:
depends_on:
- migrations
db:
image: postgres:15.2-alpine
image: postgres:17.2-alpine
volumes:
- ../postgres_data:/var/lib/postgresql/data/
restart: always
@@ -80,5 +84,6 @@ services:
ENABLE_NGINX: 'no'
ENABLE_CELERY_BEAT: 'no'
ENABLE_MIGRATIONS: 'no'
DEVELOPMENT_MODE: True
depends_on:
- web

View File

@@ -13,7 +13,7 @@ services:
ENABLE_CELERY_BEAT: 'no'
ADMIN_USER: 'admin'
ADMIN_EMAIL: 'admin@localhost'
#ADMIN_PASSWORD: 'uncomment_and_set_password_here'
# ADMIN_PASSWORD: 'uncomment_and_set_password_here'
command: "./deploy/docker/prestart.sh"
restart: on-failure
depends_on:
@@ -62,7 +62,7 @@ services:
depends_on:
- migrations
db:
image: postgres:15.2-alpine
image: postgres:17.2-alpine
volumes:
- ../postgres_data:/var/lib/postgresql/data/
restart: always

View File

@@ -0,0 +1,144 @@
services:
migrations:
platform: linux/amd64
build:
context: .
dockerfile: ./Dockerfile
args:
- DEVELOPMENT_MODE=True
image: mediacms/mediacms:latest
volumes:
- ./:/home/mediacms.io/mediacms/
command: "./deploy/docker/prestart.sh"
environment:
DEVELOPMENT_MODE: True
ENABLE_UWSGI: 'no'
ENABLE_NGINX: 'no'
ENABLE_CELERY_SHORT: 'no'
ENABLE_CELERY_LONG: 'no'
ENABLE_CELERY_BEAT: 'no'
ADMIN_USER: 'admin'
ADMIN_EMAIL: 'admin@localhost'
ADMIN_PASSWORD: 'admin'
restart: on-failure
depends_on:
redis:
condition: service_healthy
db:
condition: service_healthy
frontend:
image: node:20
user: "root"
volumes:
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
- npm_global:/home/node/.npm-global
working_dir: /home/mediacms.io/mediacms/frontend/
command: >
bash -c "
echo 'Setting up npm global directory...' &&
mkdir -p /home/node/.npm-global &&
chown -R node:node /home/node/.npm-global &&
echo 'Setting up permissions...' &&
chown -R node:node /home/mediacms.io/mediacms/frontend &&
echo 'Cleaning up node_modules...' &&
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
echo 'Switching to node user...' &&
su node -c '
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
echo \"Setting up frontend...\" &&
rm -f package-lock.json &&
rm -f packages/player/package-lock.json &&
rm -f packages/scripts/package-lock.json &&
echo \"Installing dependencies...\" &&
npm install --legacy-peer-deps &&
echo \"Setting up workspaces...\" &&
npm install -g npm@latest &&
cd packages/scripts &&
npm install --legacy-peer-deps &&
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
npm install tslib@2.6.2 --save --legacy-peer-deps &&
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
npm install --legacy-peer-deps &&
npm run build &&
cd ../.. &&
cd packages/player &&
npm install --legacy-peer-deps &&
npm run build &&
cd ../.. &&
echo \"Starting development server...\" &&
npm run start
'"
env_file:
- ${PWD}/frontend/.env
environment:
- NPM_CONFIG_PREFIX=/home/node/.npm-global
ports:
- "8088:8088"
depends_on:
- web
restart: unless-stopped
web:
platform: linux/amd64
image: mediacms/mediacms:latest
command: "python manage.py runserver 0.0.0.0:80"
environment:
DEVELOPMENT_MODE: True
ports:
- "80:80"
volumes:
- ./:/home/mediacms.io/mediacms/
depends_on:
- migrations
db:
image: postgres:17.2-alpine
volumes:
- ./postgres_data:/var/lib/postgresql/data/
restart: always
environment:
POSTGRES_USER: mediacms
POSTGRES_PASSWORD: mediacms
POSTGRES_DB: mediacms
TZ: Europe/London
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}", "--host=db", "--dbname=$POSTGRES_DB", "--username=$POSTGRES_USER"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: "redis:alpine"
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
celery_worker:
platform: linux/amd64
image: mediacms/mediacms:latest
deploy:
replicas: 1
volumes:
- ./:/home/mediacms.io/mediacms/
environment:
ENABLE_UWSGI: 'no'
ENABLE_NGINX: 'no'
ENABLE_CELERY_BEAT: 'no'
ENABLE_MIGRATIONS: 'no'
DEVELOPMENT_MODE: True
depends_on:
- web
volumes:
frontend_node_modules:
player_node_modules:
scripts_node_modules:
npm_global:

View File

@@ -68,7 +68,7 @@ services:
depends_on:
- migrations
db:
image: postgres:15.2-alpine
image: postgres:17.2-alpine
volumes:
- ../postgres_data/:/var/lib/postgresql/data/
restart: always

View File

@@ -70,7 +70,7 @@ services:
depends_on:
- migrations
db:
image: postgres:15.2-alpine
image: postgres:17.2-alpine
volumes:
- ../postgres_data/:/var/lib/postgresql/data/
restart: always

View File

@@ -90,7 +90,7 @@ services:
depends_on:
- migrations
db:
image: postgres:15.2-alpine
image: postgres:17.2-alpine
volumes:
- ../postgres_data:/var/lib/postgresql/data/
restart: always

View File

@@ -66,7 +66,7 @@ services:
depends_on:
- migrations
db:
image: postgres:15.2-alpine
image: postgres:17.2-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
restart: always

View File

@@ -21,6 +21,12 @@
- [18. Disable encoding and show only original file](#18-disable-encoding-and-show-only-original-file)
- [19. Rounded corners on videos](#19-rounded-corners)
- [20. Translations](#20-translations)
- [21. How to change the video frames on videos](#21-how-to-change-the-video-frames-on-videos)
- [22. Role-Based Access Control](#22-role-based-access-control)
- [23. SAML setup](#23-saml-setup)
- [24. Identity Providers setup](#24-identity-providers-setup)
## 1. Welcome
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
@@ -354,13 +360,22 @@ ADMIN_EMAIL_LIST = ['info@mediacms.io']
### 5.13 Disallow user registrations from specific domains
set domains that are not valid for registration via this variable:
Set domains that are not valid for registration via this variable:
```
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = [
'xxx.com', 'emaildomainwhatever.com']
```
Alternatively, allow only permitted domains to register. This can be useful if you're using mediacms as a private service within an organization, and want to give free registration for those in the org, but deny registration from all other domains. Setting this option bans all domains NOT in the list from registering. Default is a blank list, which is ignored. To disable, set to a blank list.
```
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = [
"private.com",
"vod.private.com",
"my.favorite.domain",
"test.private.com"]
```
### 5.14 Require a review by MediaCMS editors/managers/admins
set value
@@ -833,10 +848,128 @@ After the string is marked as translatable, add the string to `files/frontend-tr
python manage.py process_translations
```
In order to populate the string in all the languages. NO PR will be accepted if this procedure is not followed. You don't have to translate the string to all supported languages, but the command has to run and populate the existing dictionaries with the new strings for all languages. This ensures that there is no missing string to be translated in any language.
In order to populate the string in all the languages. NO PR will be accepted if this procedure is not followed. You don't have to translate the string to all supported languages, but the command has to run and populate the existing dictionaries with the new strings for all languages. This ensures that there is no missing string to be translated in any language.
After this command is run, translate the string to the language you want. If the string to be translated lives in Django templates, you don't have to re-build the frontend. If the change lives in the frontend, you will have to re-build in order to see the changes. The Makefile command `make build-frontend` can help with this.
### 20.5 Add a new language and translate
To add a new language: add the language in settings.py, then add the file in `files/frontend-translations/`. Make sure you copy the initial strings by copying `files/frontend-translations/en.py` to it.
To add a new language: add the language in settings.py, then add the file in `files/frontend-translations/`. Make sure you copy the initial strings by copying `files/frontend-translations/en.py` to it.
## 21. How to change the video frames on videos
By default while watching a video you can hover and see the small images named sprites that are extracted every 10 seconds of a video. You can change this number to something smaller by performing the following:
* edit ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and change `seconds: 10 ` to the value you prefer, eg 2.
* edit settings.py and set the same number for value SPRITE_NUM_SECS
* now you have to re-build the frontend: the easiest way is to run `make build-frontend`, which requires Docker
After that, newly uploaded videos will have sprites generated with the new number of seconds.
## 22. Role-Based Access Control
By default there are 3 statuses for any Media that lives on the system, public, unlisted, private. When RBAC support is added, a user that is part of a group has access to media that are published to one or more categories that the group is associated with. The workflow is this:
1. A Group is created
2. A Category is associated with the Group
3. A User is added to the Group
Now user can view the Media even if it is in private state. User also sees all media in Category page
When user is added to group, they can be set as Member, Contributor, Manager.
- Member: user can view media that are published on one or more categories that this group is associated with
- Contributor: besides viewing, user can also edit the Media in a category associated with this Group. They can also publish Media to this category
- Manager: same as Contributor for now
Use cases facilitated with RBAC:
- viewing a Media in private state: if RBAC is enabled, if user is Member on a Group that is associated with a Category, and the media is published to this Category, then user can view the media
- editing a Media: if RBAC is enabled, and user is Contributor to one or more Categories, they can publish media to these Categories as long as they are associated with one Group
- viewing all media of a category: if RBAC is enabled, and user visits a Category, they are able to see the listing of all media that are published in this category, independent of their state, provided that the category is associated with a group that the user is member of
- viewing all categories associated with groups the user is member of: if RBAC is enabled, and user visits the listing of categories, they can view all categories that are associated with a group the user is member
How to enable RBAC support:
```
USE_RBAC = True
```
on `local_settings.py` and restart the instance.
## 23. SAML setup
SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups.
To enable SAML support, edit local_settings.py and set the following options:
```
USE_RBAC = True
USE_SAML = True
USE_IDENTITY_PROVIDERS = True
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
SOCIALACCOUNT_PROVIDERS = {
"saml": {
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
}
}
```
To set a SAML provider:
- Step 1: Add SAML Identity Provider
1. Navigate to Admin panel
2. Select "Identity Provider"
3. Configure as follows:
- **Provider**: saml
- **Provider ID**: an ID for the provider
- **IDP Config Name**: a name for the provider
- **Client ID**: the identifier that is part of the login, and that is shared with the IDP.
- **Site**: Set the default one
- Step 2: Add SAML Configuration
Select the SAML Configurations tab, create a new one and set:
1. **IDP ID**: Must be a URL
2. **IDP Certificate**: x509cert from your SAML provider
3. **SSO 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
- Step 3: Set other Options
1. **Email Settings**:
- `verified_email`: When enabled, emails from SAML responses will be marked as verified
- `Remove from groups`: When enabled, user is removed from a group after login, if they have been removed from the group on the IDP
2. **Global Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in MediaCMS
3. **Group Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in groups that user will be added
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS
## 24. Identity Providers setup
A separate Django app identity_providers has been added in order to facilitate a number of configurations related to different identity providers. If this is enabled, it gives the following options:
- allows to add an Identity Provider through Django admin, and set a number of mappings, as Group Mapping, Global Role mapping and more. While SAML is the only provider that can be added out of the box, any identity provider supported by django allauth can be added with minimal effort. If the response of the identity provider contains attributes as role, or groups, then these can be mapped to MediaCMS specific roles (advanced user, editor, manager, admin) and groups (rbac groups)
- saves SAML response logs after user is authenticated (can be utilized for other providers too)
- allows to specify a list of login options through the admin (eg system login, identity provider login)
to enable the identity providers, set the following setting on `local_settings.py`:
```
USE_IDENTITY_PROVIDERS = True
```
Visiting the admin, you will see the Identity Providers tab and you can add one.

View File

@@ -1,6 +1,21 @@
from django import forms
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db import transaction
from .models import Category, Comment, EncodeProfile, Encoding, Language, Media, Subtitle, Tag
from rbac.models import RBACGroup
from .models import (
Category,
Comment,
EncodeProfile,
Encoding,
Language,
Media,
Subtitle,
Tag,
)
class CommentAdmin(admin.ModelAdmin):
@@ -40,12 +55,126 @@ class MediaAdmin(admin.ModelAdmin):
get_comments_count.short_description = "Comments count"
class CategoryAdminForm(forms.ModelForm):
rbac_groups = forms.ModelMultipleChoiceField(queryset=RBACGroup.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Groups', False))
class Meta:
model = Category
fields = '__all__'
def clean(self):
cleaned_data = super().clean()
is_rbac_category = cleaned_data.get('is_rbac_category')
identity_provider = cleaned_data.get('identity_provider')
# Check if this category has any RBAC groups
if self.instance.pk:
has_rbac_groups = cleaned_data.get('rbac_groups')
else:
has_rbac_groups = False
if not is_rbac_category:
if has_rbac_groups:
cleaned_data['is_rbac_category'] = True
# self.add_error('is_rbac_category', ValidationError('This category has RBAC groups assigned. "Is RBAC Category" must be enabled.'))
for rbac_group in cleaned_data.get('rbac_groups'):
if rbac_group.identity_provider != identity_provider:
self.add_error('rbac_groups', ValidationError('Chosen Groups are associated with a different Identity Provider than the one selected here.'))
return cleaned_data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields['rbac_groups'].initial = self.instance.rbac_groups.all()
def save(self, commit=True):
category = super().save(commit=True)
if commit:
self.save_m2m()
if self.instance.rbac_groups.exists() or self.cleaned_data.get('rbac_groups'):
if not self.cleaned_data['is_rbac_category']:
category.is_rbac_category = True
category.save(update_fields=['is_rbac_category'])
return category
@transaction.atomic
def save_m2m(self):
if self.instance.pk:
rbac_groups = self.cleaned_data['rbac_groups']
self._update_rbac_groups(rbac_groups)
def _update_rbac_groups(self, rbac_groups):
new_rbac_group_ids = RBACGroup.objects.filter(pk__in=rbac_groups).values_list('pk', flat=True)
existing_rbac_groups = RBACGroup.objects.filter(categories=self.instance)
existing_rbac_groups_ids = existing_rbac_groups.values_list('pk', flat=True)
rbac_groups_to_add = RBACGroup.objects.filter(pk__in=new_rbac_group_ids).exclude(pk__in=existing_rbac_groups_ids)
rbac_groups_to_remove = existing_rbac_groups.exclude(pk__in=new_rbac_group_ids)
for rbac_group in rbac_groups_to_add:
rbac_group.categories.add(self.instance)
for rbac_group in rbac_groups_to_remove:
rbac_group.categories.remove(self.instance)
class CategoryAdmin(admin.ModelAdmin):
search_fields = ["title"]
list_display = ["title", "user", "add_date", "is_global", "media_count"]
list_filter = ["is_global"]
form = CategoryAdminForm
search_fields = ["title", "uid"]
list_display = ["title", "user", "add_date", "media_count"]
list_filter = []
ordering = ("-add_date",)
readonly_fields = ("user", "media_count")
change_form_template = 'admin/files/category/change_form.html'
def get_list_filter(self, request):
list_filter = list(self.list_filter)
if getattr(settings, 'USE_RBAC', False):
list_filter.insert(0, "is_rbac_category")
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_filter.insert(-1, "identity_provider")
return list_filter
def get_list_display(self, request):
list_display = list(self.list_display)
if getattr(settings, 'USE_RBAC', False):
list_display.insert(-1, "is_rbac_category")
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_display.insert(-1, "identity_provider")
return list_display
def get_fieldsets(self, request, obj=None):
basic_fieldset = [
(
'Category Information',
{
'fields': ['uid', 'title', 'description', 'user', 'media_count', 'thumbnail', 'listings_thumbnail'],
},
),
]
if getattr(settings, 'USE_RBAC', False):
rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
]
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
]
return basic_fieldset + rbac_fieldset
else:
return basic_fieldset
class TagAdmin(admin.ModelAdmin):
@@ -93,3 +222,5 @@ admin.site.register(Category, CategoryAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(Subtitle, SubtitleAdmin)
admin.site.register(Language, LanguageAdmin)
Media._meta.app_config.verbose_name = "Media"

View File

@@ -15,7 +15,7 @@ class VideoEncodingError(Exception):
RE_TIMECODE = re.compile(r"time=(\d+:\d+:\d+.\d+)")
console_encoding = locale.getdefaultlocale()[1] or "UTF-8"
console_encoding = locale.getlocale()[1] or "UTF-8"
class FFmpegBackend(object):

View File

@@ -34,5 +34,8 @@ def stuff(request):
ret["RSS_URL"] = "/rss"
ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE)
ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE)
ret["USE_SAML"] = settings.USE_SAML
if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
return ret

View File

@@ -1,7 +1,8 @@
from django import forms
from django.conf import settings
from .methods import get_next_state, is_mediacms_editor
from .models import Media, Subtitle
from .models import Category, Media, Subtitle
class MultipleSelect(forms.CheckboxSelectMultiple):
@@ -41,6 +42,25 @@ class MediaForm(forms.ModelForm):
self.fields.pop("featured")
self.fields.pop("reported_times")
self.fields.pop("is_reviewed")
# if settings.PORTAL_WORKFLOW == 'private':
# self.fields.pop("state")
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user):
pass
else:
self.fields['category'].initial = self.instance.category.all()
non_rbac_categories = Category.objects.filter(is_rbac_category=False)
rbac_categories = user.get_rbac_categories_as_contributor()
combined_category_ids = list(non_rbac_categories.values_list('id', flat=True)) + list(rbac_categories.values_list('id', flat=True))
if self.instance.pk:
instance_category_ids = list(self.instance.category.all().values_list('id', flat=True))
combined_category_ids = list(set(combined_category_ids + instance_category_ids))
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
def clean_uploaded_poster(self):
@@ -68,6 +88,8 @@ class SubtitleForm(forms.ModelForm):
def __init__(self, media_item, *args, **kwargs):
super(SubtitleForm, self).__init__(*args, **kwargs)
self.instance.media = media_item
self.fields["subtitle_file"].help_text = "SubRip (.srt) and WebVTT (.vtt) are supported file formats."
self.fields["subtitle_file"].label = "Subtitle or Closed Caption File"
def save(self, *args, **kwargs):
self.instance.user = self.instance.media.user
@@ -75,6 +97,14 @@ class SubtitleForm(forms.ModelForm):
return media
class EditSubtitleForm(forms.Form):
subtitle = forms.CharField(widget=forms.Textarea, required=True)
def __init__(self, subtitle, *args, **kwargs):
super(EditSubtitleForm, self).__init__(*args, **kwargs)
self.fields["subtitle"].initial = subtitle.subtitle_file.read().decode("utf-8")
class ContactForm(forms.Form):
from_email = forms.EmailField(required=True)
name = forms.CharField(required=False)

View File

@@ -32,7 +32,6 @@ for translation_file in files:
replacement_strings[language_code] = tr_module.replacement_strings
def get_translation(language_code):
# get list of translations per language
if not check_language_code(language_code):

View File

@@ -18,7 +18,10 @@ class Command(BaseCommand):
files = os.listdir(translations_dir)
files = [f for f in files if f.endswith('.py') and f not in ('__init__.py', 'en.py')]
# Import the original English translations
from files.frontend_translations.en import replacement_strings, translation_strings
from files.frontend_translations.en import (
replacement_strings,
translation_strings,
)
for file in files:
file_path = os.path.join(translations_dir, file)
@@ -44,12 +47,12 @@ class Command(BaseCommand):
with open(file_path, 'w') as f:
f.write("translation_strings = {\n")
for key, value in translation_strings_wip.items():
f.write(f' "{key}": "{value}",\n')
f.write(f' "{key}": "{value}",\n') # noqa
f.write("}\n\n")
f.write("replacement_strings = {\n")
for key, value in replacement_strings_wip.items():
f.write(f' "{key}": "{value}",\n')
f.write(f' "{key}": "{value}",\n') # noqa
f.write("}\n")
self.stdout.write(self.style.SUCCESS(f'Processed {file}'))

View File

@@ -119,12 +119,16 @@ def get_next_state(user, current_state, next_state):
if next_state not in ["public", "private", "unlisted"]:
next_state = settings.PORTAL_WORKFLOW # get default state
if is_mediacms_editor(user):
# allow any transition
return next_state
if settings.PORTAL_WORKFLOW == "private":
next_state = "private"
if next_state in ["private", "unlisted"]:
next_state = next_state
else:
next_state = current_state
if settings.PORTAL_WORKFLOW == "unlisted":
# don't allow to make media public in this case

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0003_auto_20210927_1245'),
('socialaccount', '0006_alter_socialaccount_extra_data'),
]
operations = [
migrations.AlterModelOptions(
name='subtitle',
options={'ordering': ['language__title']},
),
migrations.AddField(
model_name='category',
name='identity_provider',
field=models.ForeignKey(
blank=True,
help_text='If category is related with a specific Identity Provider',
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='categories',
to='socialaccount.socialapp',
verbose_name='IDP Config Name',
),
),
migrations.AddField(
model_name='category',
name='is_rbac_category',
field=models.BooleanField(db_index=True, default=False, help_text='If access to Category is controlled by role based membership of Groups'),
),
migrations.AlterField(
model_name='media',
name='state',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='private', help_text='state of Media', max_length=20),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-25 14:13
from django.db import migrations, models
import files.models
class Migration(migrations.Migration):
dependencies = [
('files', '0004_alter_subtitle_options_category_identity_provider_and_more'),
]
operations = [
migrations.AlterField(
model_name='category',
name='uid',
field=models.CharField(default=files.models.generate_uid, max_length=36, unique=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-03-27 09:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0005_alter_category_uid'),
]
operations = [
migrations.AlterField(
model_name='category',
name='title',
field=models.CharField(db_index=True, max_length=100),
),
]

View File

@@ -18,6 +18,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.html import strip_tags
from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFit
@@ -83,6 +84,10 @@ ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS]
ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS]
def generate_uid():
return get_random_string(length=16)
def original_media_file_path(instance, filename):
"""Helper function to place original media file"""
file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename))
@@ -780,6 +785,36 @@ class Media(models.Model):
return helpers.url_from_path(self.poster.path)
return None
@property
def slideshow_items(self):
slideshow_items = getattr(settings, "SLIDESHOW_ITEMS", 30)
if self.media_type != "image":
items = []
else:
qs = Media.objects.filter(listable=True, user=self.user, media_type="image").exclude(id=self.id).order_by('id')[:slideshow_items]
items = [
{
"poster_url": item.poster_url,
"url": item.get_absolute_url(),
"thumbnail_url": item.thumbnail_url,
"title": item.title,
"original_media_url": item.original_media_url,
}
for item in qs
]
items.insert(
0,
{
"poster_url": self.poster_url,
"url": self.get_absolute_url(),
"thumbnail_url": self.thumbnail_url,
"title": self.title,
"original_media_url": self.original_media_url,
},
)
return items
@property
def subtitles_info(self):
"""Property used on serializers
@@ -787,7 +822,9 @@ class Media(models.Model):
"""
ret = []
for subtitle in self.subtitles.all():
# Retrieve all subtitles and sort by the first letter of their associated language's title
sorted_subtitles = sorted(self.subtitles.all(), key=lambda s: s.language.title[0])
for subtitle in sorted_subtitles:
ret.append(
{
"src": helpers.url_from_path(subtitle.subtitle_file.path),
@@ -925,11 +962,11 @@ class License(models.Model):
class Category(models.Model):
"""A Category base model"""
uid = models.UUIDField(unique=True, default=uuid.uuid4)
uid = models.CharField(unique=True, max_length=36, default=generate_uid)
add_date = models.DateTimeField(auto_now_add=True)
title = models.CharField(max_length=100, unique=True, db_index=True)
title = models.CharField(max_length=100, db_index=True)
description = models.TextField(blank=True)
@@ -949,6 +986,18 @@ class Category(models.Model):
listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings")
is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups')
identity_provider = models.ForeignKey(
'socialaccount.SocialApp',
blank=True,
null=True,
on_delete=models.CASCADE,
related_name='categories',
help_text='If category is related with a specific Identity Provider',
verbose_name='IDP Config Name',
)
def __str__(self):
return self.title
@@ -962,7 +1011,11 @@ class Category(models.Model):
def update_category_media(self):
"""Set media_count"""
self.media_count = Media.objects.filter(listable=True, category=self).count()
if getattr(settings, 'USE_RBAC', False) and self.is_rbac_category:
self.media_count = Media.objects.filter(category=self).count()
else:
self.media_count = Media.objects.filter(listable=True, category=self).count()
self.save(update_fields=["media_count"])
return True
@@ -1178,9 +1231,36 @@ class Subtitle(models.Model):
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
class Meta:
ordering = ["language__title"]
def __str__(self):
return "{0}-{1}".format(self.media.title, self.language.title)
def get_absolute_url(self):
return f"{reverse('edit_subtitle')}?id={self.id}"
@property
def url(self):
return self.get_absolute_url()
def convert_to_srt(self):
input_path = self.subtitle_file.path
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
pysub = settings.PYSUBS_COMMAND
cmd = [pysub, input_path, "--to", "vtt", "-o", tmpdirname]
stdout = helpers.run_command(cmd)
list_of_files = os.listdir(tmpdirname)
if list_of_files:
subtitles_file = os.path.join(tmpdirname, list_of_files[0])
cmd = ["cp", subtitles_file, input_path]
stdout = helpers.run_command(cmd) # noqa
else:
raise Exception("Could not convert to srt")
return True
class RatingCategory(models.Model):
"""Rating Category
@@ -1253,7 +1333,7 @@ class Playlist(models.Model):
@property
def media_count(self):
return self.media.count()
return self.media.filter(listable=True).count()
def get_absolute_url(self, api=False):
if api:
@@ -1300,7 +1380,7 @@ class Playlist(models.Model):
@property
def thumbnail_url(self):
pm = self.playlistmedia_set.first()
pm = self.playlistmedia_set.filter(media__listable=True).first()
if pm and pm.media.thumbnail:
return helpers.url_from_path(pm.media.thumbnail.path)
return None

View File

@@ -1,5 +1,7 @@
from django.conf import settings
from rest_framework import serializers
from .methods import is_mediacms_editor
from .models import Category, Comment, EncodeProfile, Media, Playlist, Tag
# TODO: put them in a more DRY way
@@ -76,8 +78,25 @@ class MediaSerializer(serializers.ModelSerializer):
"featured",
"user_featured",
"size",
# "category",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = self.context.get('request')
if False and request and 'category' in self.fields:
# this is not working
user = request.user
if is_mediacms_editor(user):
pass
else:
if getattr(settings, 'USE_RBAC', False):
# Filter category queryset based on user permissions
non_rbac_categories = Category.objects.filter(is_rbac_category=False)
rbac_categories = user.get_rbac_categories_as_contributor()
self.fields['category'].queryset = non_rbac_categories.union(rbac_categories)
class SingleMediaSerializer(serializers.ModelSerializer):
user = serializers.ReadOnlyField(source="user.username")
@@ -145,6 +164,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"ratings_info",
"add_subtitle_url",
"allow_download",
"slideshow_items",
)

View File

@@ -23,7 +23,17 @@ from users.models import User
from .backends import FFmpegBackend
from .exceptions import VideoEncodingError
from .helpers import calculate_seconds, create_temp_file, get_file_name, get_file_type, media_file_info, produce_ffmpeg_commands, produce_friendly_token, rm_file, run_command
from .helpers import (
calculate_seconds,
create_temp_file,
get_file_name,
get_file_type,
media_file_info,
produce_ffmpeg_commands,
produce_friendly_token,
rm_file,
run_command,
)
from .methods import list_tasks, notify_users, pre_save_action
from .models import Category, EncodeProfile, Encoding, Media, Rating, Tag
@@ -38,7 +48,7 @@ ERRORS_LIST = [
]
@task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30)
@task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30 * 4)
def chunkize_media(self, friendly_token, profiles, force=True):
"""Break media in chunks and start encoding tasks"""
@@ -374,14 +384,16 @@ def produce_sprite_from_video(friendly_token):
try:
tmpdir_image_files = tmpdirname + "/img%03d.jpg"
output_name = tmpdirname + "/sprites.jpg"
cmd = "{0} -i {1} -f image2 -vf 'fps=1/10, scale=160:90' {2}&&files=$(ls {3}/img*.jpg | sort -t '-' -n -k 2 | tr '\n' ' ')&&convert $files -append {4}".format(
settings.FFMPEG_COMMAND,
media.media_file.path,
tmpdir_image_files,
tmpdirname,
output_name,
)
subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
fps = getattr(settings, 'SPRITE_NUM_SECS', 10)
ffmpeg_cmd = [settings.FFMPEG_COMMAND, "-i", media.media_file.path, "-f", "image2", "-vf", f"fps=1/{fps}, scale=160:90", tmpdir_image_files] # noqa
run_command(ffmpeg_cmd)
image_files = [f for f in os.listdir(tmpdirname) if f.startswith("img") and f.endswith(".jpg")]
image_files = sorted(image_files, key=lambda x: int(re.search(r'\d+', x).group()))
image_files = [os.path.join(tmpdirname, f) for f in image_files]
cmd_convert = ["convert", *image_files, "-append", output_name] # image files, unpacked into the list
ret = run_command(cmd_convert) # noqa
if os.path.exists(output_name) and get_file_type(output_name) == "image":
with open(output_name, "rb") as f:
myfile = File(f)
@@ -389,8 +401,8 @@ def produce_sprite_from_video(friendly_token):
content=myfile,
name=get_file_name(media.media_file.path) + "sprites.jpg",
)
except BaseException:
pass
except Exception as e:
print(e)
return True
@@ -415,19 +427,27 @@ def create_hls(friendly_token):
p = media.uid.hex
output_dir = os.path.join(settings.HLS_DIR, p)
encodings = media.encodings.filter(profile__extension="mp4", status="success", chunk=False, profile__codec="h264")
if encodings:
existing_output_dir = None
if os.path.exists(output_dir):
existing_output_dir = output_dir
output_dir = os.path.join(settings.HLS_DIR, p + produce_friendly_token())
files = " ".join([f.media_file.path for f in encodings if f.media_file])
cmd = "{0} --segment-duration=4 --output-dir={1} {2}".format(settings.MP4HLS_COMMAND, output_dir, files)
subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
files = [f.media_file.path for f in encodings if f.media_file]
cmd = [settings.MP4HLS_COMMAND, '--segment-duration=4', f'--output-dir={output_dir}', *files]
run_command(cmd)
if existing_output_dir:
# override content with -T !
cmd = "cp -rT {0} {1}".format(output_dir, existing_output_dir)
subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
shutil.rmtree(output_dir)
cmd = ["cp", "-rT", output_dir, existing_output_dir]
run_command(cmd)
try:
shutil.rmtree(output_dir)
except: # noqa
# this was breaking in some cases where it was already deleted
# because create_hls was running multiple times
pass
output_dir = existing_output_dir
pp = os.path.join(output_dir, "master.m3u8")
if os.path.exists(pp):

View File

@@ -7,5 +7,4 @@ register = template.Library()
@register.filter
def custom_translate(string, lang_code):
return translate_string(lang_code, string)

View File

@@ -1,3 +1,4 @@
from allauth.account.views import LoginView
from django.conf import settings
from django.conf.urls import include
from django.conf.urls.static import static
@@ -12,6 +13,7 @@ urlpatterns = [
re_path(r"^about", views.about, name="about"),
re_path(r"^setlanguage", views.setlanguage, name="setlanguage"),
re_path(r"^add_subtitle", views.add_subtitle, name="add_subtitle"),
re_path(r"^edit_subtitle", views.edit_subtitle, name="edit_subtitle"),
re_path(r"^categories$", views.categories, name="categories"),
re_path(r"^contact$", views.contact, name="contact"),
re_path(r"^edit", views.edit_media, name="edit_media"),
@@ -92,5 +94,14 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))
if hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS:
urlpatterns.append(path('accounts/login_system', LoginView.as_view(), name='login_system'))
urlpatterns.append(re_path(r"^accounts/login", views.custom_login_view, name='login'))
else:
urlpatterns.append(path('accounts/login', LoginView.as_view(), name='login_system'))
if hasattr(settings, "GENERATE_SITEMAP") and settings.GENERATE_SITEMAP:
urlpatterns.append(path("sitemap.xml", views.sitemap, name="sitemap"))

View File

@@ -1,28 +1,42 @@
from datetime import datetime, timedelta
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import SearchQuery
from django.core.mail import EmailMessage
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from drf_yasg import openapi as openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import FileUploadParser, FormParser, JSONParser, MultiPartParser
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from actions.models import USER_MEDIA_ACTIONS, MediaAction
from cms.custom_pagination import FastPaginationWithoutCount
from cms.permissions import IsAuthorizedToAdd, IsAuthorizedToAddComment, IsUserOrEditor, user_allowed_to_upload
from cms.permissions import (
IsAuthorizedToAdd,
IsAuthorizedToAddComment,
IsUserOrEditor,
user_allowed_to_upload,
)
from cms.version import VERSION
from identity_providers.models import LoginOption
from users.models import User
from .forms import ContactForm, MediaForm, SubtitleForm
from .forms import ContactForm, EditSubtitleForm, MediaForm, SubtitleForm
from .frontend_translations import translate_string
from .helpers import clean_query, get_alphanumeric_only, produce_ffmpeg_commands
from .methods import (
@@ -36,7 +50,17 @@ from .methods import (
show_related_media,
update_user_ratings,
)
from .models import Category, Comment, EncodeProfile, Encoding, Media, Playlist, PlaylistMedia, Tag
from .models import (
Category,
Comment,
EncodeProfile,
Encoding,
Media,
Playlist,
PlaylistMedia,
Subtitle,
Tag,
)
from .serializers import (
CategorySerializer,
CommentSerializer,
@@ -57,7 +81,7 @@ VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
def about(request):
"""About view"""
context = {}
context = {"VERSION": VERSION}
return render(request, "cms/about.html", context)
@@ -86,12 +110,68 @@ def add_subtitle(request):
form = SubtitleForm(media, request.POST, request.FILES)
if form.is_valid():
subtitle = form.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Subtitle was added"))
new_subtitle = Subtitle.objects.filter(id=subtitle.id).first()
try:
new_subtitle.convert_to_srt()
messages.add_message(request, messages.INFO, "Subtitle was added!")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
except: # noqa: E722
new_subtitle.delete()
error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files."
form.add_error("subtitle_file", error_msg)
return HttpResponseRedirect(subtitle.media.get_absolute_url())
else:
form = SubtitleForm(media_item=media)
return render(request, "cms/add_subtitle.html", {"form": form})
subtitles = media.subtitles.all()
context = {"media": media, "form": form, "subtitles": subtitles}
return render(request, "cms/add_subtitle.html", context)
@login_required
def edit_subtitle(request):
subtitle_id = request.GET.get("id", "").strip()
action = request.GET.get("action", "").strip()
if not subtitle_id:
return HttpResponseRedirect("/")
subtitle = Subtitle.objects.filter(id=subtitle_id).first()
if not subtitle:
return HttpResponseRedirect("/")
if not (request.user == subtitle.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
return HttpResponseRedirect("/")
context = {"subtitle": subtitle, "action": action}
if action == "download":
response = HttpResponse(subtitle.subtitle_file.read(), content_type="text/vtt")
filename = subtitle.subtitle_file.name.split("/")[-1]
if not filename.endswith(".vtt"):
filename = f"{filename}.vtt"
response["Content-Disposition"] = f"attachment; filename={filename}" # noqa
return response
if request.method == "GET":
form = EditSubtitleForm(subtitle)
context["form"] = form
elif request.method == "POST":
confirm = request.GET.get("confirm", "").strip()
if confirm == "true":
messages.add_message(request, messages.INFO, "Subtitle was deleted")
redirect_url = subtitle.media.get_absolute_url()
subtitle.delete()
return HttpResponseRedirect(redirect_url)
form = EditSubtitleForm(subtitle, request.POST)
subtitle_text = form.data["subtitle"]
with open(subtitle.subtitle_file.path, "w") as ff:
ff.write(subtitle_text)
messages.add_message(request, messages.INFO, "Subtitle was edited")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
return render(request, "cms/edit_subtitle.html", context)
def categories(request):
@@ -311,6 +391,7 @@ def tos(request):
return render(request, "cms/tos.html", context)
@login_required
def upload_media(request):
"""Upload media view"""
@@ -459,9 +540,10 @@ class MediaDetail(APIView):
# this need be explicitly called, and will call
# has_object_permission() after has_permission has succeeded
self.check_object_permissions(self.request, media)
if media.state == "private" and not (self.request.user == media.user or is_mediacms_editor(self.request.user)):
if (not password) or (not media.password) or (password != media.password):
if getattr(settings, 'USE_RBAC', False) and self.request.user.is_authenticated and self.request.user.has_member_access_to_media(media):
pass
elif (not password) or (not media.password) or (password != media.password):
return Response(
{"detail": "media is private"},
status=status.HTTP_401_UNAUTHORIZED,
@@ -656,6 +738,9 @@ class MediaActions(APIView):
def get(self, request, friendly_token, format=None):
# show date and reason for each time media was reported
media = self.get_object(friendly_token)
if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
if isinstance(media, Response):
return media
@@ -733,7 +818,7 @@ class MediaActions(APIView):
class MediaSearch(APIView):
"""
Retrieve results for searc
Retrieve results for search
Only GET is implemented here
"""
@@ -793,6 +878,11 @@ class MediaSearch(APIView):
if category:
media = media.filter(category__title__contains=category)
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
c_object = Category.objects.filter(title=category, is_rbac_category=True).first()
if c_object and request.user.has_member_access_to_category(c_object):
# show all media where user has access based on RBAC
media = Media.objects.filter(category=c_object)
if media_type:
media = media.filter(media_type=media_type)
@@ -909,9 +999,10 @@ class PlaylistDetail(APIView):
serializer = PlaylistDetailSerializer(playlist, context={"request": request})
playlist_media = PlaylistMedia.objects.filter(playlist=playlist).prefetch_related("media__user")
playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user")
playlist_media = [c.media for c in playlist_media]
playlist_media_serializer = MediaSerializer(playlist_media, many=True, context={"request": request})
ret = serializer.data
ret["playlist_media"] = playlist_media_serializer.data
@@ -1176,7 +1267,7 @@ class CommentList(APIView):
def get(self, request, format=None):
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
comments = Comment.objects.filter()
comments = Comment.objects.filter(media__state="public").order_by("-add_date")
comments = comments.prefetch_related("user")
comments = comments.prefetch_related("media")
params = self.request.query_params
@@ -1336,7 +1427,17 @@ class CategoryList(APIView):
},
)
def get(self, request, format=None):
categories = Category.objects.filter().order_by("title")
if is_mediacms_editor(request.user):
categories = Category.objects.filter()
else:
categories = Category.objects.filter(is_rbac_category=False)
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
rbac_categories = request.user.get_rbac_categories_as_member()
categories = categories.union(rbac_categories)
categories = categories.order_by("title")
serializer = CategorySerializer(categories, many=True, context={"request": request})
ret = serializer.data
return Response(ret)
@@ -1404,3 +1505,38 @@ class TaskDetail(APIView):
# This is not imported!
# revoke(uid, terminate=True)
return Response(status=status.HTTP_204_NO_CONTENT)
def saml_metadata(request):
if not (hasattr(settings, "USE_SAML") and settings.USE_SAML):
raise Http404
xml_parts = ['<?xml version="1.0"?>']
saml_social_apps = SocialApp.objects.filter(provider='saml')
entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/"
xml_parts.append(f'<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" Name="{entity_id}">') # noqa
xml_parts.append(f' <md:EntityDescriptor entityID="{entity_id}">') # noqa
xml_parts.append(' <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">') # noqa
# Add multiple AssertionConsumerService elements with different indices
for index, app in enumerate(saml_social_apps, start=1):
xml_parts.append(
f' <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" ' # noqa
f'Location="{settings.FRONTEND_HOST}/accounts/saml/{app.client_id}/acs/" index="{index}"/>' # noqa
)
xml_parts.append(' </md:SPSSODescriptor>') # noqa
xml_parts.append(' </md:EntityDescriptor>') # noqa
xml_parts.append('</md:EntitiesDescriptor>') # noqa
metadata_xml = '\n'.join(xml_parts)
return HttpResponse(metadata_xml, content_type='application/xml')
def custom_login_view(request):
if not (hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS):
return redirect(reverse('login_system'))
login_options = []
for option in LoginOption.objects.filter(active=True):
login_options.append({'url': option.url, 'title': option.title})
return render(request, 'account/custom_login_selector.html', {'login_options': login_options})

32357
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,34 +14,34 @@
"cover 99.5%"
],
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-env": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@types/react": "^17.0.11",
"@types/react-dom": "^17.0.7",
"autoprefixer": "^10.2.6",
"babel-loader": "^8.2.2",
"compass-mixins": "^0.12.10",
"copy-webpack-plugin": "^9.0.0",
"core-js": "^3.14.0",
"css-loader": "^5.2.6",
"dotenv": "^10.0.0",
"ejs": "^3.1.6",
"@babel/core": "^7.26.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"compass-mixins": "^0.12.12",
"copy-webpack-plugin": "^13.0.0",
"core-js": "^3.41.0",
"css-loader": "^7.1.2",
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"ejs-compiled-loader": "^3.1.0",
"mediacms-scripts": "file:packages/scripts",
"postcss-loader": "^6.1.0",
"prettier": "^2.3.1",
"prop-types": "^15.7.2",
"sass": "^1.34.1",
"sass-loader": "^12.1.0",
"ts-loader": "^9.2.3",
"typescript": "^4.3.2",
"postcss-loader": "^8.1.1",
"prettier": "^3.5.3",
"prop-types": "^15.8.1",
"sass": "^1.85.1",
"sass-loader": "^16.0.5",
"ts-loader": "^9.5.2",
"typescript": "^5.8.2",
"url-loader": "^4.1.1",
"webpack": "^5.38.1"
"webpack": "^5.98.0"
},
"dependencies": {
"axios": "^0.21.1",
"flux": "^4.0.1",
"axios": "^1.8.2",
"flux": "^4.0.4",
"mediacms-player": "file:packages/player",
"normalize.css": "^8.0.1",
"react": "^17.0.2",
@@ -49,6 +49,9 @@
"react-mentions": "^4.3.1",
"sortablejs": "^1.13.0",
"timeago.js": "^4.0.2",
"url-parse": "^1.5.1"
"url-parse": "^1.5.10",
"pdfjs-dist": "3.4.120",
"@react-pdf-viewer/core": "^3.9.0",
"@react-pdf-viewer/default-layout": "^3.9.0"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@
"css-loader": "^5.2.6",
"global": "^4.4.0",
"json-loader": "^0.5.7",
"node-sass": "^6.0.0",
"sass": "^1.85.1",
"postcss": "^8.3.2",
"rollup": "^2.51.2",
"rollup-plugin-babel": "^4.3.3",

View File

@@ -3,24 +3,28 @@
Object.defineProperty(exports, '__esModule', { value: true });
var __assign = function() {
__assign = Object.assign || function __assign(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
__assign = Object.assign || function __assign(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
function __spreadArray(to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || from);
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
function bodySnippet(id) {
return '<div id="' + id + '"></div>';
@@ -139,44 +143,44 @@ var config$2 = {
optimization: {
runtimeChunk: false,
/*splitChunks: {
// minSize: 1000000,
chunks: 'all',
automaticNameDelimiter: '-',
},*/
// minSize: 1000000,
chunks: 'all',
automaticNameDelimiter: '-',
},*/
/*splitChunks: {
// minSize: 1000000,
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_0,
},*/
// minSize: 1000000,
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_0,
},*/
/*splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_1,
},*/
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_1,
},*/
/*splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_2,
},*/
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_2,
},*/
/*splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_3,
},*/
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: chunksCacheGroups_3,
},*/
splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: "_commons",
name: '_commons',
priority: 1,
chunks: "initial",
chunks: 'initial',
},
},
},
}
},
};
/*const chunksCacheGroups_0 = {
@@ -241,13 +245,13 @@ var config$1 = {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: "_commons",
name: '_commons',
priority: 1,
chunks: "initial",
chunks: 'initial',
},
},
},
}
},
};
/**
@@ -307,7 +311,7 @@ function pagesConfig(pagesKeys) {
head: {},
body: {
scripts: [],
snippet: sitemapTemplate({ pages: __spreadArray(__spreadArray([], pagesKeys), Object.keys(pages)) }),
snippet: sitemapTemplate({ pages: __spreadArray(__spreadArray([], pagesKeys, true), Object.keys(pages), true) }),
},
},
window: {},
@@ -649,7 +653,9 @@ function analyzer(analyzerOptions) {
statsFilename: 'analyzer-stats.json',
reportFilename: 'analyzer-report.html',
};
var compiler = 'dist' === options.env ? webpack$2(__assign(__assign({}, config$1), config)) : webpack$2(__assign(__assign({}, config$2), config));
var compiler = 'dist' === options.env
? webpack$2(__assign(__assign({}, config$1), config))
: webpack$2(__assign(__assign({}, config$2), config));
var analyzer = new BundleAnalyzerPlugin(analyzerConfig);
analyzer.apply(compiler);
compiler.run(function (err, stats) {
@@ -697,7 +703,9 @@ function build(buildOptions) {
throw Error('"postcssConfigFile" is not an absolute path');
}
var config = generateConfig(options.env, options.config);
var compiler = 'dist' === options.env ? webpack$1(__assign(__assign({}, config$1), config)) : webpack$1(__assign(__assign({}, config$2), config));
var compiler = 'dist' === options.env
? webpack$1(__assign(__assign({}, config$1), config))
: webpack$1(__assign(__assign({}, config$2), config));
compiler.run(function (err, stats) {
if (err)
throw err;
@@ -728,8 +736,8 @@ var config = {
// devtool: 'source-map',
// devtool: 'eval-cheap-source-map',
optimization: {
minimize: false
}
minimize: false,
},
};
function configFunc(contentBase) {
@@ -739,7 +747,7 @@ function configFunc(contentBase) {
},
contentBase: contentBase,
compress: true,
hot: true
hot: true,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -36,14 +36,15 @@
"cross-spawn": "^7.0.3",
"dotenv": "^10.0.0",
"rimraf": "^3.0.2",
"rollup": "^2.51.2",
"rollup": "^2.79.1",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-typescript2": "^0.30.0",
"rollup-plugin-typescript2": "^0.34.1",
"rollup-plugin-visualizer": "^5.5.0",
"serialize-javascript": "^5.0.1",
"source-map-loader": "^3.0.0",
"ts-loader": "^9.2.3",
"typescript": "^4.3.2"
"tslib": "^2.6.2",
"typescript": "^4.9.5"
},
"dependencies": {
"@svgr/webpack": "^5.5.0",
@@ -64,7 +65,6 @@
"lodash.merge": "^4.6.2",
"mini-css-extract-plugin": "^1.6.0",
"node-polyfill-webpack-plugin": "^1.1.2",
"node-sass": "^6.0.0",
"postcss": "^8.3.2",
"postcss-import": "^14.0.2",
"postcss-loader": "^6.1.0",
@@ -72,6 +72,7 @@
"postcss-nested": "^5.0.5",
"postcss-scss": "^3.0.5",
"progress-bar-webpack-plugin": "^2.1.0",
"sass": "^1.85.1",
"sass-loader": "^12.1.0",
"style-loader": "^2.0.0",
"url-loader": "^4.1.1",

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@
"load-grunt-tasks": "^5.1.0",
"lodash": "^4.17.21",
"material-design-icons": "^3.0.1",
"node-sass": "^6.0.0",
"sass": "^1.85.1",
"rimraf": "^3.0.2",
"time-grunt": "^2.0.0",
"webfonts-generator": "^0.4.0"

File diff suppressed because it is too large Load Diff

View File

@@ -42,7 +42,7 @@
"core-js": "^3.14.0",
"global": "^4.4.0",
"minami": "^1.2.3",
"node-sass": "^6.0.0",
"sass": "^1.85.1",
"postcss": "^8.3.2",
"rimraf": "^3.0.2",
"rollup": "^2.51.2",

View File

@@ -3024,6 +3024,12 @@ function generatePlugin() {
MediaCmsVjsPlugin.VERSION = VERSION;
videojs.registerPlugin('mediaCmsVjsPlugin', MediaCmsVjsPlugin);
if (typeof videojs.registerPlugin === 'function') {
videojs.registerPlugin('mediaCmsVjsPlugin', MediaCmsVjsPlugin);
} else {
videojs.plugin('mediaCmsVjsPlugin', MediaCmsVjsPlugin);
}
return MediaCmsVjsPlugin;
}

View File

@@ -7,9 +7,10 @@ interface MediaListRowProps {
viewAllText?: string;
className?: string;
style?: { [key: string]: any };
children?: React.ReactNode;
}
export const MediaListRow: React.FC<MediaListRowProps> = (props) => {
export const MediaListRow: React.FC<MediaListRowProps> = (props:any) => {
return (
<div className={(props.className ? props.className + ' ' : '') + 'media-list-row'} style={props.style}>
{props.title ? (

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useRef, useState } from 'react';
import './ToolTip.scss';
function Tooltip({ children, content, title, position = 'right', classNames = '' }) {
const [active, setActive] = useState(false);
const [tooltipDimensions, setTooltipDimensions] = useState({
height: 0,
width: 0,
});
const popUpRef = useRef(null);
const showTip = () => {
setActive(true);
};
const hideTip = () => {
setActive(false);
};
useEffect(() => {
if (popUpRef.current) {
setTooltipDimensions({
height: popUpRef.current.clientHeight || 0,
width: popUpRef.current.clientWidth || 0,
});
}
}, [active]);
const tooltipPositionStyles = {
right: { left: '100%', marginLeft: '10px', top: '-50%' },
left: { right: '100%', marginRight: '10px', top: '-50%' },
top: { left: '50%', top: `-${tooltipDimensions.height + 10}px`, transform: 'translateX(-50%)' },
center: { top: '50%', left: '50%', translate: 'x-[-50%]' },
'bottom-left': { left: `-${tooltipDimensions.width - 20}px`, top: '100%', marginTop: '10px' },
};
return (
<div onMouseEnter={showTip} onMouseLeave={hideTip}>
<div
ref={popUpRef}
className={`tooltip-box ${active ? 'show' : 'hide'} ${classNames}`}
style={tooltipPositionStyles[position]}
>
{title && <div className="tooltip-title">{title}</div>}
<div className="tooltip-content">{content}</div>
</div>
{children}
</div>
);
}
export default Tooltip;

View File

@@ -0,0 +1,31 @@
.tooltip-box {
position: absolute;
padding: 10px;
z-index: 100;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.tooltip-box.show {
opacity: 1;
transform: translateY(0);
}
.tooltip-box.hide {
opacity: 0;
transform: translateY(-10px);
}
.tooltip-title {
color: #333;
font-size: 14px;
margin-bottom: 5px;
}
.tooltip-content {
color: #666;
font-size: 12px;
}

View File

@@ -11,7 +11,7 @@ import { replaceString } from '../../utils/helpers/';
import './videojs-markers.js';
import './videojs.markers.css';
import {enableMarkers, addMarker} from './videojs-markers_config.js'
import { enableMarkers, addMarker } from './videojs-markers_config.js';
import { translateString } from '../../utils/helpers/';
import './Comments.scss';
@@ -39,7 +39,7 @@ function CommentForm(props) {
? null
: LinksContext._currentValue.signin +
'?next=/' +
window.location.href.replace(SiteContext._currentValue.url, '').replace(/^\//g, '')
window.location.href.replace(SiteContext._currentValue.url, '').replace(/^\//g, ''),
);
function onFocus() {
@@ -50,12 +50,11 @@ function CommentForm(props) {
setTextareaFocused(false);
}
function onUsersLoad()
{
const userList =[...MediaPageStore.get('users')];
const cleanList = []
userList.forEach(user => {
cleanList.push({id : user.username, display : user.name});
function onUsersLoad() {
const userList = [...MediaPageStore.get('users')];
const cleanList = [];
userList.forEach((user) => {
cleanList.push({ id: user.username, display: user.name });
});
setUsersList(cleanList);
@@ -125,16 +124,14 @@ function CommentForm(props) {
useEffect(() => {
MediaPageStore.on('comment_submit', onCommentSubmit);
MediaPageStore.on('comment_submit_fail', onCommentSubmitFail);
if (MediaCMS.features.media.actions.comment_mention === true)
{
if (MediaCMS.features.media.actions.comment_mention === true) {
MediaPageStore.on('users_load', onUsersLoad);
}
return () => {
MediaPageStore.removeListener('comment_submit', onCommentSubmit);
MediaPageStore.removeListener('comment_submit_fail', onCommentSubmitFail);
if (MediaCMS.features.media.actions.comment_mention === true)
{
if (MediaCMS.features.media.actions.comment_mention === true) {
MediaPageStore.removeListener('users_load', onUsersLoad);
}
};
@@ -146,33 +143,31 @@ function CommentForm(props) {
<UserThumbnail />
<div className="form">
<div className={'form-textarea-wrap' + (textareaFocused ? ' focused' : '')}>
{ MediaCMS.features.media.actions.comment_mention ?
{MediaCMS.features.media.actions.comment_mention ? (
<MentionsInput
inputRef={textareaRef}
className="form-textarea"
rows="1"
placeholder={translateString('Add a ') + commentsText.single + '...'}
placeholder={'Add a ' + commentsText.single + '...'}
value={value}
onChange={onChangeWithMention}
onFocus={onFocus}
onBlur={onBlur}>
<Mention
data={userList}
markup="@(___id___)[___display___]"
/>
onBlur={onBlur}
>
<Mention data={userList} markup="@(___id___)[___display___]" />
</MentionsInput>
:
) : (
<textarea
ref={textareaRef}
className="form-textarea"
rows="1"
placeholder={translateString('Add a ') + commentsText.single + '...'}
placeholder={'Add a ' + commentsText.single + '...'}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
></textarea>
}
)}
</div>
<div className="form-buttons">
<button className={'' === value.trim() ? 'disabled' : ''} onClick={submitComment}>
@@ -239,7 +234,9 @@ function CommentActions(props) {
{MemberContext._currentValue.can.deleteComment ? (
<div className="comment-action remove-comment">
<PopupTrigger contentRef={popupContentRef}>
<button>{translateString('DELETE')} {commentsText.uppercaseSingle}</button>
<button>
{translateString('DELETE')} {commentsText.uppercaseSingle}
</button>
</PopupTrigger>
<PopupContent contentRef={popupContentRef}>
@@ -425,7 +422,7 @@ export default function CommentsList(props) {
const [mediaId, setMediaId] = useState(MediaPageStore.get('media-id'));
const [comments, setComments] = useState(
MemberContext._currentValue.can.readComment ? MediaPageStore.get('media-comments') : []
MemberContext._currentValue.can.readComment ? MediaPageStore.get('media-comments') : [],
);
const [displayComments, setDisplayComments] = useState(false);
@@ -433,67 +430,66 @@ export default function CommentsList(props) {
function onCommentsLoad() {
const retrievedComments = [...MediaPageStore.get('media-comments')];
retrievedComments.forEach((comment) => {
comment.text = setTimestampAnchors(comment.text);
});
displayCommentsRelatedAlert();
setComments([...retrievedComments]);
// TODO: this code is breaking, beed ti debug, until then removing the extra
// functionality related with video/timestamp/user mentions
// const video = videojs('vjs_video_3');
// if (MediaCMS.features.media.actions.timestampTimebar)
//{
// enableMarkers(video);
//}
//if (MediaCMS.features.media.actions.comment_mention === true)
//{
// retrievedComments.forEach(comment => {
// comment.text = setMentions(comment.text);
// });
//}
// TODO: this code is breaking
// video.one('loadedmetadata', () => {
// retrievedComments.forEach(comment => {
// comment.text = setTimestampAnchorsAndMarkers(comment.text, video);
// });
// displayCommentsRelatedAlert();
// setComments([...retrievedComments]);
//});
//setComments([...retrievedComments]);
}
function setMentions(text)
{
let sanitizedComment = text.split('@(_').join("<a href=\"/user/");
sanitizedComment = sanitizedComment.split('_)[_').join("\">");
return sanitizedComment.split('_]').join("</a>");
}
function setTimestampAnchorsAndMarkers(text, videoPlayer)
{
function wrapTimestampWithAnchor(match, string)
{
let split = match.split(':'), s = 0, m = 1;
function setTimestampAnchors(text) {
function wrapTimestampWithAnchor(match, string) {
let split = match.split(':'),
s = 0,
m = 1;
let searchParameters = new URLSearchParams(window.location.search);
while (split.length > 0)
{
s += m * parseInt(split.pop(), 10);
m *= 60;
}
if (MediaCMS.features.media.actions.timestampTimebar)
{
addMarker(videoPlayer, s, text);
while (split.length > 0) {
s += m * parseInt(split.pop(), 10);
m *= 60;
}
searchParameters.set('t', s);
searchParameters.set('t', s)
const wrapped = "<a href=\"" + MediaPageStore.get('media-url').split('?')[0] + "?" + searchParameters + "\">" + match + "</a>";
let mediaUrl = MediaPageStore.get('media-url').split('?')[0] + '?' + searchParameters;
const wrapped = '<a href="' + mediaUrl + '">' + match + '</a>';
return wrapped;
}
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
return text.replace(timeRegex , wrapTimestampWithAnchor);
return text.replace(timeRegex, wrapTimestampWithAnchor);
}
function setMentions(text) {
let sanitizedComment = text.split('@(_').join('<a href="/user/');
sanitizedComment = sanitizedComment.split('_)[_').join('">');
return sanitizedComment.split('_]').join('</a>');
}
function setTimestampAnchorsAndMarkers(text, videoPlayer) {
function wrapTimestampWithAnchor(match, string) {
let split = match.split(':'),
s = 0,
m = 1;
let searchParameters = new URLSearchParams(window.location.search);
while (split.length > 0) {
s += m * parseInt(split.pop(), 10);
m *= 60;
}
if (MediaCMS.features.media.actions.timestampTimebar) {
addMarker(videoPlayer, s, text);
}
searchParameters.set('t', s);
const wrapped =
'<a href="' + MediaPageStore.get('media-url').split('?')[0] + '?' + searchParameters + '">' + match + '</a>';
return wrapped;
}
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
return text.replace(timeRegex, wrapTimestampWithAnchor);
}
function onCommentSubmit(commentId) {
@@ -505,8 +501,8 @@ export default function CommentsList(props) {
function onCommentSubmitFail() {
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
setTimeout(
() => PageActions.addNotification(commentsText.ucfirstSingle + ' submition failed', 'commentSubmitFail'),
100
() => PageActions.addNotification(commentsText.ucfirstSingle + ' submission failed', 'commentSubmitFail'),
100,
);
}
@@ -520,7 +516,7 @@ export default function CommentsList(props) {
// FIXME: Without delay creates conflict [ Uncaught Error: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. ].
setTimeout(
() => PageActions.addNotification(commentsText.ucfirstSingle + ' removal failed', 'commentDeleteFail'),
100
100,
);
}
@@ -528,7 +524,7 @@ export default function CommentsList(props) {
setDisplayComments(
comments.length &&
MemberContext._currentValue.can.readComment &&
(MediaPageStore.get('media-data').enable_comments || MemberContext._currentValue.can.editMedia)
(MediaPageStore.get('media-data').enable_comments || MemberContext._currentValue.can.editMedia),
);
}, [comments]);

View File

@@ -520,6 +520,6 @@
};
}
_video2.default.plugin('markers', registerVideoJsMarkersPlugin);
videojs.registerPlugin('markers', registerVideoJsMarkersPlugin);
});
//# sourceMappingURL=videojs-markers.js.map

View File

@@ -69,13 +69,18 @@ a.item-thumb {
}
}
.item.pdf-item &,
.item.attachment-item & {
.item.pdf-item & {
&:before {
content: '\e415';
}
}
.item.attachment-item & {
&:before {
content: '\e24d';
}
}
.item.playlist-item & {
&:before {

View File

@@ -522,6 +522,7 @@
display: block;
img {
cursor: pointer;
position: relative;
display: block;
max-width: 100%;
@@ -530,6 +531,135 @@
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.slideshow-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: auto;
max-width: 90%;
}
.slideshow-image img {
display: block;
width: auto;
height: auto;
max-width: 100%; /* Ensure image doesn't exceed container width */
max-height: 90vh;
border-radius: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: transform 60s ease-in-out, opacity 60 ease-in-out;
}
.slideshow-title {
margin-top: 10px;
text-align: start;
font-size: 16px;
font-weight: bold;
color: #bdb6b6;
z-index: 1200;
}
.arrow {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
top: 50%;
transform: translateY(-50%);
border: none;
color: white;
font-size: 2rem;
background-color: rgba(0, 0, 0, 0.2);
cursor: pointer;
padding: 10px;
border-radius: 50%;
z-index: 1000;
transition: background-color 0.2s ease, transform 0.2s ease;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
}
.arrow:hover {
background: rgba(92, 78, 78, 0.6);
transform: translateY(-50%) scale(1.1);
}
.arrow.left {
left: 10px;
}
.arrow.right {
right: 10px;
}
.thumbnail-navigation {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
gap: 10px;
bottom: 10%;
left: 50%;
transform: translateX(-50%);
}
.thumbnail-container {
display: flex;
gap: 10px;
overflow-x: auto;
scroll-behavior: smooth;
max-width: 80%;
padding: 10px 0;
scrollbar-width: none; /* Hide scrollbar for Firefox */
}
.thumbnail-container.center-thumbnails {
display: flex;
justify-content: center;
overflow: visible; /* No scrollbars for fewer thumbnails */
}
.thumbnail-container::-webkit-scrollbar {
display: none; /* Hide scrollbar for Chrome/Safari */
}
.thumbnail {
width: 60px;
height: 60px;
object-fit: cover;
border: 2px solid transparent;
border-radius: 5px;
cursor: pointer;
transition: transform 0.3s ease;
}
.thumbnail.active {
border-color: white;
}
.thumbnail:hover {
transform: scale(1.1);
}
.viewer-container .player-container {
@media screen and (min-width: 480px) {
border-radius: 10px;
@@ -537,7 +667,6 @@
}
.viewer-container .player-container.audio-player-container {
@media screen and (min-width: 480px) {
padding-top: 0.75 * 56.25%;
}
@@ -551,6 +680,26 @@
}
}
.viewer-container .pdf-container {
overflow-y: auto;
display: flex;
justify-content: center;
align-items: center;
width: 100%; // Default width for mobile
height: 400px; // Default height for mobile
@media (min-width: 768px) and (max-width: 1023px) { // Tablets
width: 90%;
height: 600px;
}
@media (min-width: 1024px) { // Desktop
width: 85%;
height: 900px;
}
}
.viewer-container .player-container.viewer-pdf-container,
.viewer-container .player-container.viewer-attachment-container {
background-color: var(--item-thumb-bg-color);

View File

@@ -14,7 +14,7 @@ function metafield(arr) {
let sep;
let ret = [];
if (arr.length) {
if (arr && arr.length) {
i = 0;
sep = 1 < arr.length ? ', ' : '';
while (i < arr.length) {
@@ -50,7 +50,9 @@ function MediaAuthorBanner(props) {
</a>
</span>
{PageStore.get('config-media-item').displayPublishDate && props.published ? (
<span className="author-banner-date">{translateString("Published on")} {replaceString(publishedOnDate(new Date(props.published)))}</span>
<span className="author-banner-date">
{translateString('Published on')} {replaceString(publishedOnDate(new Date(props.published)))}
</span>
) : null}
</div>
</div>
@@ -78,8 +80,8 @@ function EditMediaButton(props) {
}
return (
<a href={link} rel="nofollow" title={translateString("Edit media")} className="edit-media">
{translateString("EDIT MEDIA")}
<a href={link} rel="nofollow" title={translateString('Edit media')} className="edit-media">
{translateString('EDIT MEDIA')}
</a>
);
}
@@ -92,8 +94,8 @@ function EditSubtitleButton(props) {
}
return (
<a href={link} rel="nofollow" title={translateString("Edit subtitle")} className="edit-subtitle">
{translateString("EDIT SUBTITLE")}
<a href={link} rel="nofollow" title={translateString('Edit subtitle')} className="edit-subtitle">
{translateString('EDIT SUBTITLE')}
</a>
);
}
@@ -173,6 +175,28 @@ export default function ViewerInfoContent(props) {
const authorLink = formatInnerLink(props.author.url, SiteContext._currentValue.url);
const authorThumb = formatInnerLink(props.author.thumb, SiteContext._currentValue.url);
function setTimestampAnchors(text) {
function wrapTimestampWithAnchor(match, string) {
let split = match.split(':'),
s = 0,
m = 1;
let searchParameters = new URLSearchParams(window.location.search);
while (split.length > 0) {
s += m * parseInt(split.pop(), 10);
m *= 60;
}
searchParameters.set('t', s);
const wrapped =
'<a href="' + MediaPageStore.get('media-url').split('?')[0] + '?' + searchParameters + '">' + match + '</a>';
return wrapped;
}
const timeRegex = new RegExp('((\\d)?\\d:)?(\\d)?\\d:\\d\\d', 'g');
return text.replace(timeRegex, wrapTimestampWithAnchor);
}
return (
<div className="media-info-content">
{void 0 === PageStore.get('config-media-item').displayAuthor ||
@@ -185,11 +209,10 @@ export default function ViewerInfoContent(props) {
<div className="media-content-banner-inner">
{hasSummary ? <div className="media-content-summary">{summary}</div> : null}
{(!hasSummary || isContentVisible) && description ? (
PageStore.get('config-options').pages.media.htmlInDescription ? (
<div className="media-content-description" dangerouslySetInnerHTML={{ __html: description }}></div>
) : (
<div className="media-content-description">{description}</div>
)
<div
className="media-content-description"
dangerouslySetInnerHTML={{ __html: setTimestampAnchors(description) }}
></div>
) : null}
{hasSummary ? (
<button className="load-more" onClick={onClickLoadMore}>
@@ -197,7 +220,11 @@ export default function ViewerInfoContent(props) {
</button>
) : null}
{tagsContent.length ? (
<MediaMetaField value={tagsContent} title={1 < tagsContent.length ? translateString('Tags') : translateString('Tag')} id="tags" />
<MediaMetaField
value={tagsContent}
title={1 < tagsContent.length ? translateString('Tags') : translateString('Tag')}
id="tags"
/>
) : null}
{categoriesContent.length ? (
<MediaMetaField
@@ -217,7 +244,7 @@ export default function ViewerInfoContent(props) {
) : null}
<PopupTrigger contentRef={popupContentRef}>
<button className="remove-media">{translateString("DELETE MEDIA")}</button>
<button className="remove-media">{translateString('DELETE MEDIA')}</button>
</PopupTrigger>
<PopupContent contentRef={popupContentRef}>

View File

@@ -1,8 +1,10 @@
import React, { useContext, useEffect, useState } from 'react';
import { SiteContext } from '../../utils/contexts/';
import { MediaPageStore } from '../../utils/stores/';
import { SpinnerLoader } from '../_shared';
import Tooltip from '../_shared/ToolTip';
export default function ImageViewer(props) {
export default function ImageViewer() {
const site = useContext(SiteContext);
let initialImage = getImageUrl();
@@ -11,6 +13,12 @@ export default function ImageViewer(props) {
initialImage = initialImage ? initialImage : '';
const [image, setImage] = useState(initialImage);
const [slideshowItems, setSlideshowItems] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [isImgLoading, setIsImgLoading] = useState(true);
const thumbnailRef = React.useRef();
function onImageLoad() {
setImage(getImageUrl());
@@ -19,34 +27,142 @@ export default function ImageViewer(props) {
function getImageUrl() {
const media_data = MediaPageStore.get('media-data');
let imgUrl = 'string' === typeof media_data.poster_url ? media_data.poster_url.trim() : '';
if ('' === imgUrl) {
imgUrl = 'string' === typeof media_data.thumbnail_url ? media_data.thumbnail_url.trim() : '';
}
if ('' === imgUrl) {
imgUrl =
'string' === typeof MediaPageStore.get('media-original-url')
? MediaPageStore.get('media-original-url').trim()
: '';
}
if ('' === imgUrl) {
return '#';
}
let imgUrl =
media_data.poster_url?.trim() ||
media_data.thumbnail_url?.trim() ||
MediaPageStore.get('media-original-url')?.trim() ||
'#';
return site.url + '/' + imgUrl.replace(/^\//g, '');
}
const fetchSlideShowItems = () => {
const media_data = MediaPageStore.get('media-data');
const items = media_data.slideshow_items;
if (Array.isArray(items)) {
setSlideshowItems(items);
}
};
useEffect(() => {
if (image) {
fetchSlideShowItems();
}
}, [image]);
useEffect(() => {
MediaPageStore.on('loaded_image_data', onImageLoad);
return () => MediaPageStore.removeListener('loaded_image_data', onImageLoad);
}, []);
useEffect(() => {
if (!isModalOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isModalOpen, slideshowItems]);
const handleKeyDown = (event) => {
if (event.key === 'ArrowRight') handleNext();
if (event.key === 'ArrowLeft') handlePrevious();
if (event.key === 'Escape') onClose();
};
const onClose = () => setIsModalOpen(false);
const handleNext = () => {
setIsImgLoading(true);
setCurrentIndex((prevIndex) => (prevIndex + 1) % slideshowItems.length);
};
const handlePrevious = () => {
setIsImgLoading(true);
setCurrentIndex((prevIndex) => (prevIndex - 1 + slideshowItems.length) % slideshowItems.length);
};
const handleDotClick = (index) => {
setIsImgLoading(true);
setCurrentIndex(index);
};
const handleImageClick = (index) => {
const mediaPageUrl = site.url + slideshowItems[index]?.url;
window.location.href = mediaPageUrl;
};
const scrollThumbnails = (direction) => {
if (thumbnailRef.current) {
const scrollAmount = 10;
if (direction === 'left') {
thumbnailRef.current.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
} else if (direction === 'right') {
thumbnailRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}
};
return !image ? null : (
<div className="viewer-image-container">
<img src={image} alt={MediaPageStore.get('media-data').title || null} />
<Tooltip content={'load full-image'} position="center">
<img src={image} alt={MediaPageStore.get('media-data').title || null} onClick={() => setIsModalOpen(true)} />
</Tooltip>
{isModalOpen && slideshowItems && (
<div className="modal-overlay" onClick={() => setIsModalOpen(false)}>
<div className="slideshow-container" onClick={(e) => e.stopPropagation()}>
{!isImgLoading && (
<button className="arrow left" onClick={handlePrevious} aria-label="Previous slide">
&#8249;
</button>
)}
<div className="slideshow-image">
{isImgLoading && <SpinnerLoader size="large" />}
<img
src={site.url + '/' + slideshowItems[currentIndex]?.original_media_url}
alt={`Slide ${currentIndex + 1}`}
onClick={() => handleImageClick(currentIndex)}
onLoad={() => setIsImgLoading(false)}
onError={() => setIsImgLoading(false)}
style={{ display: isImgLoading ? 'none' : 'block' }}
/>
{!isImgLoading && <div className="slideshow-title">{slideshowItems[currentIndex]?.title}</div>}
</div>
{!isImgLoading && (
<button className="arrow right" onClick={handleNext} aria-label="Next slide">
&#8250;
</button>
)}
<div className="thumbnail-navigation">
{slideshowItems.length > 5 && (
<button className="arrow left" onClick={() => scrollThumbnails('left')} aria-label="Scroll left">
&#8249;
</button>
)}
<div
className={`thumbnail-container ${slideshowItems.length <= 5 ? 'center-thumbnails' : ''}`}
ref={thumbnailRef}
>
{slideshowItems.map((item, index) => (
<img
key={index}
src={site.url + '/' + item.thumbnail_url}
alt={`Thumbnail ${index + 1}`}
className={`thumbnail ${currentIndex === index ? 'active' : ''}`}
onClick={() => handleDotClick(index)}
/>
))}
</div>
{slideshowItems.length > 5 && (
<button className="arrow right" onClick={() => scrollThumbnails('right')} aria-label="Scroll right">
&#8250;
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,15 +1,18 @@
import React from 'react';
import { Worker, Viewer } from '@react-pdf-viewer/core';
import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout';
export default function PdfViewer() {
import '@react-pdf-viewer/core/lib/styles/index.css'
import '@react-pdf-viewer/default-layout/lib/styles/index.css';
export default function PdfViewer({ fileUrl }) {
const defaultLayoutPluginInstance = defaultLayoutPlugin();
return (
<div className="player-container viewer-pdf-container">
<div className="player-container-inner">
<span>
<span>
<i className="material-icons">insert_drive_file</i>
</span>
</span>
<div className='pdf-container'>
<Worker workerUrl="https://unpkg.com/pdfjs-dist@3.4.120/build/pdf.worker.min.js">
<Viewer fileUrl={fileUrl} plugins={[defaultLayoutPluginInstance]} />
</Worker>
</div>
</div>
);
}

View File

@@ -589,27 +589,46 @@ function findGetParameter(parameterName) {
return result;
}
function handleCanvas(canvasElem) {
const Player = videojs(canvasElem);
function handleCanvas(videoElem) { // Make sure it's a video element
if (!videoElem || !videoElem.tagName || videoElem.tagName.toLowerCase() !== 'video') {
console.error('Invalid video element:', videoElem);
return;
}
const Player = videojs(videoElem);
Player.playsinline(true);
// TODO: Make them work only in embedded player...?
if (findGetParameter('muted') == 1) {
Player.muted(true);
}
if (findGetParameter('time') >= 0) {
Player.currentTime(findGetParameter('time'));
}
if (findGetParameter('autoplay') == 1) {
Player.play();
}
Player.on('loadedmetadata', function () {
const muted = parseInt(findGetParameter('muted'));
const autoplay = parseInt(findGetParameter('autoplay'));
const timestamp = parseInt(findGetParameter('t'));
if (muted == 1) {
Player.muted(true);
}
if (timestamp >= 0 && timestamp < Player.duration()) {
// Start the video from the given time
Player.currentTime(timestamp);
} else if (timestamp >= 0 && timestamp >= Player.duration()) {
// Restart the video if the given time is greater than the duration
Player.play();
}
if (autoplay === 1) {
Player.play();
}
});
}
const observer = new MutationObserver(function (mutations, me) {
const canvas = document.querySelector('.video-js.vjs-mediacms video');
if (canvas) {
handleCanvas(canvas);
me.disconnect();
return;
const observer = new MutationObserver((mutations, me) => {
const playerContainer = document.querySelector('.video-js.vjs-mediacms');
if (playerContainer) {
const video = playerContainer.querySelector('video');
if (video) {
handleCanvas(video);
me.disconnect();
}
}
});

View File

@@ -44,7 +44,7 @@ function headerPopupPages(user, popupNavItems, hasHeaderThemeSwitcher) {
<UserThumbnail size="medium" />
</span>
<span>
<span className="username">{user.username}</span>
<span className="username">{(user?.name || user?.email || user?.username || "User")}</span>
</span>
</a>
</PopupTop>

View File

@@ -192,12 +192,14 @@ export function VideoPlayer(props) {
document.addEventListener('visibilitychange', initPlayer);
}
player && player.player.one('loadedmetadata', () => {
/*
// We don't need this because we have a custom function in frontend/src/static/js/components/media-viewer/VideoViewer/index.js:617
player && player.player.one('loadedmetadata', () => {
const urlParams = new URLSearchParams(window.location.search);
const paramT = Number(urlParams.get('t'));
const timestamp = !isNaN(paramT) ? paramT : 0;
player.player.currentTime(timestamp);
});
}); */
return () => {
unsetPlayer();

View File

@@ -7,6 +7,8 @@ import ImageViewer from '../components/media-viewer/ImageViewer';
import PdfViewer from '../components/media-viewer/PdfViewer';
import VideoViewer from '../components/media-viewer/VideoViewer';
import { _VideoMediaPage } from './_VideoMediaPage';
import { formatInnerLink } from '../utils/helpers';
import {SiteContext} from '../utils/contexts/';
if (window.MediaCMS.site.devEnv) {
const extractUrlParams = () => {
@@ -52,7 +54,8 @@ export class MediaPage extends _VideoMediaPage {
case 'image':
return <ImageViewer />;
case 'pdf':
return <PdfViewer />;
const pdf_url = formatInnerLink(MediaPageStore.get('media-original-url'), SiteContext._currentValue.url);
return <PdfViewer fileUrl={pdf_url} />;
}
return <AttachmentViewer />;

View File

@@ -46,6 +46,11 @@ if (window.MediaCMS.site.devEnv) {
}
function PlayAllLink(props) {
if (!props.media || !props.media.length) {
return <span>{props.children}</span>;
}
let playAllUrl = props.media[0].url;
if (window.MediaCMS.site.devEnv && -1 < playAllUrl.indexOf('view?')) {

View File

@@ -1,4 +1,4 @@
import axios, { get as axiosGet, post as axiosPost, put as axiosPut } from 'axios';
import axios from 'axios';
export async function getRequest(url, sync, callback, errorCallback) {
const requestConfig = {
@@ -44,11 +44,11 @@ export async function getRequest(url, sync, callback, errorCallback) {
}
if (sync) {
await axiosGet(url, requestConfig)
await axios.get(url, requestConfig)
.then(responseHandler)
.catch(errorHandler || null);
} else {
axiosGet(url, requestConfig)
axios.get(url, requestConfig)
.then(responseHandler)
.catch(errorHandler || null);
}
@@ -70,11 +70,11 @@ export async function postRequest(url, postData, configData, sync, callback, err
}
if (sync) {
await axiosPost(url, postData, configData || null)
await axios.post(url, postData, configData || null)
.then(responseHandler)
.catch(errorHandler || null);
} else {
axiosPost(url, postData, configData || null)
axios.post(url, postData, configData || null)
.then(responseHandler)
.catch(errorHandler || null);
}
@@ -96,11 +96,11 @@ export async function putRequest(url, putData, configData, sync, callback, error
}
if (sync) {
await axiosPut(url, putData, configData || null)
await axios.put(url, putData, configData || null)
.then(responseHandler)
.catch(errorHandler || null);
} else {
axiosPut(url, putData, configData || null)
axios.put(url, putData, configData || null)
.then(responseHandler)
.catch(errorHandler || null);
}

View File

360
identity_providers/admin.py Normal file
View File

@@ -0,0 +1,360 @@
import csv
import logging
from allauth.socialaccount.admin import SocialAccountAdmin, SocialAppAdmin
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from django import forms
from django.conf import settings
from django.contrib import admin
from identity_providers.forms import ImportCSVsForm
from identity_providers.models import (
IdentityProviderCategoryMapping,
IdentityProviderGlobalRole,
IdentityProviderGroupRole,
IdentityProviderUserLog,
LoginOption,
)
from rbac.models import RBACGroup
from saml_auth.models import SAMLConfiguration
class IdentityProviderUserLogAdmin(admin.ModelAdmin):
list_display = [
'identity_provider',
'user',
'created_at',
]
list_filter = ['identity_provider', 'created_at']
search_fields = ['identity_provider__name', 'user__username', 'user__email', 'logs']
readonly_fields = ['identity_provider', 'user', 'created_at', 'logs']
class SAMLConfigurationInline(admin.StackedInline):
model = SAMLConfiguration
extra = 0
can_delete = True
max_num = 1
class IdentityProviderCategoryMappingInlineForm(forms.ModelForm):
class Meta:
model = IdentityProviderCategoryMapping
fields = ('name', 'map_to')
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
class IdentityProviderCategoryMappingInline(admin.TabularInline):
model = IdentityProviderCategoryMapping
form = IdentityProviderCategoryMappingInlineForm
extra = 0
can_delete = True
show_change_link = True
verbose_name = "Category Mapping"
verbose_name_plural = "Category Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline.html'
autocomplete_fields = ['map_to']
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('name', 'map_to') and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class RBACGroupInlineForm(forms.ModelForm):
class Meta:
model = RBACGroup
fields = ('uid', 'name')
labels = {
'uid': 'Group Attribute Value',
'name': 'Name',
}
help_texts = {
'uid': 'Identity Provider group attribute value',
'name': 'MediaCMS Group name',
}
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
class RBACGroupInline(admin.TabularInline):
model = RBACGroup
form = RBACGroupInlineForm
extra = 0
can_delete = True
show_change_link = True
verbose_name = "Group Mapping"
verbose_name_plural = "Group Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline_for_groups.html'
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('uid', 'name') and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class CustomSocialAppAdmin(SocialAppAdmin):
# The default SocialAppAdmin has been overriden to achieve a number of changes.
# If you need to add more fields (out of those that are hidden), or remove tabs, or
# change the ordering of fields, or the place where fields appear, don't forget to
# check the html template!
change_form_template = 'admin/socialaccount/socialapp/change_form.html'
list_display = ('get_config_name', 'get_protocol')
fields = ('provider', 'provider_id', 'name', 'client_id', 'sites', 'groups_csv', 'categories_csv')
form = ImportCSVsForm
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.inlines = []
if getattr(settings, 'USE_SAML', False):
self.inlines.append(SAMLConfigurationInline)
self.inlines.append(IdentityProviderGlobalRoleInline)
self.inlines.append(IdentityProviderGroupRoleInline)
self.inlines.append(RBACGroupInline)
self.inlines.append(IdentityProviderCategoryMappingInline)
def get_protocol(self, obj):
return obj.provider
def get_config_name(self, obj):
return obj.name
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'provider':
field.label = 'Protocol'
field.help_text = "The provider type, eg `google`. For SAML providers, make sure this is set to `saml` lowercase."
elif db_field.name == 'name':
field.label = 'IDP Config Name'
field.help_text = "This should be a unique name for the provider."
elif db_field.name == 'client_id':
field.help_text = 'App ID, or consumer key. For SAML providers, this will be part of the default login URL /accounts/saml/{client_id}/login/'
elif db_field.name == 'sites':
field.required = True
field.help_text = "Select at least one site where this social application is available. Required."
elif db_field.name == 'provider_id':
field.required = True
field.help_text = "This should be a unique identifier for the provider."
return field
get_config_name.short_description = 'IDP Config Name'
get_protocol.short_description = 'Protocol'
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
csv_file = form.cleaned_data.get('groups_csv')
if csv_file:
try:
csv_file.seek(0)
decoded_file = csv_file.read().decode('utf-8').splitlines()
csv_reader = csv.DictReader(decoded_file)
for row in csv_reader:
group_id = row.get('group_id')
name = row.get('name')
if group_id and name:
if not (RBACGroup.objects.filter(identity_provider=obj, uid=group_id).exists() or RBACGroup.objects.filter(identity_provider=obj, name=name).exists()):
try:
group = RBACGroup.objects.create(identity_provider=obj, uid=group_id, name=name) # noqa
except Exception as e:
logging.error(e)
except Exception as e:
logging.error(e)
csv_file = form.cleaned_data.get('categories_csv')
if csv_file:
from files.models import Category
try:
csv_file.seek(0)
decoded_file = csv_file.read().decode('utf-8').splitlines()
csv_reader = csv.DictReader(decoded_file)
for row in csv_reader:
group_id = row.get('group_id')
category_id = row.get('category_id')
if group_id and category_id:
category = Category.objects.filter(uid=category_id).first()
if category:
if not IdentityProviderCategoryMapping.objects.filter(identity_provider=obj, name=group_id, map_to=category).exists():
mapping = IdentityProviderCategoryMapping.objects.create(identity_provider=obj, name=group_id, map_to=category) # noqa
except Exception as e:
logging.error(e)
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for form in formset.forms:
if form.cleaned_data.get('should_delete', False) and form.instance.pk:
instances.remove(form.instance)
form.instance.delete()
for instance in instances:
instance.save()
formset.save_m2m()
class CustomSocialAccountAdmin(SocialAccountAdmin):
list_display = ('user', 'uid', 'get_provider')
def get_provider(self, obj):
return obj.provider
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'provider':
field.label = 'Provider ID'
return field
get_provider.short_description = 'Provider ID'
class IdentityProviderGroupRoleInlineForm(forms.ModelForm):
class Meta:
model = IdentityProviderGroupRole
fields = ('name', 'map_to')
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
def clean(self):
cleaned_data = super().clean()
name = cleaned_data.get('name')
identity_provider = getattr(self.instance, 'identity_provider', None)
if name and identity_provider:
if IdentityProviderGroupRole.objects.filter(identity_provider=identity_provider, name=name).exclude(pk=self.instance.pk).exists():
self.add_error('name', 'A group role mapping with this name already exists for this Identity provider.')
class IdentityProviderGroupRoleInline(admin.TabularInline):
model = IdentityProviderGroupRole
form = IdentityProviderGroupRoleInlineForm
extra = 0
verbose_name = "Group Role Mapping"
verbose_name_plural = "Group Role Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline.html'
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('name',) and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class IdentityProviderGlobalRoleInlineForm(forms.ModelForm):
class Meta:
model = IdentityProviderGlobalRole
fields = ('name', 'map_to')
# custom field to track if the row should be deleted
should_delete = forms.BooleanField(required=False, widget=forms.HiddenInput())
def clean(self):
cleaned_data = super().clean()
name = cleaned_data.get('name')
identity_provider = getattr(self.instance, 'identity_provider', None)
if name and identity_provider:
if IdentityProviderGlobalRole.objects.filter(identity_provider=identity_provider, name=name).exclude(pk=self.instance.pk).exists():
self.add_error('name', 'A global role mapping with this name already exists for this Identity provider.')
class IdentityProviderGlobalRoleInline(admin.TabularInline):
model = IdentityProviderGlobalRole
form = IdentityProviderGlobalRoleInlineForm
extra = 0
verbose_name = "Global Role Mapping"
verbose_name_plural = "Global Role Mapping"
template = 'admin/socialaccount/socialapp/custom_tabular_inline.html'
def formfield_for_dbfield(self, db_field, **kwargs):
formfield = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name in ('name',) and formfield:
formfield.widget.attrs.update(
{
'data-help-text': db_field.help_text,
'class': 'with-help-text',
}
)
return formfield
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
return formset
def has_delete_permission(self, request, obj=None):
return True
class LoginOptionAdmin(admin.ModelAdmin):
list_display = ('title', 'url', 'ordering', 'active')
list_editable = ('ordering', 'active')
list_filter = ('active',)
search_fields = ('title', 'url')
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
admin.site.register(IdentityProviderUserLog, IdentityProviderUserLogAdmin)
admin.site.unregister(SocialToken)
# This is unregistering the default Social App and registers the custom one here,
# with mostly name setting options
IdentityProviderUserLog._meta.verbose_name = "User Logs"
IdentityProviderUserLog._meta.verbose_name_plural = "User Logs"
SocialAccount._meta.verbose_name = "User Account"
SocialAccount._meta.verbose_name_plural = "User Accounts"
admin.site.unregister(SocialApp)
admin.site.register(SocialApp, CustomSocialAppAdmin)
admin.site.register(LoginOption, LoginOptionAdmin)
admin.site.unregister(SocialAccount)
admin.site.register(SocialAccount, CustomSocialAccountAdmin)
SocialApp._meta.verbose_name = "ID Provider"
SocialApp._meta.verbose_name_plural = "ID Providers"
SocialAccount._meta.app_config.verbose_name = "Identity Providers"

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class IdentityProvidersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'identity_providers'

View File

@@ -0,0 +1,69 @@
import csv
from allauth.socialaccount.models import SocialApp
from django import forms
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
class ImportCSVsForm(forms.ModelForm):
groups_csv = forms.FileField(
required=False,
label="CSV file",
help_text=mark_safe("Optionally, upload a CSV file to add multiple group mappings at once. <a href='/static/templates/group_mapping.csv' class='download-template'>Download Template</a>"),
)
categories_csv = forms.FileField(
required=False,
label="CSV file",
help_text=("Optionally, upload a CSV file to add multiple category mappings at once. <a href='/static/templates/category_mapping.csv' class='download-template'>Download Template</a>"),
)
class Meta:
model = SocialApp
fields = '__all__'
def clean_groups_csv(self):
groups_csv = self.cleaned_data.get('groups_csv')
if not groups_csv:
return groups_csv
if not groups_csv.name.endswith('.csv'):
raise ValidationError("Uploaded file must be a CSV file.")
try:
decoded_file = groups_csv.read().decode('utf-8').splitlines()
csv_reader = csv.reader(decoded_file)
headers = next(csv_reader, None)
if not headers or 'group_id' not in headers or 'name' not in headers:
raise ValidationError("CSV file must contain 'group_id' and 'name' headers. " f"Found headers: {', '.join(headers) if headers else 'none'}")
groups_csv.seek(0)
return groups_csv
except csv.Error:
raise ValidationError("Invalid CSV file. Please ensure the file is properly formatted.")
except UnicodeDecodeError:
raise ValidationError("Invalid file encoding. Please upload a CSV file with UTF-8 encoding.")
def clean_categories_csv(self):
categories_csv = self.cleaned_data.get('categories_csv')
if not categories_csv:
return categories_csv
if not categories_csv.name.endswith('.csv'):
raise ValidationError("Uploaded file must be a CSV file.")
try:
decoded_file = categories_csv.read().decode('utf-8').splitlines()
csv_reader = csv.reader(decoded_file)
headers = next(csv_reader, None)
if not headers or 'category_id' not in headers or 'group_id' not in headers:
raise ValidationError("CSV file must contain 'group_id' and 'category_id' headers. " f"Found headers: {', '.join(headers) if headers else 'none'}")
categories_csv.seek(0)
return categories_csv
except csv.Error:
raise ValidationError("Invalid CSV file. Please ensure the file is properly formatted.")
except UnicodeDecodeError:
raise ValidationError("Invalid file encoding. Please upload a CSV file with UTF-8 encoding.")

View File

@@ -0,0 +1,87 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('socialaccount', '0006_alter_socialaccount_extra_data'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='IdentityProviderUserLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('logs', models.TextField(blank=True, null=True)),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saml_logs', to='socialaccount.socialapp')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saml_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Identity Provider User Log',
'verbose_name_plural': 'Identity Provider User Logs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='IdentityProviderCategoryMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Identity Provider group attribute value', max_length=100, verbose_name='Group Attribute Value')),
('map_to', models.CharField(help_text='Category id', max_length=300)),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_mapping', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'Identity Provider Category Mapping',
'verbose_name_plural': 'Identity Provider Category Mappings',
'unique_together': {('identity_provider', 'name')},
},
),
migrations.CreateModel(
name='IdentityProviderGlobalRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Identity Provider role attribute value', max_length=100, verbose_name='Global Role Mapping')),
(
'map_to',
models.CharField(
choices=[
('user', 'Authenticated User'),
('advancedUser', 'Advanced User'),
('editor', 'MediaCMS Editor'),
('manager', 'MediaCMS Manager'),
('admin', 'MediaCMS Administrator'),
],
help_text='MediaCMS Global Role',
max_length=20,
),
),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_roles', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'Identity Provider Global Role Mapping',
'verbose_name_plural': 'Identity Provider Global Role Mappings',
'unique_together': {('identity_provider', 'name')},
},
),
migrations.CreateModel(
name='IdentityProviderGroupRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Identity Provider role attribute value', max_length=100, verbose_name='Group Role Mapping')),
('map_to', models.CharField(choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], help_text='MediaCMS Group Role', max_length=20)),
('identity_provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_roles', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'Identity Provider Group Role Mapping',
'verbose_name_plural': 'Identity Provider Group Role Mappings',
'unique_together': {('identity_provider', 'name')},
},
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.1.6 on 2025-03-20 18:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity_providers', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='LoginOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Display name for this login option (e.g. Login through DEIC)', max_length=100)),
('url', models.CharField(help_text='URL or path for this login option', max_length=255)),
('ordering', models.PositiveIntegerField(default=0, help_text='Display order (smaller numbers appear first)')),
('active', models.BooleanField(default=True, help_text='Whether this login option is currently active')),
],
options={
'verbose_name': 'Login Option',
'verbose_name_plural': 'Login Options',
'ordering': ['ordering'],
},
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.1.6 on 2025-03-25 15:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('identity_providers', '0002_loginoption'),
]
operations = [
migrations.AlterUniqueTogether(
name='identityprovidercategorymapping',
unique_together=set(),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-25 15:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0005_alter_category_uid'),
('identity_providers', '0003_alter_identityprovidercategorymapping_unique_together'),
]
operations = [
migrations.AlterField(
model_name='identityprovidercategorymapping',
name='map_to',
field=models.ForeignKey(help_text='Category id', on_delete=django.db.models.deletion.CASCADE, to='files.category'),
),
]

View File

@@ -0,0 +1,125 @@
from allauth.socialaccount.models import SocialApp
from django.core.exceptions import ValidationError
from django.db import models
class IdentityProviderUserLog(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='saml_logs')
user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name='saml_logs')
created_at = models.DateTimeField(auto_now_add=True)
logs = models.TextField(blank=True, null=True)
class Meta:
verbose_name = 'Identity Provider User Log'
verbose_name_plural = 'Identity Provider User Logs'
ordering = ['-created_at']
def __str__(self):
return f'SAML Log - {self.user.username} - {self.created_at}'
class IdentityProviderGroupRole(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='group_roles')
name = models.CharField(verbose_name='Group Role Mapping', max_length=100, help_text='Identity Provider role attribute value')
map_to = models.CharField(max_length=20, choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], help_text='MediaCMS Group Role')
class Meta:
verbose_name = 'Identity Provider Group Role Mapping'
verbose_name_plural = 'Identity Provider Group Role Mappings'
unique_together = ('identity_provider', 'name')
def __str__(self):
return f'Identity Provider Group Role Mapping {self.name}'
def save(self, *args, **kwargs):
if not self.identity_provider:
raise ValidationError({'identity_provider': 'Identity Provider is required.'})
if IdentityProviderGroupRole.objects.filter(identity_provider=self.identity_provider, name=self.name).exclude(pk=self.pk).exists():
raise ValidationError({'name': 'A group role mapping for this Identity Provider with this name already exists.'})
super().save(*args, **kwargs)
class IdentityProviderGlobalRole(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='global_roles')
name = models.CharField(verbose_name='Global Role Mapping', max_length=100, help_text='Identity Provider role attribute value')
map_to = models.CharField(
max_length=20,
choices=[('user', 'Authenticated User'), ('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')],
help_text='MediaCMS Global Role',
)
class Meta:
verbose_name = 'Identity Provider Global Role Mapping'
verbose_name_plural = 'Identity Provider Global Role Mappings'
unique_together = ('identity_provider', 'name')
def __str__(self):
return f'Identity Provider Global Role Mapping {self.name}'
def save(self, *args, **kwargs):
if not self.identity_provider:
raise ValidationError({'identity_provider': 'Identity Provider is required.'})
if IdentityProviderGlobalRole.objects.filter(identity_provider=self.identity_provider, name=self.name).exclude(pk=self.pk).exists():
raise ValidationError({'name': 'A global role mapping for this Identity Provider with this name already exists.'})
super().save(*args, **kwargs)
class IdentityProviderCategoryMapping(models.Model):
identity_provider = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='category_mapping')
name = models.CharField(verbose_name='Group Attribute Value', max_length=100, help_text='Identity Provider group attribute value')
map_to = models.ForeignKey('files.Category', on_delete=models.CASCADE, help_text='Category id')
class Meta:
verbose_name = 'Identity Provider Category Mapping'
verbose_name_plural = 'Identity Provider Category Mappings'
def clean(self):
if not self._state.adding and self.pk:
original = IdentityProviderCategoryMapping.objects.get(pk=self.pk)
if original.name != self.name:
raise ValidationError("Cannot change the name once it is set. First delete this entry and then create a new one instead.")
super().clean()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from rbac.models import RBACGroup
group = RBACGroup.objects.filter(identity_provider=self.identity_provider, uid=self.name).first()
if group:
group.categories.add(self.map_to)
return True
def __str__(self):
return f'Identity Provider Category Mapping {self.name}'
def delete(self, *args, **kwargs):
from rbac.models import RBACGroup
group = RBACGroup.objects.filter(identity_provider=self.identity_provider, uid=self.name).first()
if group:
group.categories.remove(self.map_to)
super().delete(*args, **kwargs)
class LoginOption(models.Model):
title = models.CharField(max_length=100, help_text="Display name for this login option (e.g. Login through DEIC)")
url = models.CharField(max_length=255, help_text="URL or path for this login option")
ordering = models.PositiveIntegerField(default=0, help_text="Display order (smaller numbers appear first)")
active = models.BooleanField(default=True, help_text="Whether this login option is currently active")
class Meta:
ordering = ['ordering']
verbose_name = "Login Option"
verbose_name_plural = "Login Options"
def __str__(self):
return self.title

View File

View File

0
rbac/__init__.py Normal file
View File

212
rbac/admin.py Normal file
View File

@@ -0,0 +1,212 @@
from django import forms
from django.conf import settings
from django.contrib import admin
from django.db import transaction
from django.utils.html import format_html
from files.models import Category
from users.models import User
from .models import RBACGroup, RBACMembership, RBACRole
class RoleFilter(admin.SimpleListFilter):
title = 'Role'
parameter_name = 'role'
def lookups(self, request, model_admin):
return RBACRole.choices
def queryset(self, request, queryset):
if self.value():
return queryset.filter(memberships__role=self.value()).distinct()
return queryset
class RBACGroupAdminForm(forms.ModelForm):
categories = forms.ModelMultipleChoiceField(
queryset=Category.objects.filter(is_rbac_category=True),
required=False,
widget=admin.widgets.FilteredSelectMultiple('Categories', False),
help_text='Select categories this RBAC group has access to',
)
members_field = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Members', False), help_text='Users with Member role', label=''
)
contributors_field = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Contributors', False), help_text='Users with Contributor role', label=''
)
managers_field = forms.ModelMultipleChoiceField(
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Managers', False), help_text='Users with Manager role', label=''
)
class Meta:
model = RBACGroup
fields = ('name',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields['categories'].initial = self.instance.categories.all()
self.fields['members_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.MEMBER)
self.fields['contributors_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.CONTRIBUTOR)
self.fields['managers_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.MANAGER)
def save(self, commit=True):
group = super().save(commit=True)
if commit:
self.save_m2m()
if 'categories' in self.cleaned_data:
self.instance.categories.set(self.cleaned_data['categories'])
return group
@transaction.atomic
def save_m2m(self):
if self.instance.pk:
member_users = self.cleaned_data['members_field']
contributor_users = self.cleaned_data['contributors_field']
manager_users = self.cleaned_data['managers_field']
self._update_role_memberships(RBACRole.MEMBER, member_users)
self._update_role_memberships(RBACRole.CONTRIBUTOR, contributor_users)
self._update_role_memberships(RBACRole.MANAGER, manager_users)
def _update_role_memberships(self, role, new_users):
new_user_ids = User.objects.filter(pk__in=new_users).values_list('pk', flat=True)
existing_users = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=role)
existing_user_ids = existing_users.values_list('pk', flat=True)
users_to_add = User.objects.filter(pk__in=new_user_ids).exclude(pk__in=existing_user_ids)
users_to_remove = existing_users.exclude(pk__in=new_user_ids)
for user in users_to_add:
RBACMembership.objects.get_or_create(user=user, rbac_group=self.instance, role=role)
RBACMembership.objects.filter(user__in=users_to_remove, rbac_group=self.instance, role=role).delete()
class RBACGroupAdmin(admin.ModelAdmin):
list_display = ('name', 'get_member_count', 'get_contributor_count', 'get_manager_count', 'categories_list')
form = RBACGroupAdminForm
list_filter = (RoleFilter,)
search_fields = ['name', 'uid', 'description', 'identity_provider__name']
filter_horizontal = ['categories']
change_form_template = 'admin/rbac/rbacgroup/change_form.html'
def get_list_filter(self, request):
list_filter = list(self.list_filter)
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_filter.insert(-1, "identity_provider")
return list_filter
def get_list_display(self, request):
list_display = list(self.list_display)
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_display.insert(-1, "identity_provider")
return list_display
def get_member_count(self, obj):
return obj.memberships.filter(role=RBACRole.MEMBER).count()
get_member_count.short_description = 'Members'
def get_contributor_count(self, obj):
return obj.memberships.filter(role=RBACRole.CONTRIBUTOR).count()
get_contributor_count.short_description = 'Contributors'
def get_manager_count(self, obj):
return obj.memberships.filter(role=RBACRole.MANAGER).count()
get_manager_count.short_description = 'Managers'
fieldsets = (
(
None,
{
'fields': ('uid', 'name', 'description', 'created_at', 'updated_at'),
},
),
)
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
fieldsets = (
(
None,
{
'fields': ('identity_provider', 'uid', 'name', 'description', 'created_at', 'updated_at'),
},
),
)
if obj:
fieldsets += (
('Members', {'fields': ['members_field'], 'description': 'Select users for members. The same user cannot be contributor or manager'}),
('Contributors', {'fields': ['contributors_field'], 'description': 'Select users for contributors. The same user cannot be member or manager'}),
('Managers', {'fields': ['managers_field'], 'description': 'Select users for managers. The same user cannot be member or contributor'}),
('Access To Categories', {'fields': ['categories'], 'classes': ['collapse', 'open'], 'description': 'Select which categories this RBAC group has access to'}),
)
return fieldsets
readonly_fields = ['created_at', 'updated_at']
def member_count(self, obj):
count = obj.memberships.count()
return format_html('<a href="?rbac_group__id__exact={}">{} members</a>', obj.id, count)
member_count.short_description = 'Members'
def categories_list(self, obj):
return ", ".join([c.title for c in obj.categories.all()])
categories_list.short_description = 'Categories'
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'social_app':
field.label = 'ID Provider'
return field
class RBACMembershipAdmin(admin.ModelAdmin):
list_display = ['user', 'rbac_group', 'role', 'joined_at', 'updated_at']
list_filter = ['role', 'rbac_group', 'joined_at', 'updated_at']
search_fields = ['user__username', 'user__email', 'rbac_group__name', 'rbac_group__uid']
raw_id_fields = ['user']
autocomplete_fields = ['user']
readonly_fields = ['joined_at', 'updated_at']
fieldsets = [(None, {'fields': ['user', 'rbac_group', 'role']}), ('Timestamps', {'fields': ['joined_at', 'updated_at'], 'classes': ['collapse']})]
if getattr(settings, 'USE_RBAC', False):
for field in RBACGroup._meta.fields:
if field.name == 'social_app':
field.verbose_name = "ID Provider"
RBACGroup._meta.verbose_name_plural = "Groups"
RBACGroup._meta.verbose_name = "Group"
RBACMembership._meta.verbose_name_plural = "Role Based Access Control Membership"
RBACGroup._meta.app_config.verbose_name = "Role Based Access Control"
admin.site.register(RBACGroup, RBACGroupAdmin)
admin.site.register(RBACMembership, RBACMembershipAdmin)

6
rbac/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RbacConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'rbac'

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.1.6 on 2025-03-18 17:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('files', '0004_alter_subtitle_options_category_identity_provider_and_more'),
('socialaccount', '0006_alter_socialaccount_extra_data'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RBACGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uid', models.CharField(help_text='Unique identifier for the RBAC group (unique per identity provider)', max_length=255)),
('name', models.CharField(max_length=100, help_text='MediaCMS Group name')),
('description', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('categories', models.ManyToManyField(blank=True, help_text='Categories this RBAC group has access to', related_name='rbac_groups', to='files.category')),
(
'identity_provider',
models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rbac_groups', to='socialaccount.socialapp', verbose_name='IDP Config Name'),
),
],
options={
'verbose_name': 'RBAC Group',
'verbose_name_plural': 'RBAC Groups',
},
),
migrations.CreateModel(
name='RBACMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], default='member', max_length=20)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('rbac_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='rbac.rbacgroup')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rbac_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'RBAC Membership',
'verbose_name_plural': 'RBAC Memberships',
'unique_together': {('user', 'rbac_group', 'role')},
},
),
migrations.AddField(
model_name='rbacgroup',
name='members',
field=models.ManyToManyField(related_name='rbac_groups', through='rbac.RBACMembership', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='rbacgroup',
unique_together={('name', 'identity_provider'), ('uid', 'identity_provider')},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.6 on 2025-03-25 14:18
from django.db import migrations, models
import rbac.models
class Migration(migrations.Migration):
dependencies = [
('rbac', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='rbacgroup',
name='uid',
field=models.CharField(default=rbac.models.generate_uid, help_text='Unique identifier for the RBAC group (unique per identity provider)', max_length=255),
),
]

View File

96
rbac/models.py Normal file
View File

@@ -0,0 +1,96 @@
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.db import models
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.utils.crypto import get_random_string
def generate_uid():
return get_random_string(length=10)
class RBACGroup(models.Model):
uid = models.CharField(max_length=255, default=generate_uid, help_text='Unique identifier for the RBAC group (unique per identity provider)')
name = models.CharField(max_length=100, help_text='MediaCMS Group name')
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# access to members through the membership model
members = models.ManyToManyField("users.User", through='RBACMembership', through_fields=('rbac_group', 'user'), related_name='rbac_groups')
categories = models.ManyToManyField('files.Category', related_name='rbac_groups', blank=True, help_text='Categories this RBAC group has access to')
identity_provider = models.ForeignKey(SocialApp, on_delete=models.SET_NULL, null=True, blank=True, related_name='rbac_groups', verbose_name='IDP Config Name')
def __str__(self):
name = f"{self.name}"
if self.identity_provider:
name = f"{name} for {self.identity_provider}"
return name
class Meta:
verbose_name = 'RBAC Group'
verbose_name_plural = 'RBAC Groups'
unique_together = [['uid', 'identity_provider'], ['name', 'identity_provider']]
class RBACRole(models.TextChoices):
MEMBER = 'member', 'Member'
CONTRIBUTOR = 'contributor', 'Contributor'
MANAGER = 'manager', 'Manager'
class RBACMembership(models.Model):
user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name='rbac_memberships')
rbac_group = models.ForeignKey(RBACGroup, on_delete=models.CASCADE, related_name='memberships')
role = models.CharField(max_length=20, choices=RBACRole.choices, default=RBACRole.MEMBER)
joined_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['user', 'rbac_group', 'role']
verbose_name = 'RBAC Membership'
verbose_name_plural = 'RBAC Memberships'
def clean(self):
super().clean()
return True
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def __str__(self):
return f'{self.user.username} - {self.rbac_group.name} ({self.role})'
@receiver(m2m_changed, sender=RBACGroup.categories.through)
def handle_rbac_group_categories_change(sender, instance, action, pk_set, **kwargs):
"""
Signal handler for when categories are added to or removed from an RBACGroup.
"""
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
return
from files.models import Category
from identity_providers.models import IdentityProviderCategoryMapping
if action == 'post_add':
if not instance.identity_provider:
return
# the following apply only if identity_provider is there
for category_id in pk_set:
category = Category.objects.get(pk=category_id)
mapping_exists = IdentityProviderCategoryMapping.objects.filter(identity_provider=instance.identity_provider, name=instance.uid, map_to=category).exists()
if not mapping_exists:
IdentityProviderCategoryMapping.objects.create(identity_provider=instance.identity_provider, name=instance.uid, map_to=category)
elif action == 'post_remove':
for category_id in pk_set:
category = Category.objects.get(pk=category_id)
IdentityProviderCategoryMapping.objects.filter(identity_provider=instance.identity_provider, name=instance.uid, map_to=category).delete()

0
rbac/tests.py Normal file
View File

0
rbac/views.py Normal file
View File

View File

@@ -1,16 +1,15 @@
-r requirements.txt
rpdb
tqdm
ipython
flake8
pylint
pep8
django-silk
pre-commit
pytest-cov
pytest-django
pytest-factoryboy
Faker
django-cors-headers
rpdb==0.2.0
tqdm==4.67.1
ipython==8.32.0
flake8==7.1.1
pylint==3.3.4
pep8==1.7.1
django-silk==5.3.2
pytest-cov==6.0.0
pytest-django==4.9.0
pytest-factoryboy==2.7.0
Faker==35.2.0
django-cors-headers==4.7.0

View File

@@ -1,21 +1,27 @@
Django==4.2.2
djangorestframework==3.14.0
django-allauth==0.54.0
psycopg==3.1.9
uwsgi==2.0.21
django-redis==5.3.0
celery==5.3.1
drf-yasg==1.21.6
Pillow==9.5.0
django-imagekit==4.1.0
markdown==3.4.3
django-filter==23.2
Django==5.1.6
djangorestframework==3.15.2
lxml==5.0.0 # dont use later version, as theres a strange error "lxml & xmlsec libxml2 library version mismatch"
python3-saml==1.16.0
django-allauth==65.4.1
psycopg==3.2.4
uwsgi==2.0.28
django-redis==5.4.0
celery==5.4.0
drf-yasg==1.21.8
Pillow==11.1.0
django-imagekit==5.0.0
markdown==3.7
django-filter==24.3
filetype==1.2.0
django-mptt==0.14.0
django-crispy-forms==1.13.0
requests==2.31.0
django-mptt==0.16.0
crispy-bootstrap5==2024.10
requests==2.32.3
django-celery-email==3.0.0
m3u8==3.5.0
django-ckeditor==6.6.1
django-debug-toolbar==4.1.0
m3u8==6.0.0
django-debug-toolbar==5.0.1
django-login-required-middleware==0.9.0
pre-commit==4.1.0
django-jazzmin==3.0.1
pysubs2==1.8.0
sentry-sdk[django]==2.23.1

0
saml_auth/__init__.py Normal file
View File

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