Compare commits

..

15 Commits

Author SHA1 Message Date
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
244 changed files with 34942 additions and 56118 deletions

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

@@ -24,7 +24,7 @@ RUN mkdir -p /home/mediacms.io/bento4 && \
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
############ RUNTIME IMAGE ############
FROM python:3.13-bookworm as runtime-image
FROM python:3.13-bookworm AS runtime_image
SHELL ["/bin/bash", "-c"]
@@ -37,7 +37,7 @@ 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 -y && \
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
@@ -85,4 +85,4 @@ EXPOSE 9000 80
RUN chmod +x ./deploy/docker/entrypoint.sh
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
CMD ["./deploy/docker/start.sh"]
CMD ["./deploy/docker/start.sh"]

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)
- **SAML support
- **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

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 = [
@@ -46,5 +52,5 @@ MIDDLEWARE = [
DEBUG = True
CORS_ORIGIN_ALLOW_ALL = True
# STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static_files/'),)
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)
STATIC_ROOT = os.path.join(BASE_DIR, 'static_collected')

View File

@@ -115,7 +115,7 @@ 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"
@@ -232,7 +232,7 @@ CANNOT_ADD_MEDIA_MESSAGE = ""
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
@@ -247,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 = "/"
@@ -285,7 +256,7 @@ AUTHENTICATION_BACKENDS = (
)
INSTALLED_APPS = [
"django.contrib.admin",
"admin_customizations",
"django.contrib.auth",
"allauth",
"allauth.account",
@@ -294,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",
@@ -301,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 = [
@@ -348,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,
},
},
{
@@ -465,26 +446,6 @@ 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(6, "login_required.middleware.LoginRequiredMiddleware")
@@ -502,16 +463,6 @@ 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')),
@@ -543,3 +494,53 @@ SPRITE_NUM_SECS = 10
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

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.0"

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

@@ -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

@@ -28,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/

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:

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

@@ -21,7 +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-fames)
- [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.
@@ -861,3 +866,110 @@ By default while watching a video you can hover and see the small images named s
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,4 +1,10 @@
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 rbac.models import RBACGroup
from .models import (
Category,
@@ -49,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):
@@ -102,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

@@ -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

@@ -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))
@@ -957,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)
@@ -981,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
@@ -994,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
@@ -1210,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
@@ -1285,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:
@@ -1332,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")

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,13 +1,15 @@
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
@@ -30,9 +32,11 @@ from cms.permissions import (
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 (
@@ -54,6 +58,7 @@ from .models import (
Media,
Playlist,
PlaylistMedia,
Subtitle,
Tag,
)
from .serializers import (
@@ -76,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)
@@ -105,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):
@@ -330,6 +391,7 @@ def tos(request):
return render(request, "cms/tos.html", context)
@login_required
def upload_media(request):
"""Upload media view"""
@@ -478,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,
@@ -675,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
@@ -752,7 +818,7 @@ class MediaActions(APIView):
class MediaSearch(APIView):
"""
Retrieve results for searc
Retrieve results for search
Only GET is implemented here
"""
@@ -812,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)
@@ -928,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
@@ -1195,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
@@ -1355,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)
@@ -1423,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})

28290
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,10 +49,9 @@
"react-mentions": "^4.3.1",
"sortablejs": "^1.13.0",
"timeago.js": "^4.0.2",
"url-parse": "^1.5.1",
"pdfjs-dist": "^3.4.120",
"url-parse": "^1.5.10",
"pdfjs-dist": "3.4.120",
"@react-pdf-viewer/core": "^3.9.0",
"@react-pdf-viewer/default-layout": "^3.12.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

@@ -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) {
@@ -506,7 +502,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 + ' submission failed', 'commentSubmitFail'),
100
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

@@ -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

@@ -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

@@ -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,5 +1,7 @@
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
@@ -12,11 +14,14 @@ markdown==3.7
django-filter==24.3
filetype==1.2.0
django-mptt==0.16.0
django-crispy-forms==2.3
crispy-bootstrap5==2024.10
requests==2.32.3
django-celery-email==3.0.0
m3u8==6.0.0
django-ckeditor==6.7.2
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

153
saml_auth/adapter.py Normal file
View File

