diff --git a/cms/celery.py b/cms/celery.py index 87371590..322bd31b 100644 --- a/cms/celery.py +++ b/cms/celery.py @@ -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() diff --git a/cms/settings.py b/cms/settings.py index 1632f4bd..1d06770b 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -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" diff --git a/deploy/docker/local_settings.py b/deploy/docker/local_settings.py index 0e2a3274..f8872f69 100644 --- a/deploy/docker/local_settings.py +++ b/deploy/docker/local_settings.py @@ -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, + } + }, } } diff --git a/files/models/media.py b/files/models/media.py index 4d505657..dab27167 100644 --- a/files/models/media.py +++ b/files/models/media.py @@ -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): diff --git a/files/views/media.py b/files/views/media.py index 859cc31c..2a568780 100644 --- a/files/views/media.py +++ b/files/views/media.py @@ -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: diff --git a/saml_auth/adapter.py b/saml_auth/adapter.py index 55fc14e9..f20731a3 100644 --- a/saml_auth/adapter.py +++ b/saml_auth/adapter.py @@ -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]) diff --git a/users/validators.py b/users/validators.py index bf85054e..8cd30fe9 100644 --- a/users/validators.py +++ b/users/validators.py @@ -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 diff --git a/users/views.py b/users/views.py index 1ecb1d6b..011c604c 100644 --- a/users/views.py +++ b/users/views.py @@ -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)