Compare commits

..

9 Commits

Author SHA1 Message Date
semantic-release-bot 51b1097509 chore(release): 8.2.0 [skip ci]
## [8.2.0](https://github.com/mediacms-io/mediacms/compare/v8.1.3...v8.2.0) (2026-05-31)

### Features

* configure SP certificate and private key via SAMLConfiguration ([#1531](https://github.com/mediacms-io/mediacms/issues/1531)) ([95644dc](https://github.com/mediacms-io/mediacms/commit/95644dc9615f428191d9fda0847c1b91a0b094a5))
2026-05-31 13:17:18 +00:00
Markos Gogoulos 95644dc961 feat: configure SP certificate and private key via SAMLConfiguration (#1531) 2026-05-31 16:16:46 +03:00
semantic-release-bot a3fe375a83 chore(release): 8.1.3 [skip ci]
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)

### Bug Fixes

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

### Bug Fixes

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

### Bug Fixes

* x-accell headers on uploaded poster ([#1526](https://github.com/mediacms-io/mediacms/issues/1526)) ([ae63a5a](https://github.com/mediacms-io/mediacms/commit/ae63a5af647c8865b96e6e50dda1ea9d29b5bd0b))
2026-05-18 11:25:57 +00:00
Markos Gogoulos ae63a5af64 fix: x-accell headers on uploaded poster (#1526) 2026-05-18 14:25:23 +03:00
22 changed files with 174 additions and 42 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## [8.2.0](https://github.com/mediacms-io/mediacms/compare/v8.1.3...v8.2.0) (2026-05-31)
### Features
* configure SP certificate and private key via SAMLConfiguration ([#1531](https://github.com/mediacms-io/mediacms/issues/1531)) ([95644dc](https://github.com/mediacms-io/mediacms/commit/95644dc9615f428191d9fda0847c1b91a0b094a5))
## [8.1.3](https://github.com/mediacms-io/mediacms/compare/v8.1.2...v8.1.3) (2026-05-19)
### Bug Fixes
* django connection settings ([#1529](https://github.com/mediacms-io/mediacms/issues/1529)) ([e89c4a3](https://github.com/mediacms-io/mediacms/commit/e89c4a3c8523574b5852a434ed67e281b6290584))
* prestart.sh loaddata re-runs on every container restart ([#1502](https://github.com/mediacms-io/mediacms/issues/1502)) ([777b06b](https://github.com/mediacms-io/mediacms/commit/777b06bbebf141e5b1cb27e17533fe65d57eb6cd))
## [8.1.2](https://github.com/mediacms-io/mediacms/compare/v8.1.1...v8.1.2) (2026-05-18)
### Bug Fixes
* remove redundant check ([#1528](https://github.com/mediacms-io/mediacms/issues/1528)) ([c7a673b](https://github.com/mediacms-io/mediacms/commit/c7a673bbbf46efc37621dc4a5109a85fc10e1317))
## [8.1.1](https://github.com/mediacms-io/mediacms/compare/v8.1.0...v8.1.1) (2026-05-18)
### Bug Fixes
* x-accell headers on uploaded poster ([#1526](https://github.com/mediacms-io/mediacms/issues/1526)) ([ae63a5a](https://github.com/mediacms-io/mediacms/commit/ae63a5af647c8865b96e6e50dda1ea9d29b5bd0b))
## [8.1.0](https://github.com/mediacms-io/mediacms/compare/v8.0.8...v8.1.0) (2026-05-17)
### Features
+12
View File
@@ -3,7 +3,9 @@ from __future__ import absolute_import
import os
from celery import Celery
from celery.signals import worker_process_init
from django.conf import settings
from django.db import connections
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.settings")
app = Celery("cms")
@@ -20,3 +22,13 @@ app.conf.task_always_eager = settings.CELERY_TASK_ALWAYS_EAGER
app.conf.worker_prefetch_multiplier = 1
@worker_process_init.connect
def close_db_pool_on_fork(**_):
# psycopg3's ConnectionPool is not fork-safe: children inherit dead sockets
# from the parent's pool and block on getconn() until PoolTimeout. Dispose
# the inherited pool here so each prefork child opens its own on first use.
# NB: plain conn.close() would only putconn() back into the broken pool.
for conn in connections.all():
conn.close_pool()
+19 -1
View File
@@ -407,7 +407,25 @@ LOGGING = {
},
}
DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql", "NAME": "mediacms", "HOST": "127.0.0.1", "PORT": "5432", "USER": "mediacms", "PASSWORD": "mediacms", "OPTIONS": {'pool': True}}}
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mediacms",
"HOST": "127.0.0.1",
"PORT": "5432",
"USER": "mediacms",
"PASSWORD": "mediacms",
"OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 8,
"timeout": 10,
"max_lifetime": 30 * 60,
"max_idle": 10 * 60,
}
},
}
}
REDIS_LOCATION = "redis://127.0.0.1:6379/1"
+1 -1
View File
@@ -1 +1 @@
VERSION = "8.1.0"
VERSION = "8.2.0"
+9 -1
View File
@@ -12,7 +12,15 @@ DATABASES = {
"PORT": os.getenv('POSTGRES_PORT', '5432'),
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
"OPTIONS": {'pool': True},
"OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 8,
"timeout": 10,
"max_lifetime": 30 * 60,
"max_idle": 10 * 60,
}
},
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ ADMIN_PASSWORD=${ADMIN_PASSWORD:-$RANDOM_ADMIN_PASS}
if [ X"$ENABLE_MIGRATIONS" = X"yes" ]; then
echo "Running migrations service"
python manage.py migrate
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell`
EXISTING_INSTALLATION=`echo "from users.models import User; print(User.objects.exists())" |python manage.py shell 2>/dev/null | tail -1`
if [ "$EXISTING_INSTALLATION" = "True" ]; then
echo "Loaddata has already run"
else
+2
View File
@@ -947,6 +947,8 @@ Select the SAML Configurations tab, create a new one and set:
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
6. **SP Certificate** (optional): SP x509 certificate (PEM). Enables encrypted/signed SAML communication. If set, the SP Private Key must also be provided, and the certificate is published in the SP metadata so the IDP can encrypt assertions to MediaCMS.
7. **SP Private Key** (optional): SP private key (PEM). Used to sign AuthnRequests/LogoutRequests and to decrypt assertions encrypted by the IDP. Required if SP Certificate is provided.
- Step 3: Set other Options
1. **Email Settings**:
+4 -2
View File
@@ -10,7 +10,7 @@ from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.files import File
from django.db import models
from django.db import models, transaction
from django.db.models import Func, Value
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
from django.dispatch import receiver
@@ -536,7 +536,9 @@ class Media(models.Model):
from .. import tasks
tasks.produce_sprite_from_video.delay(self.friendly_token)
# Defer until the surrounding transaction commits so the worker can
# actually find the Media row. Runs immediately if not in a tx.
transaction.on_commit(lambda token=self.friendly_token: tasks.produce_sprite_from_video.delay(token))
return True
def encode(self, profiles=[], force=True, chunkize=True):
+7 -3
View File
@@ -660,7 +660,9 @@ class MediaBulkUserActions(APIView):
# Prioritize category_uids
if category_uids:
categories = Category.objects.filter(uid__in=category_uids)
requested = Category.objects.filter(uid__in=category_uids)
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
categories = Category.objects.filter(id__in=allowed_ids)
elif lti_context_id:
# Filter categories by lti_context_id and ensure they ARE RBAC categories
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
@@ -691,9 +693,11 @@ class MediaBulkUserActions(APIView):
if not category_uids:
return Response({"detail": "category_uids is required for remove_from_category action"}, status=status.HTTP_400_BAD_REQUEST)
categories = Category.objects.filter(uid__in=category_uids)
requested = Category.objects.filter(uid__in=category_uids)
allowed_ids = [cat.id for cat in requested if not cat.is_rbac_category or request.user.has_contributor_access_to_category(cat)]
categories = Category.objects.filter(id__in=allowed_ids)
if not categories:
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0
for category in categories:
+36 -1
View File
@@ -1,7 +1,9 @@
import re
from urllib.parse import unquote
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
@@ -10,6 +12,7 @@ from ..methods import is_mediacms_editor
from ..models import Media
UID_RE = re.compile(r"[0-9a-f]{32}")
THUMBNAILS_PREFIX = "original/thumbnails/"
def _ttl():
@@ -23,6 +26,32 @@ def _extract_uid(uri):
return match.group(0) if match else None
def _relpath_from_uri(uri):
path = unquote(uri.split("?", 1)[0])
media_url = settings.MEDIA_URL
if path.startswith(media_url):
return path[len(media_url) :]
return None
def _lookup_uid_by_path(relpath):
path_key = f"xaccel:path:{relpath}"
cached = cache.get(path_key)
if cached is not None:
return cached or None
parts = relpath.split("/", 4)
if len(parts) < 5 or parts[2] != "user":
cache.set(path_key, "", _ttl())
return None
username = parts[3]
row = Media.objects.filter(user__username=username).filter(Q(uploaded_thumbnail=relpath) | Q(uploaded_poster=relpath)).values("uid").first()
uid_hex = row["uid"].hex if row else ""
cache.set(path_key, uid_hex, _ttl())
return uid_hex or None
def _lookup_state(uid):
"""Return (state, owner_id) for a uid, or (None, None) if missing.
@@ -76,7 +105,13 @@ def media_auth(request):
uri = request.META.get("HTTP_X_ORIGINAL_URI", "")
uid = _extract_uid(uri)
if not uid:
return HttpResponse(status=403)
# User-uploaded thumbnails/posters don't have the uid in the filename.
# Fall back to a per-path lookup, scoped to /original/thumbnails/.
relpath = _relpath_from_uri(uri)
if relpath and relpath.startswith(THUMBNAILS_PREFIX):
uid = _lookup_uid_by_path(relpath)
if not uid:
return HttpResponse(status=403)
user = request.user
cache_key = f"xaccel:auth:{uid}:{user.id if user.is_authenticated else 'anon'}"
-9
View File
@@ -1,16 +1,7 @@
from django.apps import AppConfig
from .keys import ensure_keys_exist
class LtiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'lti'
verbose_name = 'LTI 1.3 Integration'
def ready(self):
"""Initialize LTI app - ensure keys exist"""
try:
ensure_keys_exist()
except Exception:
pass
-7
View File
@@ -21,10 +21,3 @@ def get_jwks():
"""
public_key = load_public_key()
return {'keys': [public_key]}
def ensure_keys_exist():
"""Ensure key pair exists in database, generate if not"""
from .models import LTIToolKeys
LTIToolKeys.get_or_create_keys()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mediacms",
"version": "8.1.0",
"version": "8.2.0",
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
+5 -1
View File
@@ -1,5 +1,6 @@
import base64
import logging
import re
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialApp
@@ -22,7 +23,10 @@ class SAMLAccountAdapter(DefaultSocialAccountAdapter):
def populate_user(self, request, sociallogin, data):
user = sociallogin.user
user.username = sociallogin.account.uid
raw_uid = sociallogin.account.uid or ""
# Match the user URL pattern in users/urls.py: only [\w@._-] is reverse-able.
sanitized = re.sub(r"[^\w.@-]", "_", raw_uid, flags=re.ASCII)
user.username = sanitized[:150] if sanitized else raw_uid
for item in ["name", "first_name", "last_name"]:
if data.get(item):
setattr(user, item, data[item])
+1 -1
View File
@@ -51,7 +51,7 @@ class SAMLConfigurationAdmin(admin.ModelAdmin):
search_fields = ['social_app__name', 'idp_id', 'sp_metadata_url']
fieldsets = [
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert']}),
('Provider Settings', {'fields': ['social_app', 'idp_id', 'idp_cert', 'sp_cert', 'sp_private_key']}),
('URLs', {'fields': ['sso_url', 'slo_url', 'sp_metadata_url']}),
('Group Management', {'fields': ['remove_from_groups', 'save_saml_response_logs']}),
('Attribute Mapping', {'fields': ['uid', 'name', 'email', 'groups', 'first_name', 'last_name', 'user_logo', 'role']}),
+5 -9
View File
@@ -53,16 +53,12 @@ def build_sp_config(request, provider_config, org):
"binding": OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
},
}
if _sp_config.get("x509cert"):
sp_config["x509cert"] = _sp_config["x509cert"]
if _sp_config.get("private_key"):
sp_config["privateKey"] = _sp_config["private_key"]
avd = provider_config.get("advanced", {})
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"]
+3 -1
View File
@@ -154,7 +154,9 @@ sls = SLSView.as_view()
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)
custom_configuration = provider.app.saml_configurations.first()
provider_config = custom_configuration.saml_provider_settings if custom_configuration else provider.app.settings
config = build_saml_config(self.request, provider_config, organization_slug)
saml_settings = OneLogin_Saml2_Settings(settings=config, sp_validation_only=True)
metadata = saml_settings.get_sp_metadata()
errors = saml_settings.validate_metadata(metadata)
@@ -0,0 +1,23 @@
# Generated by Django 5.2.6 on 2026-05-31 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('saml_auth', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='samlconfiguration',
name='sp_cert',
field=models.TextField(blank=True, help_text='SP x509cert (PEM). Optional; required if SP private key is set.', null=True),
),
migrations.AddField(
model_name='samlconfiguration',
name='sp_private_key',
field=models.TextField(blank=True, help_text='SP private key (PEM). Optional; required if SP certificate is set.', null=True),
),
]
+11
View File
@@ -14,6 +14,8 @@ class SAMLConfiguration(models.Model):
# Certificates
idp_cert = models.TextField(help_text='x509cert')
sp_cert = models.TextField(blank=True, null=True, help_text='SP x509cert (PEM). Optional; required if SP private key is set.')
sp_private_key = models.TextField(blank=True, null=True, help_text='SP private key (PEM). Optional; required if SP certificate is set.')
# Attribute Mapping Fields
uid = models.CharField(max_length=100, help_text='eg eduPersonPrincipalName')
@@ -49,6 +51,11 @@ class SAMLConfiguration(models.Model):
if existing_conf.exists():
raise ValidationError({'social_app': 'Cannot create configuration for the same social app because one configuration already exists.'})
if self.sp_cert and not self.sp_private_key:
raise ValidationError({'sp_private_key': 'Required when SP certificate is provided.'})
if self.sp_private_key and not self.sp_cert:
raise ValidationError({'sp_cert': 'Required when SP private key is provided.'})
super().clean()
@property
@@ -56,6 +63,10 @@ class SAMLConfiguration(models.Model):
# provide settings in a way for Social App SAML provider
provider_settings = {}
provider_settings["sp"] = {"entity_id": self.sp_metadata_url}
if self.sp_cert:
provider_settings["sp"]["x509cert"] = self.sp_cert
if self.sp_private_key:
provider_settings["sp"]["private_key"] = self.sp_private_key
provider_settings["idp"] = {"slo_url": self.slo_url, "sso_url": self.sso_url, "x509cert": self.idp_cert, "entity_id": self.idp_id}
provider_settings["attribute_mapping"] = {
+1 -1
View File
@@ -2,4 +2,4 @@
exclude = .git,*migrations*
max-line-length = 119
#ignore=F401,F403,E501,W503
ignore=E501
ignore=E501,E203
+2 -2
View File
@@ -7,8 +7,8 @@ from django.utils.translation import gettext_lazy as _
@deconstructible
class ASCIIUsernameValidator(validators.RegexValidator):
regex = r"^[\w.@]+$"
message = _("Enter a valid username. This value may contain only " "English letters and numbers")
regex = r"^[\w.@-]+$"
message = _("Enter a valid username. This value may contain only English letters, numbers, and '_', '.', '@', '-'.")
flags = re.ASCII
+6
View File
@@ -1,3 +1,4 @@
from allauth.account.adapter import get_adapter
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.password_validation import validate_password
@@ -277,6 +278,11 @@ class UserList(APIView):
if not all([username, password, email, name]):
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
try:
username = get_adapter().clean_username(username, shallow=True)
except DjangoValidationError as e:
return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)