@@ -0,0 +1,153 @@
import base64
import logging
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.signals import social_account_updated
from django.core.files.base import ContentFile
from django.dispatch import receiver
from identity_providers.models import IdentityProviderUserLog
from rbac.models import RBACGroup, RBACMembership
class SAMLAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, socialaccount):
return True
def pre_social_login(self, request, sociallogin):
# data = sociallogin.data
return super().pre_social_login(request, sociallogin)
def populate_user(self, request, sociallogin, data):
user = sociallogin.user
user.username = sociallogin.account.uid
for item in ["name", "first_name", "last_name"]:
if data.get(item):
setattr(user, item, data[item])
sociallogin.data = data
# User is not retrieved through DB. Id is None.
return user
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form)
# Runs after new user is created
perform_user_actions(user, sociallogin.account)
return user
@receiver(social_account_updated)
def social_account_updated(sender, request, sociallogin, **kwargs):
# Runs after existing user is updated
user = sociallogin.user
# data is there due to populate_user
common_fields = sociallogin.data
perform_user_actions(user, sociallogin.account, common_fields)
def perform_user_actions(user, social_account, common_fields=None):
# common_fields is data already mapped to the attributes we want
if common_fields:
# check the following fields, if they are updated from the IDP side, update
# the user object too
fields_to_update = []
for item in ["name", "first_name", "last_name", "email"]:
if common_fields.get(item) and common_fields[item] != getattr(user, item):
setattr(user, item, common_fields[item])
fields_to_update.append(item)
if fields_to_update:
user.save(update_fields=fields_to_update)
# extra_data is the plain response from SAML provider
extra_data = social_account.extra_data
# there's no FK from Social Account to Social App
social_app = SocialApp.objects.filter(provider_id=social_account.provider).first()
saml_configuration = None
if social_app:
saml_configuration = social_app.saml_configurations.first()
add_user_logo(user, extra_data)
handle_role_mapping(user, extra_data, social_app, saml_configuration)
if saml_configuration and saml_configuration.save_saml_response_logs:
handle_saml_logs_save(user, extra_data, social_app)
return user
def add_user_logo(user, extra_data):
try:
if extra_data.get("jpegPhoto") and user.logo.name in ["userlogos/user.jpg", "", None]:
base64_string = extra_data.get("jpegPhoto")[0]
image_data = base64.b64decode(base64_string)
image_content = ContentFile(image_data)
user.logo.save('user.jpg', image_content, save=True)
except Exception as e:
logging.error(e)
return True
def handle_role_mapping(user, extra_data, social_app, saml_configuration):
if not saml_configuration:
return False
rbac_groups = []
role = "member"
# get groups key from configuration / attributes mapping
groups_key = saml_configuration.groups
groups = extra_data.get(groups_key, [])
# groups is a list of group_ids here
if groups:
rbac_groups = RBACGroup.objects.filter(identity_provider=social_app, uid__in=groups)
try:
# try to get the role, always use member as fallback
role_key = saml_configuration.role
role = extra_data.get(role_key, "student")
if role and isinstance(role, list):
role = role[0]
# populate global role
global_role = social_app.global_roles.filter(name=role).first()
if global_role:
user.set_role_from_mapping(global_role.map_to)
group_role = social_app.group_roles.filter(name=role).first()
if group_role:
if group_role.map_to in ['member', 'contributor', 'manager']:
role = group_role.map_to
except Exception as e:
logging.error(e)
role = role if role in ['member', 'contributor', 'manager'] else 'member'
for rbac_group in rbac_groups:
membership = RBACMembership.objects.filter(user=user, rbac_group=rbac_group).first()
if membership and role != membership.role:
membership.role = role
membership.save(update_fields=["role"])
if not membership:
try:
# use role from early above
membership = RBACMembership.objects.create(user=user, rbac_group=rbac_group, role=role)
except Exception as e:
logging.error(e)
# if remove_from_groups setting is True and user is part of groups for this
# social app that are not included anymore on the response, then remove user from group
if saml_configuration.remove_from_groups:
for group in user.rbac_groups.filter(identity_provider=social_app):
if group not in rbac_groups:
group.members.remove(user)
return True
def handle_saml_logs_save(user, extra_data, social_app):
# do not save jpegPhoto, if it exists
extra_data.pop("jpegPhoto", None)
log = IdentityProviderUserLog.objects.create(user=user, identity_provider=social_app, logs=extra_data) # noqa
return True

123
saml_auth/admin.py Normal file
View File

