diff --git a/apps/activity/api/views.py b/apps/activity/api/views.py index 19618a5b..afa41ea7 100644 --- a/apps/activity/api/views.py +++ b/apps/activity/api/views.py @@ -1,10 +1,10 @@ # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from api.filters import RegexSafeSearchFilter from api.viewsets import ReadProtectedModelViewSet from django.core.exceptions import ValidationError from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import SearchFilter from rest_framework.response import Response from rest_framework import status @@ -32,7 +32,7 @@ class ActivityViewSet(ReadProtectedModelViewSet): """ queryset = Activity.objects.order_by('id') serializer_class = ActivitySerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club', 'date_start', 'date_end', 'valid', 'open', ] search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name', @@ -50,7 +50,7 @@ class GuestViewSet(ReadProtectedModelViewSet): """ queryset = Guest.objects.order_by('id') serializer_class = GuestSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name', 'inviter__alias__normalized_name', ] search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name', @@ -65,7 +65,7 @@ class EntryViewSet(ReadProtectedModelViewSet): """ queryset = Entry.objects.order_by('id') serializer_class = EntrySerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['activity', 'time', 'note', 'guest', ] search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name', '$guest__last_name', '$guest__first_name', ] @@ -79,7 +79,7 @@ class OpenerViewSet(ReadProtectedModelViewSet): """ queryset = Opener.objects serializer_class = OpenerSerializer - filter_backends = [SearchFilter, DjangoFilterBackend] + filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend] search_fields = ['$opener__alias__name', '$opener__alias__normalized_name', '$activity__name'] filterset_fields = ['opener', 'opener__noteuser__user', 'activity'] diff --git a/apps/activity/views.py b/apps/activity/views.py index 27801e1b..b596eef0 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -19,6 +19,7 @@ from django.views.decorators.cache import cache_page from django.views.generic import DetailView, TemplateView, UpdateView from django.views.generic.list import ListView from django_tables2.views import MultiTableMixin +from api.viewsets import is_regex from note.models import Alias, NoteSpecial, NoteUser from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin, ProtectedCreateView @@ -228,13 +229,16 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] - if pattern[0] != "^": - pattern = "^" + pattern + + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__istartswith" + pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern guest_qs = guest_qs.filter( - Q(first_name__iregex=pattern) - | Q(last_name__iregex=pattern) - | Q(inviter__alias__name__iregex=pattern) - | Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern)) + Q(**{f"first_name{suffix}": pattern}) + | Q(**{f"last_name{suffix}": pattern}) + | Q(**{f"inviter__alias__name{suffix}": pattern}) + | Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)}) ) else: guest_qs = guest_qs.none() @@ -266,11 +270,15 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] + + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__icontains" note_qs = note_qs.filter( - Q(note__noteuser__user__first_name__iregex=pattern) - | Q(note__noteuser__user__last_name__iregex=pattern) - | Q(name__iregex=pattern) - | Q(normalized_name__iregex=Alias.normalize(pattern)) + Q(**{f"note__noteuser__user__first_name{suffix}": pattern}) + | Q(**{f"note__noteuser__user__last_name{suffix}": pattern}) + | Q(**{f"name{suffix}": pattern}) + | Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)}) ) else: note_qs = note_qs.none() diff --git a/apps/api/filters.py b/apps/api/filters.py new file mode 100644 index 00000000..cb51c37c --- /dev/null +++ b/apps/api/filters.py @@ -0,0 +1,42 @@ +import re +from functools import lru_cache + +from rest_framework.filters import SearchFilter + + +class RegexSafeSearchFilter(SearchFilter): + @lru_cache + def validate_regex(self, search_term) -> bool: + try: + re.compile(search_term) + return True + except re.error: + return False + + def get_search_fields(self, view, request): + """ + Ensure that given regex are valid. + If not, we consider that the user is trying to search by substring. + """ + search_fields = super().get_search_fields(view, request) + search_terms = self.get_search_terms(request) + + for search_term in search_terms: + if not self.validate_regex(search_term): + # Invalid regex. We assume we don't query by regex but by substring. + search_fields = [f.replace('$', '') for f in search_fields] + break + + return search_fields + + def get_search_terms(self, request): + """ + Ensure that search field is a valid regex query. If not, we remove extra characters. + """ + terms = super().get_search_terms(request) + if not all(self.validate_regex(term) for term in terms): + # Invalid regex. If a ^ is prefixed to the search term, we remove it. + terms = [term[1:] if term[0] == '^' else term for term in terms] + # Same for dollars. + terms = [term[:-1] if term[-1] == '$' else term for term in terms] + return terms diff --git a/apps/api/tests.py b/apps/api/tests.py index 285acc46..6be818bf 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -12,11 +12,12 @@ from django.contrib.contenttypes.models import ContentType from django.db.models.fields.files import ImageFieldFile from django.test import TestCase from django_filters.rest_framework import DjangoFilterBackend +from phonenumbers import PhoneNumber +from rest_framework.filters import OrderingFilter +from api.filters import RegexSafeSearchFilter from member.models import Membership, Club from note.models import NoteClub, NoteUser, Alias, Note from permission.models import PermissionMask, Permission, Role -from phonenumbers import PhoneNumber -from rest_framework.filters import SearchFilter, OrderingFilter from .viewsets import ContentTypeViewSet, UserViewSet @@ -87,7 +88,7 @@ class TestAPI(TestCase): resp = self.client.get(url + f"?ordering=-{field}") self.assertEqual(resp.status_code, 200) - if SearchFilter in backends: + if RegexSafeSearchFilter in backends: # Basic search for field in viewset.search_fields: obj = self.fix_note_object(obj, field) diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py index 35dad92d..b1d42c50 100644 --- a/apps/api/viewsets.py +++ b/apps/api/viewsets.py @@ -1,19 +1,29 @@ # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +import re + from django.contrib.contenttypes.models import ContentType from django_filters.rest_framework import DjangoFilterBackend from django.db.models import Q from django.conf import settings from django.contrib.auth.models import User -from rest_framework.filters import SearchFilter from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet from permission.backends import PermissionBackend from note.models import Alias +from .filters import RegexSafeSearchFilter from .serializers import UserSerializer, ContentTypeSerializer +def is_regex(pattern): + try: + re.compile(pattern) + return True + except (re.error, TypeError): + return False + + class ReadProtectedModelViewSet(ModelViewSet): """ Protect a ModelViewSet by filtering the objects that the user cannot see. @@ -60,34 +70,38 @@ class UserViewSet(ReadProtectedModelViewSet): if "search" in self.request.GET: pattern = self.request.GET["search"] + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__istartswith" + prefix = "^" if valid_regex else "" # Filter with different rules # We use union-all to keep each filter rule sorted in result queryset = queryset.filter( # Match without normalization - note__alias__name__iregex="^" + pattern + Q(**{f"note__alias__name{suffix}": prefix + pattern}) ).union( queryset.filter( # Match with normalization - Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - & ~Q(note__alias__name__iregex="^" + pattern) + Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)}) + & ~Q(**{f"note__alias__name{suffix}": prefix + pattern}) ), all=True, ).union( queryset.filter( # Match on lower pattern - Q(note__alias__normalized_name__iregex="^" + pattern.lower()) - & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - & ~Q(note__alias__name__iregex="^" + pattern) + Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()}) + & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)}) + & ~Q(**{f"note__alias__name{suffix}": prefix + pattern}) ), all=True, ).union( queryset.filter( # Match on firstname or lastname - (Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern)) - & ~Q(note__alias__normalized_name__iregex="^" + pattern.lower()) - & ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - & ~Q(note__alias__name__iregex="^" + pattern) + (Q(**{f"last_name{suffix}": prefix + pattern}) | Q(**{f"first_name{suffix}": prefix + pattern})) + & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()}) + & ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)}) + & ~Q(**{f"note__alias__name{suffix}": prefix + pattern}) ), all=True, ) @@ -107,6 +121,6 @@ class ContentTypeViewSet(ReadOnlyModelViewSet): """ queryset = ContentType.objects.order_by('id') serializer_class = ContentTypeSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['id', 'app_label', 'model', ] search_fields = ['$app_label', '$model', ] diff --git a/apps/member/api/views.py b/apps/member/api/views.py index ab6146ed..a24a12d6 100644 --- a/apps/member/api/views.py +++ b/apps/member/api/views.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.filters import OrderingFilter +from api.filters import RegexSafeSearchFilter from api.viewsets import ReadProtectedModelViewSet from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer @@ -17,7 +18,7 @@ class ProfileViewSet(ReadProtectedModelViewSet): """ queryset = Profile.objects.order_by('id') serializer_class = ProfileSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section", 'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration', @@ -34,7 +35,7 @@ class ClubViewSet(ReadProtectedModelViewSet): """ queryset = Club.objects.order_by('id') serializer_class = ClubSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start', 'membership_end', ] @@ -49,7 +50,7 @@ class MembershipViewSet(ReadProtectedModelViewSet): """ queryset = Membership.objects.order_by('id') serializer_class = MembershipSerializer - filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] + filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter] filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', 'user__username', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', diff --git a/apps/member/views.py b/apps/member/views.py index d652b6b0..ba8a57e0 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -18,6 +18,7 @@ from django.views.generic import DetailView, UpdateView, TemplateView from django.views.generic.edit import FormMixin from django_tables2.views import SingleTableView from rest_framework.authtoken.models import Token +from api.viewsets import is_regex from note.models import Alias, NoteClub, NoteUser, Trust from note.models.transactions import Transaction, SpecialTransaction from note.tables import HistoryTable, AliasTable, TrustTable, TrustedTable @@ -219,16 +220,20 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__istartswith" + prefix = "^" if valid_regex else "" qs = qs.filter( - username__iregex="^" + pattern + Q(**{f"username{suffix}": prefix + pattern}) ).union( qs.filter( - (Q(alias__iregex="^" + pattern) - | Q(normalized_alias__iregex="^" + Alias.normalize(pattern)) - | Q(last_name__iregex="^" + pattern) - | Q(first_name__iregex="^" + pattern) + (Q(**{f"alias{suffix}": prefix + pattern}) + | Q(**{f"normalized_alias{suffix}": prefix + Alias.normalize(pattern)}) + | Q(**{f"last_name{suffix}": prefix + pattern}) + | Q(**{f"first_name{suffix}": prefix + pattern}) | Q(email__istartswith=pattern)) - & ~Q(username__iregex="^" + pattern) + & ~Q(**{f"username{suffix}": prefix + pattern}) ), all=True) else: qs = qs.none() @@ -410,10 +415,15 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): if "search" in self.request.GET: pattern = self.request.GET["search"] + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__istartswith" + prefix = "^" if valid_regex else "" + qs = qs.filter( - Q(name__iregex=pattern) - | Q(note__alias__name__iregex=pattern) - | Q(note__alias__normalized_name__iregex=Alias.normalize(pattern)) + Q(**{f"name{suffix}": prefix + pattern}) + | Q(**{f"note__alias__name{suffix}": prefix + pattern}) + | Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)}) ) return qs @@ -912,10 +922,15 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV if 'search' in self.request.GET: pattern = self.request.GET['search'] + + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__istartswith" + prefix = "^" if valid_regex else "" qs = qs.filter( - Q(user__first_name__iregex='^' + pattern) - | Q(user__last_name__iregex='^' + pattern) - | Q(user__note__alias__normalized_name__iregex='^' + Alias.normalize(pattern)) + Q(**{f"user__first_name{suffix}": prefix + pattern}) + | Q(**{f"user__last_name{suffix}": prefix + pattern}) + | Q(**{f"user__note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)}) ) only_active = "only_active" not in self.request.GET or self.request.GET["only_active"] != '0' diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 0aba9adc..ee1479be 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -1,16 +1,16 @@ # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -import re from django.conf import settings from django.db.models import Q from django.core.exceptions import ValidationError from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import OrderingFilter, SearchFilter -from rest_framework import viewsets +from rest_framework.filters import OrderingFilter +from rest_framework import status, viewsets from rest_framework.response import Response -from rest_framework import status -from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet +from api.filters import RegexSafeSearchFilter +from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet, \ + is_regex from permission.backends import PermissionBackend from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer, \ @@ -29,7 +29,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet): """ queryset = Note.objects.order_by('id') serializer_class = NotePolymorphicSerializer - filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter, OrderingFilter] filterset_fields = ['alias__name', 'polymorphic_ctype', 'is_active', 'balance', 'last_negative', 'created_at', ] search_fields = ['$alias__normalized_name', '$alias__name', '$polymorphic_ctype__model', '$noteuser__user__last_name', '$noteuser__user__first_name', '$noteuser__user__email', @@ -48,10 +48,14 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet): .distinct() alias = self.request.query_params.get("alias", ".*") + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(alias) + suffix = '__iregex' if valid_regex else '__istartswith' + alias_prefix = '^' if valid_regex else '' queryset = queryset.filter( - Q(alias__name__iregex="^" + alias) - | Q(alias__normalized_name__iregex="^" + Alias.normalize(alias)) - | Q(alias__normalized_name__iregex="^" + alias.lower()) + Q(**{f"alias__name{suffix}": alias_prefix + alias}) + | Q(**{f"alias__normalized_name{suffix}": alias_prefix + Alias.normalize(alias)}) + | Q(**{f"alias__normalized_name{suffix}": alias_prefix + alias.lower()}) ) return queryset.order_by("id") @@ -65,7 +69,7 @@ class TrustViewSet(ReadProtectedModelViewSet): """ queryset = Trust.objects serializer_class = TrustSerializer - filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] + filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter] search_fields = ['$trusting__alias__name', '$trusting__alias__normalized_name', '$trusted__alias__name', '$trusted__alias__normalized_name'] filterset_fields = ['trusting', 'trusting__noteuser__user', 'trusted', 'trusted__noteuser__user'] @@ -91,11 +95,11 @@ class AliasViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Alias` objects, serialize it to JSON with the given serializer, - then render it on /api/note/aliases/ + then render it on /api/note/alias/ """ queryset = Alias.objects serializer_class = AliasSerializer - filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] + filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] @@ -126,18 +130,22 @@ class AliasViewSet(ReadProtectedModelViewSet): alias = self.request.query_params.get("alias", None) if alias: + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(alias) + suffix = '__iregex' if valid_regex else '__istartswith' + alias_prefix = '^' if valid_regex else '' queryset = queryset.filter( - name__iregex="^" + alias + **{f"name{suffix}": alias_prefix + alias} ).union( queryset.filter( - Q(normalized_name__iregex="^" + Alias.normalize(alias)) - & ~Q(name__iregex="^" + alias) + Q(**{f"normalized_name{suffix}": alias_prefix + Alias.normalize(alias)}) + & ~Q(**{f"name{suffix}": alias_prefix + alias}) ), all=True).union( queryset.filter( - Q(normalized_name__iregex="^" + alias.lower()) - & ~Q(normalized_name__iregex="^" + Alias.normalize(alias)) - & ~Q(name__iregex="^" + alias) + Q(**{f"normalized_name{suffix}": "^" + alias.lower()}) + & ~Q(**{f"normalized_name{suffix}": "^" + Alias.normalize(alias)}) + & ~Q(**{f"name{suffix}": "^" + alias}) ), all=True) @@ -147,7 +155,7 @@ class AliasViewSet(ReadProtectedModelViewSet): class ConsumerViewSet(ReadOnlyProtectedModelViewSet): queryset = Alias.objects serializer_class = ConsumerSerializer - filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend] + filter_backends = [RegexSafeSearchFilter, OrderingFilter, DjangoFilterBackend] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] @@ -166,11 +174,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): alias = self.request.query_params.get("alias", None) # Check if this is a valid regex. If not, we won't check regex - try: - re.compile(alias) - valid_regex = True - except (re.error, TypeError): - valid_regex = False + valid_regex = is_regex(alias) suffix = '__iregex' if valid_regex else '__istartswith' alias_prefix = '^' if valid_regex else '' queryset = queryset.prefetch_related('note') @@ -207,7 +211,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet): """ queryset = TemplateCategory.objects.order_by('name') serializer_class = TemplateCategorySerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'templates', 'templates__name'] search_fields = ['$name', '$templates__name', ] @@ -220,7 +224,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): """ queryset = TransactionTemplate.objects.order_by('name') serializer_class = TransactionTemplateSerializer - filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] + filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter] filterset_fields = ['name', 'amount', 'display', 'category', 'category__name', ] search_fields = ['$name', '$category__name', ] ordering_fields = ['amount', ] @@ -234,7 +238,7 @@ class TransactionViewSet(ReadProtectedModelViewSet): """ queryset = Transaction.objects.order_by('-created_at') serializer_class = TransactionPolymorphicSerializer - filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] + filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend, OrderingFilter] filterset_fields = ['source', 'source_alias', 'source__alias__name', 'source__alias__normalized_name', 'destination', 'destination_alias', 'destination__alias__name', 'destination__alias__normalized_name', 'quantity', 'polymorphic_ctype', 'amount', diff --git a/apps/note/views.py b/apps/note/views.py index a84e3636..7055ea12 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -13,6 +13,7 @@ from django.views.generic import CreateView, UpdateView, DetailView from django.urls import reverse_lazy from django_tables2 import SingleTableView from activity.models import Entry +from api.viewsets import is_regex from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin from note_kfet.inputs import AmountInput @@ -89,11 +90,15 @@ class TransactionTemplateListView(ProtectQuerysetMixin, LoginRequiredMixin, Sing qs = super().get_queryset().distinct() if "search" in self.request.GET: pattern = self.request.GET["search"] + + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix = "__iregex" if valid_regex else "__icontains" qs = qs.filter( - Q(name__iregex=pattern) - | Q(destination__club__name__iregex=pattern) - | Q(category__name__iregex=pattern) - | Q(description__iregex=pattern) + Q(**{f"name{suffix}": pattern}) + | Q(**{f"destination__club__name{suffix}": pattern}) + | Q(**{f"category__name{suffix}": pattern}) + | Q(**{f"description{suffix}": pattern}) ) qs = qs.order_by('-display', 'category__name', 'destination__club__name', 'name') @@ -223,7 +228,10 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView if "type" in data and data["type"]: transactions = transactions.filter(polymorphic_ctype__in=data["type"]) if "reason" in data and data["reason"]: - transactions = transactions.filter(reason__iregex=data["reason"]) + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(data["reason"]) + suffix = "__iregex" if valid_regex else "__istartswith" + transactions = transactions.filter(Q(**{f"reason{suffix}": data["reason"]})) if "valid" in data and data["valid"]: transactions = transactions.filter(valid=data["valid"]) if "amount_gte" in data and data["amount_gte"]: diff --git a/apps/permission/api/views.py b/apps/permission/api/views.py index 5296e29b..39ff353c 100644 --- a/apps/permission/api/views.py +++ b/apps/permission/api/views.py @@ -1,9 +1,9 @@ # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from api.viewsets import ReadOnlyProtectedModelViewSet from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import SearchFilter +from api.filters import RegexSafeSearchFilter +from api.viewsets import ReadOnlyProtectedModelViewSet from .serializers import PermissionSerializer, RoleSerializer from ..models import Permission, Role @@ -17,9 +17,9 @@ class PermissionViewSet(ReadOnlyProtectedModelViewSet): """ queryset = Permission.objects.order_by('id') serializer_class = PermissionSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['model', 'type', 'query', 'mask', 'field', 'permanent', ] - search_fields = ['$model__name', '$query', '$description', ] + search_fields = ['$model__model', '$query', '$description', ] class RoleViewSet(ReadOnlyProtectedModelViewSet): @@ -30,6 +30,6 @@ class RoleViewSet(ReadOnlyProtectedModelViewSet): """ queryset = Role.objects.order_by('id') serializer_class = RoleSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'permissions', 'for_club', 'memberships__user', ] search_fields = ['$name', '$for_club__name', ] diff --git a/apps/registration/views.py b/apps/registration/views.py index 0166902c..f0314285 100644 --- a/apps/registration/views.py +++ b/apps/registration/views.py @@ -16,6 +16,7 @@ from django.views import View from django.views.generic import CreateView, TemplateView, DetailView from django.views.generic.edit import FormMixin from django_tables2 import SingleTableView +from api.viewsets import is_regex from member.forms import ProfileForm from member.models import Membership, Club from note.models import SpecialTransaction, Alias @@ -192,11 +193,16 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix_username = "__iregex" if valid_regex else "__icontains" + suffix = "__iregex" if valid_regex else "__istartswith" + prefix = "^" if valid_regex else "" qs = qs.filter( - Q(first_name__iregex=pattern) - | Q(last_name__iregex=pattern) - | Q(profile__section__iregex=pattern) - | Q(username__iregex="^" + pattern) + Q(**{f"first_name{suffix}": pattern}) + | Q(**{f"last_name{suffix}": pattern}) + | Q(**{f"profile__section{suffix}": pattern}) + | Q(**{f"username{suffix_username}": prefix + pattern}) ) return qs diff --git a/apps/treasury/api/views.py b/apps/treasury/api/views.py index 9b1825d1..418bf220 100644 --- a/apps/treasury/api/views.py +++ b/apps/treasury/api/views.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import SearchFilter +from api.filters import RegexSafeSearchFilter from api.viewsets import ReadProtectedModelViewSet from .serializers import InvoiceSerializer, ProductSerializer, RemittanceTypeSerializer, RemittanceSerializer, \ @@ -18,7 +18,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet): """ queryset = Invoice.objects.order_by('id') serializer_class = InvoiceSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['bde', 'object', 'description', 'name', 'address', 'date', 'acquitted', 'locked', ] search_fields = ['$object', '$description', '$name', '$address', ] @@ -31,7 +31,7 @@ class ProductViewSet(ReadProtectedModelViewSet): """ queryset = Product.objects.order_by('invoice_id', 'id') serializer_class = ProductSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['invoice', 'designation', 'quantity', 'amount', ] search_fields = ['$designation', '$invoice__object', ] @@ -44,7 +44,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet): """ queryset = RemittanceType.objects.order_by('id') serializer_class = RemittanceTypeSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['note', ] search_fields = ['$note__special_type', ] @@ -57,7 +57,7 @@ class RemittanceViewSet(ReadProtectedModelViewSet): """ queryset = Remittance.objects.order_by('id') serializer_class = RemittanceSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['date', 'remittance_type', 'comment', 'closed', 'transaction_proxies__transaction', ] search_fields = ['$remittance_type__note__special_type', '$comment', ] @@ -70,7 +70,7 @@ class SogeCreditViewSet(ReadProtectedModelViewSet): """ queryset = SogeCredit.objects.order_by('id') serializer_class = SogeCreditSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['user', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', 'transactions', 'credit_transaction', ] search_fields = ['$user__last_name', '$user__first_name', '$user__email', '$user__note__alias__name', diff --git a/apps/treasury/views.py b/apps/treasury/views.py index be989f99..64692793 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -20,6 +20,7 @@ from django.views.generic import UpdateView, DetailView from django.views.generic.base import View, TemplateView from django.views.generic.edit import BaseFormView, DeleteView from django_tables2 import SingleTableView +from api.viewsets import is_regex from note.models import SpecialTransaction, NoteSpecial, Alias from note_kfet.settings.base import BASE_DIR from permission.backends import PermissionBackend @@ -411,11 +412,16 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi if "search" in self.request.GET: pattern = self.request.GET["search"] if pattern: + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix_alias = "__iregex" if valid_regex else "__icontains" + suffix = "__iregex" if valid_regex else "__istartswith" + prefix = "^" if valid_regex else "" qs = qs.filter( - Q(user__first_name__iregex=pattern) - | Q(user__last_name__iregex=pattern) - | Q(user__note__alias__name__iregex="^" + pattern) - | Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) + Q(**{f"user__first_name{suffix}": pattern}) + | Q(**{f"user__last_name{suffix}": pattern}) + | Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern}) + | Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)}) ) if "valid" not in self.request.GET or not self.request.GET["valid"]: diff --git a/apps/wei/api/views.py b/apps/wei/api/views.py index e16a5374..13f04a91 100644 --- a/apps/wei/api/views.py +++ b/apps/wei/api/views.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.filters import OrderingFilter +from api.filters import RegexSafeSearchFilter from api.viewsets import ReadProtectedModelViewSet from .serializers import WEIClubSerializer, BusSerializer, BusTeamSerializer, WEIRoleSerializer, \ @@ -18,7 +19,7 @@ class WEIClubViewSet(ReadProtectedModelViewSet): """ queryset = WEIClub.objects.order_by('id') serializer_class = WEIClubSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'year', 'date_start', 'date_end', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club', 'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid', 'membership_duration', 'membership_start', @@ -34,7 +35,7 @@ class BusViewSet(ReadProtectedModelViewSet): """ queryset = Bus.objects.order_by('id') serializer_class = BusSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'wei', 'description', ] search_fields = ['$name', '$wei__name', '$description', ] @@ -47,7 +48,7 @@ class BusTeamViewSet(ReadProtectedModelViewSet): """ queryset = BusTeam.objects.order_by('id') serializer_class = BusTeamSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'bus', 'color', 'description', 'bus__wei', ] search_fields = ['$name', '$bus__name', '$bus__wei__name', '$description', ] @@ -60,7 +61,7 @@ class WEIRoleViewSet(ReadProtectedModelViewSet): """ queryset = WEIRole.objects.order_by('id') serializer_class = WEIRoleSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['name', 'permissions', 'memberships', ] search_fields = ['$name', ] @@ -73,7 +74,7 @@ class WEIRegistrationViewSet(ReadProtectedModelViewSet): """ queryset = WEIRegistration.objects.order_by('id') serializer_class = WEIRegistrationSerializer - filter_backends = [DjangoFilterBackend, SearchFilter] + filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter] filterset_fields = ['user', 'user__username', 'user__first_name', 'user__last_name', 'user__email', 'user__note__alias__name', 'user__note__alias__normalized_name', 'wei', 'wei__name', 'wei__email', 'wei__year', 'soge_credit', 'caution_check', 'birth_date', 'gender', @@ -92,7 +93,7 @@ class WEIMembershipViewSet(ReadProtectedModelViewSet): """ queryset = WEIMembership.objects.order_by('id') serializer_class = WEIMembershipSerializer - filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] + filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter] filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name', 'user__username', 'user__last_name', 'user__first_name', 'user__email', 'user__note__alias__name', diff --git a/apps/wei/views.py b/apps/wei/views.py index ce1e5791..125b8159 100644 --- a/apps/wei/views.py +++ b/apps/wei/views.py @@ -23,6 +23,7 @@ from django.views.generic import DetailView, UpdateView, RedirectView, TemplateV from django.utils.translation import gettext_lazy as _ from django.views.generic.edit import BaseFormView, DeleteView from django_tables2 import SingleTableView +from api.viewsets import is_regex from member.models import Membership, Club from note.models import Transaction, NoteClub, Alias, SpecialTransaction, NoteSpecial from note.tables import HistoryTable @@ -219,13 +220,18 @@ class WEIMembershipsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi if not pattern: return qs.none() + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix_alias = "__iregex" if valid_regex else "__istartswith" + suffix = "__iregex" if valid_regex else "__icontains" + prefix = "^" if valid_regex else "" qs = qs.filter( - Q(user__first_name__iregex=pattern) - | Q(user__last_name__iregex=pattern) - | Q(user__note__alias__name__iregex="^" + pattern) - | Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) - | Q(bus__name__iregex=pattern) - | Q(team__name__iregex=pattern) + Q(**{f"user__first_name{suffix}": pattern}) + | Q(**{f"user__last_name{suffix}": pattern}) + | Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern}) + | Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)}) + | Q(**{f"bus__name{suffix}": pattern}) + | Q(**{f"team__name{suffix}": pattern}) ) return qs @@ -255,11 +261,16 @@ class WEIRegistrationsView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTable pattern = self.request.GET.get("search", "") if pattern: + # Check if this is a valid regex. If not, we won't check regex + valid_regex = is_regex(pattern) + suffix_alias = "__iregex" if valid_regex else "__istartswith" + suffix = "__iregex" if valid_regex else "__icontains" + prefix = "^" if valid_regex else "" qs = qs.filter( - Q(user__first_name__iregex=pattern) - | Q(user__last_name__iregex=pattern) - | Q(user__note__alias__name__iregex="^" + pattern) - | Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) + Q(**{f"user__first_name{suffix}": pattern}) + | Q(**{f"user__last_name{suffix}": pattern}) + | Q(**{f"user__note__alias__name{suffix_alias}": prefix + pattern}) + | Q(**{f"user__note__alias__normalized_name{suffix_alias}": prefix + Alias.normalize(pattern)}) ) return qs