mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-20 13:36:05 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05414f66c7 | ||
|
|
8fecccce1c | ||
|
|
2a7123ca0b | ||
|
|
20f305e69e | ||
|
|
d1fda05fdc | ||
|
|
a02e0a8a66 | ||
|
|
21f76dbb6e | ||
|
|
50e9f3103f | ||
|
|
0b9a203123 | ||
|
|
5cbd815496 | ||
|
|
3a8cacc847 | ||
|
|
5402ee7bc5 | ||
|
|
a6a2b50c8d | ||
|
|
23e48a8bb7 | ||
|
|
313cd9cbc6 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
0
admin_customizations/__init__.py
Normal file
0
admin_customizations/__init__.py
Normal file
0
admin_customizations/admin.py
Normal file
0
admin_customizations/admin.py
Normal file
86
admin_customizations/apps.py
Normal file
86
admin_customizations/apps.py
Normal 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
|
||||
0
admin_customizations/migrations/__init__.py
Normal file
0
admin_customizations/migrations/__init__.py
Normal file
0
admin_customizations/models.py
Normal file
0
admin_customizations/models.py
Normal file
0
admin_customizations/tests.py
Normal file
0
admin_customizations/tests.py
Normal file
0
admin_customizations/views.py
Normal file
0
admin_customizations/views.py
Normal 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')
|
||||
|
||||
129
cms/settings.py
129
cms/settings.py
@@ -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
|
||||
|
||||
@@ -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
1
cms/version.py
Normal file
@@ -0,0 +1 @@
|
||||
VERSION = "5.0.0"
|
||||
@@ -1,5 +0,0 @@
|
||||
from pytest_factoryboy import register
|
||||
|
||||
from tests.users.factories import UserFactory
|
||||
|
||||
register(UserFactory)
|
||||
75
deic_setup_notes.md
Normal file
75
deic_setup_notes.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
144
docker-compose/docker-compose-dev-updated.yaml
Normal file
144
docker-compose/docker-compose-dev-updated.yaml
Normal 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:
|
||||
@@ -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.
|
||||
|
||||
|
||||
128
files/admin.py
128
files/admin.py
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
19
files/migrations/0005_alter_category_uid.py
Normal file
19
files/migrations/0005_alter_category_uid.py
Normal 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),
|
||||
),
|
||||
]
|
||||
17
files/migrations/0006_alter_category_title.py
Normal file
17
files/migrations/0006_alter_category_title.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
143
files/views.py
143
files/views.py
@@ -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
28290
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
5124
frontend/packages/player/dist/mediacms-player.js
vendored
5124
frontend/packages/player/dist/mediacms-player.js
vendored
File diff suppressed because it is too large
Load Diff
20010
frontend/packages/player/package-lock.json
generated
20010
frontend/packages/player/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
104
frontend/packages/scripts/dist/webpack-dev-env.js
vendored
104
frontend/packages/scripts/dist/webpack-dev-env.js
vendored
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
19334
frontend/packages/scripts/package-lock.json
generated
19334
frontend/packages/scripts/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
4644
frontend/packages/vjs-plugin-font-icons/package-lock.json
generated
4644
frontend/packages/vjs-plugin-font-icons/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
5953
frontend/packages/vjs-plugin/package-lock.json
generated
5953
frontend/packages/vjs-plugin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -520,6 +520,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
_video2.default.plugin('markers', registerVideoJsMarkersPlugin);
|
||||
videojs.registerPlugin('markers', registerVideoJsMarkersPlugin);
|
||||
});
|
||||
//# sourceMappingURL=videojs-markers.js.map
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?')) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
0
identity_providers/__init__.py
Normal file
0
identity_providers/__init__.py
Normal file
360
identity_providers/admin.py
Normal file
360
identity_providers/admin.py
Normal 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"
|
||||
6
identity_providers/apps.py
Normal file
6
identity_providers/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IdentityProvidersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'identity_providers'
|
||||
69
identity_providers/forms.py
Normal file
69
identity_providers/forms.py
Normal 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.")
|
||||
87
identity_providers/migrations/0001_initial.py
Normal file
87
identity_providers/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
27
identity_providers/migrations/0002_loginoption.py
Normal file
27
identity_providers/migrations/0002_loginoption.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
identity_providers/migrations/__init__.py
Normal file
0
identity_providers/migrations/__init__.py
Normal file
125
identity_providers/models.py
Normal file
125
identity_providers/models.py
Normal 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
|
||||
0
identity_providers/tests.py
Normal file
0
identity_providers/tests.py
Normal file
0
identity_providers/views.py
Normal file
0
identity_providers/views.py
Normal file
0
rbac/__init__.py
Normal file
0
rbac/__init__.py
Normal file
212
rbac/admin.py
Normal file
212
rbac/admin.py
Normal 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
6
rbac/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RbacConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'rbac'
|
||||
63
rbac/migrations/0001_initial.py
Normal file
63
rbac/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
19
rbac/migrations/0002_alter_rbacgroup_uid.py
Normal file
19
rbac/migrations/0002_alter_rbacgroup_uid.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
rbac/migrations/__init__.py
Normal file
0
rbac/migrations/__init__.py
Normal file
96
rbac/models.py
Normal file
96
rbac/models.py
Normal 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
0
rbac/tests.py
Normal file
0
rbac/views.py
Normal file
0
rbac/views.py
Normal 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
0
saml_auth/__init__.py
Normal file
153
saml_auth/adapter.py
Normal file
153
saml_auth/adapter.py
Normal 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
123
saml_auth/admin.py
Normal 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
6
saml_auth/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SamlAuthConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'saml_auth'
|
||||
0
saml_auth/custom/__init__.py
Normal file
0
saml_auth/custom/__init__.py
Normal file
61
saml_auth/custom/provider.py
Normal file
61
saml_auth/custom/provider.py
Normal 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
38
saml_auth/custom/urls.py
Normal 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
173
saml_auth/custom/utils.py
Normal 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
180
saml_auth/custom/views.py
Normal 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()
|
||||
44
saml_auth/migrations/0001_initial.py
Normal file
44
saml_auth/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
saml_auth/migrations/__init__.py
Normal file
0
saml_auth/migrations/__init__.py
Normal file
72
saml_auth/models.py
Normal file
72
saml_auth/models.py
Normal 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
0
saml_auth/tests.py
Normal file
0
saml_auth/views.py
Normal file
0
saml_auth/views.py
Normal file
1
static/12a4b90d32744616116e.png
Normal file
1
static/12a4b90d32744616116e.png
Normal file
@@ -0,0 +1 @@
|
||||
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAACTUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9E7JV4AAAAwdFJOUwAV7vMX/P76JPECqkdDdSb3cYwcciM4cPXhTukYEA7vdynyKF/lCeL7OnjtT3MSHhkh8ZcAAACHSURBVDjL7dK3DsJQDIXhPwlpdEKvoSWUBPD7Px0XBqYbs8AEZ7P1SZasAz8cseRrpDhfDjoJSjgdVRLe4BrrJILa58h8PZ7qJJjBKFSJP4RlqpMdrNxq4plDGzMnIg07yTORifNYtJp1O2HvbgfVZXgSFn3eEbVSfkcn5lWxo5Oe123zzyt3Bd0ph0DZVBoAAAAASUVORK5CYII="
|
||||
1
static/343ae76c75fa3e79d787.png
Normal file
1
static/343ae76c75fa3e79d787.png
Normal 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"
|
||||
1
static/4be63bf521d5ce87496b.png
Normal file
1
static/4be63bf521d5ce87496b.png
Normal file
@@ -0,0 +1 @@
|
||||
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAEvUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////6vRm/YAAABkdFJOUwDUHSjBZQX0/fXce78bwhKp8ife/sBVooz6bM7GqtWAAWtRiREh2/c5le8ZYjUIEMlyMJKU7Abi7mf28yxg+z5v4XY8KgdXX0ywIiBkU6ihAytd3x9QslJ98S27aS74W6NhSyVzU6IoAAAA8ElEQVQ4y+2TRXIDQRAEW9KiGM3MzEwyMzNj/v8Nng3r2Ot9gF2n7Ig8zEzXiPz57Oc31lhI22NhwtEttUyM6sbhFRzsbVUX01AY0AzH4uHmByehr0dRLuFO5DoX8CCkFMXiSeTeny8a7u0npigFnuXRHLUpGGI0Kgp8im2UumBoAUXxeZMPWIob7mjDVZQK7/LiUQl42KVZUU54/boosxnwCLRrD3NG8Rh2DY0nGYpryo59fgrZjDOVhc6QJTklXJaT5lrdYXvc9lhPzeJbXaFdWKW8IrlE5pe6zFCai2hUnumo0rV6iSilof7/b9byDZ5WLQHgKwvDAAAAAElFTkSuQmCC"
|
||||
1
static/511accb32ccb8952c708.png
Normal file
1
static/511accb32ccb8952c708.png
Normal file
@@ -0,0 +1 @@
|
||||
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAFBUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////26H9vQAAABqdFJOUwDF5PXttuge4gwT9zHUThZf2vMD8UXAeFfLmmxaNwrb6q+iqtYCCLEZYxC1aIJvuyog52Vdh88BBTrMM4+ocKfVKKwl9Lmj3Eot8rec+d2pMBpQ/lSknrJ3s/bYXHrTsPzwaXGS35bmgfvR//llAAAA/klEQVQ4y2NgGAVAwC9mK+AsyubuiVuJhIiTA5NXVpY9biVSbgzCjhxZWay4lVjZAQlRMVdrnCo42SwJudaAA7+8IZ8uK4upCY85Nkk5di4GD2+XLDAIljS2UAMKoABZFW4jxiwkYMbLrSCAooQ3CwsQQlECEZP08QNLhYVD+ChKFEEiHJwMAb5AWpqBIRLEF0SNGG2IUCAzkJZhYAgFUvI8qO7VAWnzDwI7mUkiAqRSGc3TmpiuVUJToiGCoYQPPez0QKJpUqmZCemssVFAtroqRvjaAIXZUqSTZJIz4oBMfS0scRASDTKIORFEsogLY08HMfFMYHlGcfaRnj0B5hlVM2lFOakAAAAASUVORK5CYII="
|
||||
1
static/58660785272880d26189.png
Normal file
1
static/58660785272880d26189.png
Normal file
@@ -0,0 +1 @@
|
||||
export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAiCAMAAAANmfvwAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAByUExURQAAAP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////15YQcQAAAAldFJOUwDv/H2h5Av1HIP08a9dXifgIuUbCoCsBBDStN7ykomBlOcZgiYhB3iQAAAAdUlEQVQ4y+3QuwKCMAyF4ZYmAYsXBFS8Iuh5/1e0Do5NdvVf+w0nde6fc5tljcJrYtvgnUYIFulbwIf1SiFdCdz1sVUio06GCAR1K+0L4Eh0zRJ8OtvklCXMU3q/MO+yROSZLrqJHJTBcyIP+18W30WiSX6jFy5aDFqlCyGCAAAAAElFTkSuQmCC"
|
||||
1
static/7b2a1c20f5cbcbc112ad.png
Normal file
1
static/7b2a1c20f5cbcbc112ad.png
Normal 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
Reference in New Issue
Block a user