@@ -0,0 +1,123 @@
import csv
import logging
from django import forms
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.utils.html import format_html
from .models import SAMLConfiguration
class SAMLConfigurationForm(forms.ModelForm):
import_csv = forms.FileField(required=False, label="CSV file", help_text="Make sure headers are group_id, name")
class Meta:
model = SAMLConfiguration
fields = '__all__'
def clean_import_csv(self):
csv_file = self.cleaned_data.get('import_csv')
if not csv_file:
return csv_file
if not csv_file.name.endswith('.csv'):
raise ValidationError("Uploaded file must be a CSV file.")
try:
decoded_file = csv_file.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'}")
csv_file.seek(0)
return csv_file
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.")
class SAMLConfigurationAdmin(admin.ModelAdmin):
form = SAMLConfigurationForm
list_display = ['social_app', 'idp_id', 'remove_from_groups', 'save_saml_response_logs', 'view_metadata_url']
list_filter = ['social_app', 'remove_from_groups', 'save_saml_response_logs']
search_fields = ['social_app__name', 'idp_id', 'sp_metadata_url']
fieldsets = [
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert']}),
('URLs', {'fields': ['sso_url', 'slo_url', 'sp_metadata_url']}),
('Group Management', {'fields': ['remove_from_groups', 'save_saml_response_logs']}),
('Attribute Mapping', {'fields': ['uid', 'name', 'email', 'groups', 'first_name', 'last_name', 'user_logo', 'role']}),
(
'Email Settings',
{
'fields': [
'verified_email',
'email_authentication',
]
},
),
]
def view_metadata_url(self, obj):
"""Display metadata URL as a clickable link"""
return format_html('<a href="{}" target="_blank">View Metadata</a>', obj.sp_metadata_url)
view_metadata_url.short_description = 'Metadata'
def formfield_for_dbfield(self, db_field, **kwargs):
field = super().formfield_for_dbfield(db_field, **kwargs)
if db_field.name == 'social_app':
field.label = 'IDP Config Name'
return field
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
fieldsets = list(fieldsets)
fieldsets.append(('BULK GROUP MAPPINGS', {'fields': ('import_csv',), 'description': 'Optionally upload a CSV file with group_id and name as headers to add multiple group mappings at once.'}))
return fieldsets
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
csv_file = form.cleaned_data.get('import_csv')
if csv_file:
from rbac.models import RBACGroup
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(uid=group_id, social_app=obj.social_app).exists():
try:
rbac_group = RBACGroup.objects.create(uid=group_id, name=name, social_app=obj.social_app) # noqa
except Exception as e:
logging.error(e)
except Exception as e:
logging.error(e)
if getattr(settings, 'USE_SAML', False):
for field in SAMLConfiguration._meta.fields:
if field.name == 'social_app':
field.verbose_name = "ID Provider"
admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin)
SAMLConfiguration._meta.app_config.verbose_name = "SAML settings and logs"
SAMLConfiguration._meta.verbose_name_plural = "SAML Configuration"

6
saml_auth/apps.py Normal file
View File

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

View File

View File

@@ -0,0 +1,61 @@
from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from django.http import HttpResponseRedirect
from saml_auth.custom.utils import build_auth
class SAMLAccount(ProviderAccount):
pass
class CustomSAMLProvider(SAMLProvider):
def _extract(self, data):
custom_configuration = self.app.saml_configurations.first()
if custom_configuration:
provider_config = custom_configuration.saml_provider_settings
else:
provider_config = self.app.settings
raw_attributes = data.get_attributes()
attributes = {}
attribute_mapping = provider_config.get("attribute_mapping", self.default_attribute_mapping)
# map configured provider attributes
for key, provider_keys in attribute_mapping.items():
if isinstance(provider_keys, str):
provider_keys = [provider_keys]
for provider_key in provider_keys:
attribute_list = raw_attributes.get(provider_key, None)
# if more than one keys, get them all comma separated
if attribute_list is not None and len(attribute_list) > 1:
attributes[key] = ",".join(attribute_list)
break
elif attribute_list is not None and len(attribute_list) > 0:
attributes[key] = attribute_list[0]
break
attributes["email_verified"] = False
email_verified = provider_config.get("email_verified", False)
if email_verified:
if isinstance(email_verified, str):
email_verified = email_verified.lower() in ["true", "1", "t", "y", "yes"]
attributes["email_verified"] = email_verified
# return username as the uid value
if "uid" in attributes:
attributes["username"] = attributes["uid"]
# If we did not find an email, check if the NameID contains the email.
if not attributes.get("email") and (
data.get_nameid_format() == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
# Alternatively, if `use_id_for_email` is true, then we always interpret the nameID as email
or provider_config.get("use_nameid_for_email", False) # noqa
):
attributes["email"] = data.get_nameid()
return attributes
def redirect(self, request, process, next_url=None, data=None, **kwargs):
auth = build_auth(request, self)
# If we pass `return_to=None` `auth.login` will use the URL of the
# current view.
redirect = auth.login(return_to="")
self.stash_redirect_state(request, process, next_url, data, state_id=auth.get_last_request_id(), **kwargs)
return HttpResponseRedirect(redirect)

38
saml_auth/custom/urls.py Normal file
View File

@@ -0,0 +1,38 @@
from django.urls import include, path, re_path
from . import views
urlpatterns = [
re_path(
r"^saml/(?P<organization_slug>[^/]+)/",
include(
[
path(
"acs/",
views.acs,
name="saml_acs",
),
path(
"acs/finish/",
views.finish_acs,
name="saml_finish_acs",
),
path(
"sls/",
views.sls,
name="saml_sls",
),
path(
"metadata/",
views.metadata,
name="saml_metadata",
),
path(
"login/",
views.login,
name="saml_login",
),
]
),
)
]

173
saml_auth/custom/utils.py Normal file
View File

@@ -0,0 +1,173 @@
from urllib.parse import urlparse
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from django.urls import reverse
from django.utils.http import urlencode
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.constants import OneLogin_Saml2_Constants
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
def get_app_or_404(request, organization_slug):
adapter = get_adapter()
try:
return adapter.get_app(request, provider=SAMLProvider.id, client_id=organization_slug)
except SocialApp.DoesNotExist:
raise Http404(f"no SocialApp found with client_id={organization_slug}")
def prepare_django_request(request):
result = {
"https": "on" if request.is_secure() else "off",
"http_host": request.META["HTTP_HOST"],
"script_name": request.META["PATH_INFO"],
"get_data": request.GET.copy(),
# 'lowercase_urlencoding': True,
"post_data": request.POST.copy(),
}
return result
def build_sp_config(request, provider_config, org):
acs_url = request.build_absolute_uri(reverse("saml_acs", args=[org]))
sls_url = request.build_absolute_uri(reverse("saml_sls", args=[org]))
metadata_url = request.build_absolute_uri(reverse("saml_metadata", args=[org]))
# SP entity ID generated with the following precedence:
# 1. Explicitly configured SP via the SocialApp.settings
# 2. Fallback to the SAML metadata urlpattern
_sp_config = provider_config.get("sp", {})
sp_entity_id = _sp_config.get("entity_id")
sp_config = {
"entityId": sp_entity_id or metadata_url,
"assertionConsumerService": {
"url": acs_url,
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_POST,
},
"singleLogoutService": {
"url": sls_url,
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
},
}
avd = provider_config.get("advanced", {})
if avd.get("x509cert") is not None:
sp_config["x509cert"] = avd["x509cert"]
if avd.get("x509cert_new"):
sp_config["x509certNew"] = avd["x509cert_new"]
if avd.get("private_key") is not None:
sp_config["privateKey"] = avd["private_key"]
if avd.get("name_id_format") is not None:
sp_config["NameIDFormat"] = avd["name_id_format"]
return sp_config
def fetch_metadata_url_config(idp_config):
metadata_url = idp_config["metadata_url"]
entity_id = idp_config["entity_id"]
cache_key = f"saml.metadata.{metadata_url}.{entity_id}"
saml_config = cache.get(cache_key)
if saml_config is None:
saml_config = OneLogin_Saml2_IdPMetadataParser.parse_remote(
metadata_url,
entity_id=entity_id,
timeout=idp_config.get("metadata_request_timeout", 10),
)
cache.set(
cache_key,
saml_config,
idp_config.get("metadata_cache_timeout", 60 * 60 * 4),
)
return saml_config
def build_saml_config(request, provider_config, org):
avd = provider_config.get("advanced", {})
security_config = {
"authnRequestsSigned": avd.get("authn_request_signed", False),
"digestAlgorithm": avd.get("digest_algorithm", OneLogin_Saml2_Constants.SHA256),
"logoutRequestSigned": avd.get("logout_request_signed", False),
"logoutResponseSigned": avd.get("logout_response_signed", False),
"requestedAuthnContext": False,
"signatureAlgorithm": avd.get("signature_algorithm", OneLogin_Saml2_Constants.RSA_SHA256),
"signMetadata": avd.get("metadata_signed", False),
"wantAssertionsEncrypted": avd.get("want_assertion_encrypted", False),
"wantAssertionsSigned": avd.get("want_assertion_signed", False),
"wantMessagesSigned": avd.get("want_message_signed", False),
"nameIdEncrypted": avd.get("name_id_encrypted", False),
"wantNameIdEncrypted": avd.get("want_name_id_encrypted", False),
"allowSingleLabelDomains": avd.get("allow_single_label_domains", False),
"rejectDeprecatedAlgorithm": avd.get("reject_deprecated_algorithm", True),
"wantNameId": avd.get("want_name_id", False),
"wantAttributeStatement": avd.get("want_attribute_statement", True),
"allowRepeatAttributeName": avd.get("allow_repeat_attribute_name", True),
}
saml_config = {
"strict": avd.get("strict", True),
"security": security_config,
}
contact_person = provider_config.get("contact_person")
if contact_person:
saml_config["contactPerson"] = contact_person
organization = provider_config.get("organization")
if organization:
saml_config["organization"] = organization
idp = provider_config.get("idp")
if idp is None:
raise ImproperlyConfigured("`idp` missing")
metadata_url = idp.get("metadata_url")
if metadata_url:
meta_config = fetch_metadata_url_config(idp)
saml_config["idp"] = meta_config["idp"]
else:
saml_config["idp"] = {
"entityId": idp["entity_id"],
"x509cert": idp["x509cert"],
"singleSignOnService": {"url": idp["sso_url"]},
}
slo_url = idp.get("slo_url")
if slo_url:
saml_config["idp"]["singleLogoutService"] = {"url": slo_url}
saml_config["sp"] = build_sp_config(request, provider_config, org)
return saml_config
def encode_relay_state(state):
params = {"state": state}
return urlencode(params)
def decode_relay_state(relay_state):
"""According to the spec, RelayState need not be a URL, yet,
``onelogin.saml2` exposes it as ``return_to -- The target URL the user
should be redirected to after login``. Also, for an IdP initiated login
sometimes a URL is used.
"""
next_url = None
if relay_state:
parts = urlparse(relay_state)
if parts.scheme or parts.netloc or (parts.path and parts.path.startswith("/")):
next_url = relay_state
return next_url
def build_auth(request, provider):
req = prepare_django_request(request)
custom_configuration = provider.app.saml_configurations.first()
if custom_configuration:
custom_settings = custom_configuration.saml_provider_settings
config = build_saml_config(request, custom_settings, provider.app.client_id)
else:
config = build_saml_config(request, provider.app.settings, provider.app.client_id)
auth = OneLogin_Saml2_Auth(req, config)
return auth

180
saml_auth/custom/views.py Normal file
View File

@@ -0,0 +1,180 @@
import binascii
import logging
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.internal.decorators import login_not_required
from allauth.core.internal import httpkit
from allauth.socialaccount.helpers import (
complete_social_login,
render_authentication_error,
)
from allauth.socialaccount.providers.base.constants import AuthError, AuthProcess
from allauth.socialaccount.providers.base.views import BaseLoginView
from allauth.socialaccount.sessions import LoginSession
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from onelogin.saml2.auth import OneLogin_Saml2_Settings
from onelogin.saml2.errors import OneLogin_Saml2_Error
from .utils import build_auth, build_saml_config, decode_relay_state, get_app_or_404
logger = logging.getLogger(__name__)
class SAMLViewMixin:
def get_app(self, organization_slug):
app = get_app_or_404(self.request, organization_slug)
return app
def get_provider(self, organization_slug):
app = self.get_app(organization_slug)
return app.get_provider(self.request)
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class ACSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
url = reverse(
"saml_finish_acs",
kwargs={"organization_slug": organization_slug},
)
response = HttpResponseRedirect(url)
acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
acs_session.store.update({"request": httpkit.serialize_request(request)})
acs_session.save(response)
return response
acs = ACSView.as_view()
@method_decorator(login_not_required, name="dispatch")
class FinishACSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
acs_session = LoginSession(request, "saml_acs_session", "saml-acs-session")
acs_request = None
acs_request_data = acs_session.store.get("request")
if acs_request_data:
acs_request = httpkit.deserialize_request(acs_request_data, HttpRequest())
acs_session.delete()
if not acs_request:
logger.error("Unable to finish login, SAML ACS session missing")
return render_authentication_error(request, provider)
auth = build_auth(acs_request, provider)
error_reason = None
errors = []
try:
# We're doing the check for a valid `InResponeTo` ourselves later on
# (*) by checking if there is a matching state stashed.
auth.process_response(request_id=None)
except binascii.Error:
errors = ["invalid_response"]
error_reason = "Invalid response"
except OneLogin_Saml2_Error as e:
errors = ["error"]
error_reason = str(e)
if not errors:
errors = auth.get_errors()
if errors:
# e.g. ['invalid_response']
error_reason = auth.get_last_error_reason() or error_reason
logger.error("Error processing SAML ACS response: %s: %s" % (", ".join(errors), error_reason))
return render_authentication_error(
request,
provider,
extra_context={
"saml_errors": errors,
"saml_last_error_reason": error_reason,
},
)
if not auth.is_authenticated():
return render_authentication_error(request, provider, error=AuthError.CANCELLED)
login = provider.sociallogin_from_response(request, auth)
# (*) If we (the SP) initiated the login, there should be a matching
# state.
state_id = auth.get_last_response_in_response_to()
if state_id:
login.state = provider.unstash_redirect_state(request, state_id)
else:
# IdP initiated SSO
reject = provider.app.settings.get("advanced", {}).get("reject_idp_initiated_sso", True)
if reject:
logger.error("IdP initiated SSO rejected")
return render_authentication_error(request, provider)
next_url = decode_relay_state(acs_request.POST.get("RelayState"))
login.state["process"] = AuthProcess.LOGIN
if next_url:
login.state["next"] = next_url
return complete_social_login(request, login)
finish_acs = FinishACSView.as_view()
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class SLSView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
auth = build_auth(self.request, provider)
should_logout = request.user.is_authenticated
account_adapter = get_account_adapter(request)
def force_logout():
account_adapter.logout(request)
redirect_to = None
error_reason = None
try:
redirect_to = auth.process_slo(delete_session_cb=force_logout, keep_local_session=not should_logout)
except OneLogin_Saml2_Error as e:
error_reason = str(e)
errors = auth.get_errors()
if errors:
error_reason = auth.get_last_error_reason() or error_reason
logger.error("Error processing SAML SLS response: %s: %s" % (", ".join(errors), error_reason))
resp = HttpResponse(error_reason, content_type="text/plain")
resp.status_code = 400
return resp
if not redirect_to:
redirect_to = account_adapter.get_logout_redirect_url(request)
return HttpResponseRedirect(redirect_to)
sls = SLSView.as_view()
@method_decorator(login_not_required, name="dispatch")
class MetadataView(SAMLViewMixin, View):
def dispatch(self, request, organization_slug):
provider = self.get_provider(organization_slug)
config = build_saml_config(self.request, provider.app.settings, organization_slug)
saml_settings = OneLogin_Saml2_Settings(settings=config, sp_validation_only=True)
metadata = saml_settings.get_sp_metadata()
errors = saml_settings.validate_metadata(metadata)
if len(errors) > 0:
resp = JsonResponse({"errors": errors})
resp.status_code = 500
return resp
return HttpResponse(content=metadata, content_type="text/xml")
metadata = MetadataView.as_view()
@method_decorator(login_not_required, name="dispatch")
class LoginView(SAMLViewMixin, BaseLoginView):
def get_provider(self):
app = self.get_app(self.kwargs["organization_slug"])
return app.get_provider(self.request)
login = LoginView.as_view()

View File

@@ -0,0 +1,44 @@
# 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):
initial = True
dependencies = [
('socialaccount', '0006_alter_socialaccount_extra_data'),
]
operations = [
migrations.CreateModel(
name='SAMLConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sso_url', models.URLField(help_text='Sign-in URL')),
('slo_url', models.URLField(help_text='Sign-out URL')),
('sp_metadata_url', models.URLField(help_text='https://host/saml/metadata')),
('idp_id', models.URLField(help_text='Identity Provider ID')),
('idp_cert', models.TextField(help_text='x509cert')),
('uid', models.CharField(help_text='eg eduPersonPrincipalName', max_length=100)),
('name', models.CharField(blank=True, help_text='eg displayName', max_length=100, null=True)),
('email', models.CharField(blank=True, help_text='eg mail', max_length=100, null=True)),
('groups', models.CharField(blank=True, help_text='eg isMemberOf', max_length=100, null=True)),
('first_name', models.CharField(blank=True, help_text='eg gn', max_length=100, null=True)),
('last_name', models.CharField(blank=True, help_text='eg sn', max_length=100, null=True)),
('user_logo', models.CharField(blank=True, help_text='eg jpegPhoto', max_length=100, null=True)),
('role', models.CharField(blank=True, help_text='eduPersonPrimaryAffiliation', max_length=100, null=True)),
('verified_email', models.BooleanField(default=False, help_text='Mark email as verified')),
('email_authentication', models.BooleanField(default=False, help_text='Use email authentication too')),
('remove_from_groups', models.BooleanField(default=False, help_text='Automatically remove from groups')),
('save_saml_response_logs', models.BooleanField(default=True)),
('social_app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saml_configurations', to='socialaccount.socialapp')),
],
options={
'verbose_name': 'SAML Configuration',
'verbose_name_plural': 'SAML Configurations',
'unique_together': {('social_app', 'idp_id')},
},
),
]

View File

72
saml_auth/models.py Normal file
View File

@@ -0,0 +1,72 @@
from allauth.socialaccount.models import SocialApp
from django.core.exceptions import ValidationError
from django.db import models
class SAMLConfiguration(models.Model):
social_app = models.ForeignKey(SocialApp, on_delete=models.CASCADE, related_name='saml_configurations')
# URLs
sso_url = models.URLField(help_text='Sign-in URL')
slo_url = models.URLField(help_text='Sign-out URL')
sp_metadata_url = models.URLField(help_text='https://host/saml/metadata')
idp_id = models.URLField(help_text='Identity Provider ID')
# Certificates
idp_cert = models.TextField(help_text='x509cert')
# Attribute Mapping Fields
uid = models.CharField(max_length=100, help_text='eg eduPersonPrincipalName')
name = models.CharField(max_length=100, blank=True, null=True, help_text='eg displayName')
email = models.CharField(max_length=100, blank=True, null=True, help_text='eg mail')
groups = models.CharField(max_length=100, blank=True, null=True, help_text='eg isMemberOf')
first_name = models.CharField(max_length=100, blank=True, null=True, help_text='eg gn')
last_name = models.CharField(max_length=100, blank=True, null=True, help_text='eg sn')
user_logo = models.CharField(max_length=100, blank=True, null=True, help_text='eg jpegPhoto')
role = models.CharField(max_length=100, blank=True, null=True, help_text='eduPersonPrimaryAffiliation')
verified_email = models.BooleanField(default=False, help_text='Mark email as verified')
email_authentication = models.BooleanField(default=False, help_text='Use email authentication too')
remove_from_groups = models.BooleanField(default=False, help_text='Automatically remove from groups')
save_saml_response_logs = models.BooleanField(default=True)
class Meta:
verbose_name = 'SAML Configuration'
verbose_name_plural = 'SAML Configurations'
unique_together = ['social_app', 'idp_id']
def __str__(self):
return f'SAML Config for {self.social_app.name} - {self.idp_id}'
def clean(self):
existing_conf = SAMLConfiguration.objects.filter(social_app=self.social_app)
if self.pk:
existing_conf = existing_conf.exclude(pk=self.pk)
if existing_conf.exists():
raise ValidationError({'social_app': 'Cannot create configuration for the same social app because one configuration already exists.'})
super().clean()
@property
def saml_provider_settings(self):
# provide settings in a way for Social App SAML provider
provider_settings = {}
provider_settings["sp"] = {"entity_id": self.sp_metadata_url}
provider_settings["idp"] = {"slo_url": self.slo_url, "sso_url": self.sso_url, "x509cert": self.idp_cert, "entity_id": self.idp_id}
provider_settings["attribute_mapping"] = {
"uid": self.uid,
"name": self.name,
"role": self.role,
"email": self.email,
"groups": self.groups,
"first_name": self.first_name,
"last_name": self.last_name,
}
provider_settings["email_verified"] = self.verified_email
provider_settings["email_authentication"] = self.email_authentication
return provider_settings

0
saml_auth/tests.py Normal file
View File

0
saml_auth/views.py Normal file
View File

View File

@@ -0,0 +1 @@
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAACTUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9E7JV4AAAAwdFJOUwAV7vMX/P76JPECqkdDdSb3cYwcciM4cPXhTukYEA7vdynyKF/lCeL7OnjtT3MSHhkh8ZcAAACHSURBVDjL7dK3DsJQDIXhPwlpdEKvoSWUBPD7Px0XBqYbs8AEZ7P1SZasAz8cseRrpDhfDjoJSjgdVRLe4BrrJILa58h8PZ7qJJjBKFSJP4RlqpMdrNxq4plDGzMnIg07yTORifNYtJp1O2HvbgfVZXgSFn3eEbVSfkcn5lWxo5Oe123zzyt3Bd0ph0DZVBoAAAAASUVORK5CYII="

View File

@@ -0,0 +1 @@
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAYAAAA6RwvCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAqtJREFUeNrsmE9LVFEYxueOM6NJiqLpVDgLFaONklBiVBQolZqRJRZlH6Ft22pXy75A+BVs0SaohkrBibDEINLShf0RG7O0dNLb88pz43C9M3POHbNZ+MKPweG95z7n/XfOaNm2HcgHCwbyxPJGSOg/vXcnOAWugmYwYG1xjTSBPtAL6pXvv21FRMpAF+gHJ0DYw2f+Xwo5BC6Dc6Ami+/4ZgupBme4+6PA0nwusRlC5GVH+PKzoCqN3yLZ5SHwRS5CJNw94BJoyeC3BmbBDxD1ELEMxkI+2v04204KsDyLv0TgM8WI8EIPnykwrSukji0nu2/U8F9lFJJgB4hlmFljIJVJSDFoA1fAaQ4hHVugiF+gFOwBBRn8R9JN1v3gIgfPPoO0pShggakoZ01kO0YSqpAS0MHdSxSKDGtHUjBHMWIVbOWARg29cYRI3m+5Rq6uScV/4YIWqWCL6tg78NERchdUGgqQA+oro7CmtGS1RiepNsrn1/MnI/ietJDmw0v0naUgR0TUUMT6IPs7FZXTVyr8MOgG7R6pWmUEkhQQVD53G3SVajKRn7mFqCa9fxB0klrwiTURVCZmiO1Z7EOEpLaBmwvo3EdE1H2KSSkiImBvmmmpY8Og1eSq+BM8V1raZgRiOYgQe+nnzhpnjagHWa6W8CNEqnuGNRHkQSat992nCJvPGwtJMpSFyh1EjvXXzkAyNCn8t35/TjxRWtbmQSYpmuSYnjNYa9wdTRMhQ3LJZTScGgkzXdd4R33sp1BNhUxxJxFGxLmN3wQPGZmT4I7GWiO5CJGXP2UEwvz7Bnig+KyA6+ACC9rLfrO2XKtjoBnQBCZAHLRn8a0Hj+yN9gEUuf1NhcgkPgBimv4RcNslZNDL11SIX86DV+A9OOblY23/f8RlfwQYAHTF0+BsYemdAAAAAElFTkSuQmCC"

View File

@@ -0,0 +1 @@
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAEvUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////6vRm/YAAABkdFJOUwDUHSjBZQX0/fXce78bwhKp8ife/sBVooz6bM7GqtWAAWtRiREh2/c5le8ZYjUIEMlyMJKU7Abi7mf28yxg+z5v4XY8KgdXX0ywIiBkU6ihAytd3x9QslJ98S27aS74W6NhSyVzU6IoAAAA8ElEQVQ4y+2TRXIDQRAEW9KiGM3MzEwyMzNj/v8Nng3r2Ot9gF2n7Ig8zEzXiPz57Oc31lhI22NhwtEttUyM6sbhFRzsbVUX01AY0AzH4uHmByehr0dRLuFO5DoX8CCkFMXiSeTeny8a7u0npigFnuXRHLUpGGI0Kgp8im2UumBoAUXxeZMPWIob7mjDVZQK7/LiUQl42KVZUU54/boosxnwCLRrD3NG8Rh2DY0nGYpryo59fgrZjDOVhc6QJTklXJaT5lrdYXvc9lhPzeJbXaFdWKW8IrlE5pe6zFCai2hUnumo0rV6iSilof7/b9byDZ5WLQHgKwvDAAAAAElFTkSuQmCC"

View File

@@ -0,0 +1 @@
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAFBUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////26H9vQAAABqdFJOUwDF5PXttuge4gwT9zHUThZf2vMD8UXAeFfLmmxaNwrb6q+iqtYCCLEZYxC1aIJvuyog52Vdh88BBTrMM4+ocKfVKKwl9Lmj3Eot8rec+d2pMBpQ/lSknrJ3s/bYXHrTsPzwaXGS35bmgfvR//llAAAA/klEQVQ4y2NgGAVAwC9mK+AsyubuiVuJhIiTA5NXVpY9biVSbgzCjhxZWay4lVjZAQlRMVdrnCo42SwJudaAA7+8IZ8uK4upCY85Nkk5di4GD2+XLDAIljS2UAMKoABZFW4jxiwkYMbLrSCAooQ3CwsQQlECEZP08QNLhYVD+ChKFEEiHJwMAb5AWpqBIRLEF0SNGG2IUCAzkJZhYAgFUvI8qO7VAWnzDwI7mUkiAqRSGc3TmpiuVUJToiGCoYQPPez0QKJpUqmZCemssVFAtroqRvjaAIXZUqSTZJIz4oBMfS0scRASDTKIORFEsogLY08HMfFMYHlGcfaRnj0B5hlVM2lFOakAAAAASUVORK5CYII="

View File

@@ -0,0 +1 @@
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAByUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////15YQcQAAAAldFJOUwDv/H2h5Av1HIP08a9dXifgIuUbCoCsBBDStN7ykomBlOcZgiYhB3iQAAAAdUlEQVQ4y+3QuwKCMAyF4ZYmAYsXBFS8Iuh5/1e0Do5NdvVf+w0nde6fc5tljcJrYtvgnUYIFulbwIf1SiFdCdz1sVUio06GCAR1K+0L4Eh0zRJ8OtvklCXMU3q/MO+yROSZLrqJHJTBcyIP+18W30WiSX6jFy5aDFqlCyGCAAAAAElFTkSuQmCC"

View File

@@ -0,0 +1 @@
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAADAUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wOj5kwAAAA/dFJOUwDtO9fWOjRj0LlD/kr9K9OTvzCLBjNc+B45YpJxQIoP4U7R34kSVVKH8AnO3UYvCB3mLEVoSeh+PCo9Qdk1wa0WUTEAAACgSURBVDjL5dBHDsJADAVQQwglCb333nvv/PvfCpslaJwtUrwY64+eLM8QBbgKmWQ6p5M6gF5bJZBaqqQvZKeS8ALu2Wff9fHqIzb7y/1JdPM8h2ajQatW+SEHXuVF9AC2q7nsFSp9kxjfJojiwMn6vA7llJHIBFfOvJlMx0O7wz1rJhOODe4RM+lydLhbZtKUn9SnVDkWdWJzjP45CVS9AX9hKlcwUFOvAAAAAElFTkSuQmCC"

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