diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1c58740e5..97cdf7683 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -135,7 +135,7 @@ test_api: only: - branches before_script: - - apk add make git + - apk add make git gcc python3-dev - cd api - pip3 install -r requirements/base.txt - pip3 install -r requirements/local.txt diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 373c196d6..2daec55ca 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -659,6 +659,8 @@ AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] +if env.bool("DISABLE_PASSWORD_VALIDATORS", default=False): + AUTH_PASSWORD_VALIDATORS = [] ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter" CORS_ORIGIN_ALLOW_ALL = True # CORS_ORIGIN_WHITELIST = ( diff --git a/api/funkwhale_api/common/preferences.py b/api/funkwhale_api/common/preferences.py index 668a7c897..972b1ac1e 100644 --- a/api/funkwhale_api/common/preferences.py +++ b/api/funkwhale_api/common/preferences.py @@ -1,4 +1,7 @@ +import json + from django import forms +from django.contrib.postgres.forms import JSONField from django.conf import settings from dynamic_preferences import serializers, types from dynamic_preferences.registries import global_preferences_registry @@ -57,3 +60,48 @@ class StringListPreference(types.BasePreferenceType): d = super(StringListPreference, self).get_api_additional_data() d["choices"] = self.get("choices") return d + + +class JSONSerializer(serializers.BaseSerializer): + required = True + + @classmethod + def to_db(cls, value, **kwargs): + if not cls.required and value is None: + return json.dumps(value) + data_serializer = cls.data_serializer_class(data=value) + if not data_serializer.is_valid(): + raise cls.exception( + "{} is not a valid value: {}".format(value, data_serializer.errors) + ) + value = data_serializer.validated_data + try: + return json.dumps(value, sort_keys=True) + except TypeError: + raise cls.exception( + "Cannot serialize, value {} is not JSON serializable".format(value) + ) + + @classmethod + def to_python(cls, value, **kwargs): + return json.loads(value) + + +class SerializedPreference(types.BasePreferenceType): + """ + A preference that store arbitrary JSON and validate it using a rest_framework + serializer + """ + + serializer = JSONSerializer + data_serializer_class = None + field_class = JSONField + widget = forms.Textarea + + @property + def serializer(self): + class _internal(JSONSerializer): + data_serializer_class = self.data_serializer_class + required = self.get("required") + + return _internal diff --git a/api/funkwhale_api/federation/migrations/0025_auto_20200317_0820.py b/api/funkwhale_api/federation/migrations/0025_auto_20200317_0820.py new file mode 100644 index 000000000..6a59c95b6 --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0025_auto_20200317_0820.py @@ -0,0 +1,45 @@ +# Generated by Django 3.0.4 on 2020-03-17 08:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('federation', '0024_actor_attachment_icon'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='object_content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='activity', + name='object_id', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='activity', + name='related_object_content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='activity', + name='related_object_id', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='activity', + name='target_content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='activity', + name='target_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 357936955..50e8eef1a 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -292,6 +292,9 @@ class Actor(models.Model): from_activity__actor=self.pk ).count() data["reports"] = moderation_models.Report.objects.get_for_target(self).count() + data["requests"] = moderation_models.UserRequest.objects.filter( + submitter=self + ).count() data["albums"] = music_models.Album.objects.filter( from_activity__actor=self.pk ).count() diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 5791afba3..eb86a59f0 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -394,3 +394,26 @@ class ManageNoteFilterSet(filters.FilterSet): class Meta: model = moderation_models.Note fields = ["q"] + + +class ManageUserRequestFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "username": {"to": "submitter__preferred_username"}, + "uuid": {"to": "uuid"}, + }, + filter_fields={ + "uuid": {"to": "uuid"}, + "id": {"to": "id"}, + "status": {"to": "status"}, + "category": {"to": "type"}, + "submitter": get_actor_filter("submitter"), + "assigned_to": get_actor_filter("assigned_to"), + }, + ) + ) + + class Meta: + model = moderation_models.UserRequest + fields = ["q", "status", "type"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 7adb9c863..c6966f108 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -710,3 +710,36 @@ class ManageReportSerializer(serializers.ModelSerializer): def get_notes(self, o): notes = getattr(o, "_prefetched_notes", []) return ManageBaseNoteSerializer(notes, many=True).data + + +class ManageUserRequestSerializer(serializers.ModelSerializer): + assigned_to = ManageBaseActorSerializer() + submitter = ManageBaseActorSerializer() + notes = serializers.SerializerMethodField() + + class Meta: + model = moderation_models.UserRequest + fields = [ + "id", + "uuid", + "creation_date", + "handled_date", + "type", + "status", + "assigned_to", + "submitter", + "notes", + "metadata", + ] + read_only_fields = [ + "id", + "uuid", + "submitter", + "creation_date", + "handled_date", + "metadata", + ] + + def get_notes(self, o): + notes = getattr(o, "_prefetched_notes", []) + return ManageBaseNoteSerializer(notes, many=True).data diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 36997b24a..8af692d7a 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -18,6 +18,7 @@ moderation_router.register( r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies" ) moderation_router.register(r"reports", views.ManageReportViewSet, "reports") +moderation_router.register(r"requests", views.ManageUserRequestViewSet, "requests") moderation_router.register(r"notes", views.ManageNoteViewSet, "notes") users_router = routers.OptionalSlashRouter() diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index c84f9b3c0..5a0f81a39 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,12 +1,14 @@ from rest_framework import mixins, response, viewsets from rest_framework import decorators as rest_decorators +from django.db import transaction from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery from django.db.models.functions import Coalesce, Length from django.shortcuts import get_object_or_404 from funkwhale_api.common import models as common_models from funkwhale_api.common import preferences, decorators +from funkwhale_api.common import utils as common_utils from funkwhale_api.favorites import models as favorites_models from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import tasks as federation_tasks @@ -14,6 +16,7 @@ from funkwhale_api.history import models as history_models from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.moderation import tasks as moderation_tasks from funkwhale_api.playlists import models as playlists_models from funkwhale_api.tags import models as tags_models from funkwhale_api.users import models as users_models @@ -469,8 +472,8 @@ class ManageActorViewSet( @rest_decorators.action(methods=["get"], detail=True) def stats(self, request, *args, **kwargs): - domain = self.get_object() - return response.Response(domain.get_stats(), status=200) + obj = self.get_object() + return response.Response(obj.get_stats(), status=200) action = decorators.action_route(serializers.ManageActorActionSerializer) @@ -607,3 +610,54 @@ class ManageTagViewSet( serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) + + +class ManageUserRequestViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + moderation_models.UserRequest.objects.all() + .order_by("-creation_date") + .select_related("submitter", "assigned_to") + .prefetch_related( + Prefetch( + "notes", + queryset=moderation_models.Note.objects.order_by( + "creation_date" + ).select_related("author"), + to_attr="_prefetched_notes", + ) + ) + ) + serializer_class = serializers.ManageUserRequestSerializer + filterset_class = filters.ManageUserRequestFilterSet + required_scope = "instance:requests" + ordering_fields = ["id", "creation_date", "handled_date"] + + def get_queryset(self): + queryset = super().get_queryset() + if self.action in ["update", "partial_update"]: + # approved requests cannot be edited + queryset = queryset.exclude(status="approved") + return queryset + + @transaction.atomic + def perform_update(self, serializer): + old_status = serializer.instance.status + new_status = serializer.validated_data.get("status") + + if old_status != new_status and new_status != "pending": + # report was resolved, we assign to the mod making the request + serializer.save(assigned_to=self.request.user.actor) + common_utils.on_commit( + moderation_tasks.user_request_handle.delay, + user_request_id=serializer.instance.pk, + new_status=new_status, + old_status=old_status, + ) + else: + serializer.save() diff --git a/api/funkwhale_api/moderation/dynamic_preferences_registry.py b/api/funkwhale_api/moderation/dynamic_preferences_registry.py index 293904341..331b9b5c7 100644 --- a/api/funkwhale_api/moderation/dynamic_preferences_registry.py +++ b/api/funkwhale_api/moderation/dynamic_preferences_registry.py @@ -1,7 +1,11 @@ from dynamic_preferences import types from dynamic_preferences.registries import global_preferences_registry +from rest_framework import serializers + from funkwhale_api.common import preferences as common_preferences +from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import utils as common_utils from . import models @@ -40,3 +44,52 @@ class UnauthenticatedReportTypes(common_preferences.StringListPreference): help_text = "A list of categories for which external users (without an account) can submit a report" choices = models.REPORT_TYPES field_kwargs = {"choices": choices, "required": False} + + +@global_preferences_registry.register +class SignupApprovalEnabled(types.BooleanPreference): + show_in_api = True + section = moderation + name = "signup_approval_enabled" + verbose_name = "Enable manual sign-up validation" + help_text = "If enabled, new registrations will go to a moderation queue and need to be reviewed by moderators." + default = False + + +CUSTOM_FIELDS_TYPES = [ + "short_text", + "long_text", +] + + +class CustomFieldSerializer(serializers.Serializer): + label = serializers.CharField() + required = serializers.BooleanField(default=True) + input_type = serializers.ChoiceField(choices=CUSTOM_FIELDS_TYPES) + + +class CustomFormSerializer(serializers.Serializer): + help_text = common_serializers.ContentSerializer(required=False, allow_null=True) + fields = serializers.ListField( + child=CustomFieldSerializer(), min_length=0, max_length=10, required=False + ) + + def validate_help_text(self, v): + if not v: + return + v["html"] = common_utils.render_html( + v["text"], content_type=v["content_type"], permissive=True + ) + return v + + +@global_preferences_registry.register +class SignupFormCustomization(common_preferences.SerializedPreference): + show_in_api = True + section = moderation + name = "signup_form_customization" + verbose_name = "Sign-up form customization" + help_text = "Configure custom fields and help text for your sign-up form" + required = False + default = {} + data_serializer_class = CustomFormSerializer diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py index 96e6b5d11..35256285d 100644 --- a/api/funkwhale_api/moderation/factories.py +++ b/api/funkwhale_api/moderation/factories.py @@ -74,3 +74,20 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): return self.target_owner = serializers.get_target_owner(self.target) + + +@registry.register +class UserRequestFactory(NoUpdateOnCreate, factory.DjangoModelFactory): + submitter = factory.SubFactory(federation_factories.ActorFactory, local=True) + + class Meta: + model = "moderation.UserRequest" + + class Params: + signup = factory.Trait( + submitter=factory.SubFactory(federation_factories.ActorFactory, local=True), + type="signup", + ) + assigned = factory.Trait( + assigned_to=factory.SubFactory(federation_factories.ActorFactory) + ) diff --git a/api/funkwhale_api/moderation/migrations/0005_auto_20200317_0820.py b/api/funkwhale_api/moderation/migrations/0005_auto_20200317_0820.py new file mode 100644 index 000000000..941b8158c --- /dev/null +++ b/api/funkwhale_api/moderation/migrations/0005_auto_20200317_0820.py @@ -0,0 +1,41 @@ +# Generated by Django 3.0.4 on 2020-03-17 08:20 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('federation', '0025_auto_20200317_0820'), + ('moderation', '0004_note'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='summary', + field=models.TextField(blank=True, max_length=50000, null=True), + ), + migrations.CreateModel( + name='UserRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(blank=True, max_length=500, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('handled_date', models.DateTimeField(null=True)), + ('type', models.CharField(choices=[('signup', 'Sign-up')], max_length=40)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'approved')], default='pending', max_length=40)), + ('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_requests', to='federation.Actor')), + ('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to='federation.Actor')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py index e6b9cf09e..0271877e4 100644 --- a/api/funkwhale_api/moderation/models.py +++ b/api/funkwhale_api/moderation/models.py @@ -185,6 +185,43 @@ class Note(models.Model): target = GenericForeignKey("target_content_type", "target_id") +USER_REQUEST_TYPES = [ + ("signup", "Sign-up"), +] + +USER_REQUEST_STATUSES = [ + ("pending", "Pending"), + ("refused", "Refused"), + ("approved", "Approved"), +] + + +class UserRequest(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + creation_date = models.DateTimeField(default=timezone.now) + handled_date = models.DateTimeField(null=True) + type = models.CharField(max_length=40, choices=USER_REQUEST_TYPES) + status = models.CharField( + max_length=40, choices=USER_REQUEST_STATUSES, default="pending" + ) + submitter = models.ForeignKey( + "federation.Actor", related_name="requests", on_delete=models.CASCADE, + ) + assigned_to = models.ForeignKey( + "federation.Actor", + related_name="assigned_requests", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + metadata = JSONField(null=True) + + notes = GenericRelation( + "Note", content_type_field="target_content_type", object_id_field="target_id" + ) + + @receiver(pre_save, sender=Report) def set_handled_date(sender, instance, **kwargs): if instance.is_handled is True and not instance.handled_date: diff --git a/api/funkwhale_api/moderation/tasks.py b/api/funkwhale_api/moderation/tasks.py index 0d0e97052..6f908270f 100644 --- a/api/funkwhale_api/moderation/tasks.py +++ b/api/funkwhale_api/moderation/tasks.py @@ -1,9 +1,11 @@ import logging from django.core import mail -from django.dispatch import receiver from django.conf import settings +from django.db import transaction +from django.dispatch import receiver from funkwhale_api.common import channels +from funkwhale_api.common import preferences from funkwhale_api.common import utils from funkwhale_api.taskapp import celery from funkwhale_api.federation import utils as federation_utils @@ -41,11 +43,7 @@ def trigger_moderator_email(report, **kwargs): utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk) -@celery.app.task(name="moderation.send_new_report_email_to_moderators") -@celery.require_instance( - models.Report.objects.select_related("submitter").filter(is_handled=False), "report" -) -def send_new_report_email_to_moderators(report): +def get_moderators(): moderators = users_models.User.objects.filter( is_active=True, permission_moderation=True ) @@ -53,6 +51,15 @@ def send_new_report_email_to_moderators(report): # we fallback on superusers moderators = users_models.User.objects.filter(is_superuser=True) moderators = sorted(moderators, key=lambda m: m.pk) + return moderators + + +@celery.app.task(name="moderation.send_new_report_email_to_moderators") +@celery.require_instance( + models.Report.objects.select_related("submitter").filter(is_handled=False), "report" +) +def send_new_report_email_to_moderators(report): + moderators = get_moderators() submitter_repr = ( report.submitter.full_username if report.submitter else report.submitter_email ) @@ -114,3 +121,148 @@ def send_new_report_email_to_moderators(report): recipient_list=[moderator.email], from_email=settings.DEFAULT_FROM_EMAIL, ) + + +@celery.app.task(name="moderation.user_request_handle") +@celery.require_instance( + models.UserRequest.objects.select_related("submitter"), "user_request" +) +@transaction.atomic +def user_request_handle(user_request, new_status, old_status=None): + if user_request.status != new_status: + logger.warn( + "User request %s was handled before asynchronous tasks run", user_request.pk + ) + return + + if user_request.type == "signup" and new_status == "pending" and old_status is None: + notify_mods_signup_request_pending(user_request) + broadcast_user_request_created(user_request) + elif user_request.type == "signup" and new_status == "approved": + user_request.submitter.user.is_active = True + user_request.submitter.user.save(update_fields=["is_active"]) + notify_submitter_signup_request_approved(user_request) + elif user_request.type == "signup" and new_status == "refused": + notify_submitter_signup_request_refused(user_request) + + +def broadcast_user_request_created(user_request): + from funkwhale_api.manage import serializers as manage_serializers + + channels.group_send( + "admin.moderation", + { + "type": "event.send", + "text": "", + "data": { + "type": "user_request.created", + "user_request": manage_serializers.ManageUserRequestSerializer( + user_request + ).data, + "pending_count": models.UserRequest.objects.filter( + status="pending" + ).count(), + }, + }, + ) + + +def notify_mods_signup_request_pending(obj): + moderators = get_moderators() + submitter_repr = obj.submitter.preferred_username + subject = "[{} moderation] New sign-up request from {}".format( + settings.FUNKWHALE_HOSTNAME, submitter_repr + ) + detail_url = federation_utils.full_url( + "/manage/moderation/requests/{}".format(obj.uuid) + ) + unresolved_requests_url = federation_utils.full_url( + "/manage/moderation/requests?q=status:pending" + ) + unresolved_requests = models.UserRequest.objects.filter(status="pending").count() + body = [ + "{} wants to register on your pod. You need to review their request before they can use the service.".format( + submitter_repr + ), + "", + "- To handle this request, please visit {}".format(detail_url), + "- To view all unresolved requests (currently {}), please visit {}".format( + unresolved_requests, unresolved_requests_url + ), + "", + "—", + "", + "You are receiving this email because you are a moderator for {}.".format( + settings.FUNKWHALE_HOSTNAME + ), + ] + + for moderator in moderators: + if not moderator.email: + logger.warning("Moderator %s has no email configured", moderator.username) + continue + mail.send_mail( + subject, + message="\n".join(body), + recipient_list=[moderator.email], + from_email=settings.DEFAULT_FROM_EMAIL, + ) + + +def notify_submitter_signup_request_approved(user_request): + submitter_repr = user_request.submitter.preferred_username + submitter_email = user_request.submitter.user.email + if not submitter_email: + logger.warning("User %s has no email configured", submitter_repr) + return + subject = "Welcome to {}, {}!".format(settings.FUNKWHALE_HOSTNAME, submitter_repr) + login_url = federation_utils.full_url("/login") + body = [ + "Hi {} and welcome,".format(submitter_repr), + "", + "Our moderation team has approved your account request and you can now start " + "using the service. Please visit {} to get started.".format(login_url), + "", + "Before your first login, you may need to verify your email address if you didn't already.", + ] + + mail.send_mail( + subject, + message="\n".join(body), + recipient_list=[submitter_email], + from_email=settings.DEFAULT_FROM_EMAIL, + ) + + +def notify_submitter_signup_request_refused(user_request): + submitter_repr = user_request.submitter.preferred_username + submitter_email = user_request.submitter.user.email + if not submitter_email: + logger.warning("User %s has no email configured", submitter_repr) + return + subject = "Your account request at {} was refused".format( + settings.FUNKWHALE_HOSTNAME + ) + body = [ + "Hi {},".format(submitter_repr), + "", + "You recently submitted an account request on our service. However, our " + "moderation team has refused it, and as a result, you won't be able to use " + "the service.", + ] + + instance_contact_email = preferences.get("instance__contact_email") + if instance_contact_email: + body += [ + "", + "If you think this is a mistake, please contact our team at {}.".format( + instance_contact_email + ), + ] + + mail.send_mail( + subject, + message="\n".join(body), + recipient_list=[submitter_email], + from_email=settings.DEFAULT_FROM_EMAIL, + ) diff --git a/api/funkwhale_api/moderation/utils.py b/api/funkwhale_api/moderation/utils.py index d4a1b879a..c8bf691dc 100644 --- a/api/funkwhale_api/moderation/utils.py +++ b/api/funkwhale_api/moderation/utils.py @@ -12,6 +12,11 @@ NOTE_TARGET_FIELDS = { "id_attr": "uuid", "id_field": serializers.UUIDField(), }, + "request": { + "queryset": models.UserRequest.objects.all(), + "id_attr": "uuid", + "id_field": serializers.UUIDField(), + }, "account": { "queryset": federation_models.Actor.objects.all(), "id_attr": "full_username", @@ -19,3 +24,21 @@ NOTE_TARGET_FIELDS = { "get_query": moderation_serializers.get_actor_query, }, } + + +def get_signup_form_additional_fields_serializer(customization): + fields = (customization or {}).get("fields", []) or [] + + class AdditionalFieldsSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in fields: + required = bool(field.get("required", True)) + self.fields[field["label"]] = serializers.CharField( + max_length=5000, + required=required, + allow_null=not required, + allow_blank=not required, + ) + + return AdditionalFieldsSerializer(required=fields, allow_null=not fields) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index c920503cd..db30199c6 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -46,6 +46,8 @@ PERMISSIONS_CONFIGURATION = { "write:instance:domains", "read:instance:reports", "write:instance:reports", + "read:instance:requests", + "write:instance:requests", "read:instance:notes", "write:instance:notes", }, diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py index 88b928c50..23958ccad 100644 --- a/api/funkwhale_api/users/oauth/scopes.py +++ b/api/funkwhale_api/users/oauth/scopes.py @@ -35,6 +35,7 @@ BASE_SCOPES = [ Scope("instance:domains", "Access instance domains"), Scope("instance:policies", "Access instance moderation policies"), Scope("instance:reports", "Access instance moderation reports"), + Scope("instance:requests", "Access instance moderation requests"), Scope("instance:notes", "Access instance moderation notes"), ] SCOPES = [ diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 1e919f7b7..2027ee8c9 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -10,9 +10,14 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.common import models as common_models +from funkwhale_api.common import preferences from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import models as federation_models +from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.moderation import tasks as moderation_tasks +from funkwhale_api.moderation import utils as moderation_utils + from . import adapters from . import models @@ -36,6 +41,17 @@ class RegisterSerializer(RS): required=False, allow_null=True, allow_blank=True ) + def __init__(self, *args, **kwargs): + self.approval_enabled = preferences.get("moderation__signup_approval_enabled") + super().__init__(*args, **kwargs) + if self.approval_enabled: + customization = preferences.get("moderation__signup_form_customization") + self.fields[ + "request_fields" + ] = moderation_utils.get_signup_form_additional_fields_serializer( + customization + ) + def validate_invitation(self, value): if not value: return @@ -67,11 +83,28 @@ class RegisterSerializer(RS): def save(self, request): user = super().save(request) + update_fields = ["actor"] + user.actor = models.create_actor(user) + user_request = None + if self.approval_enabled: + # manually approve users + user.is_active = False + user_request = moderation_models.UserRequest.objects.create( + submitter=user.actor, + type="signup", + metadata=self.validated_data.get("request_fields", None) or None, + ) + update_fields.append("is_active") if self.validated_data.get("invitation"): user.invitation = self.validated_data.get("invitation") - user.save(update_fields=["invitation"]) - user.actor = models.create_actor(user) - user.save(update_fields=["actor"]) + update_fields.append("invitation") + user.save(update_fields=update_fields) + if user_request: + common_utils.on_commit( + moderation_tasks.user_request_handle.delay, + user_request_id=user_request.pk, + new_status=user_request.status, + ) return user diff --git a/api/setup.cfg b/api/setup.cfg index 2b8f8e825..8872573e9 100644 --- a/api/setup.cfg +++ b/api/setup.cfg @@ -13,6 +13,7 @@ exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules [tool:pytest] python_files = tests.py test_*.py *_tests.py testpaths = tests +addopts = -p no:warnings env = SECRET_KEY=test EMAIL_CONFIG=consolemail:// diff --git a/api/tests/common/test_preferences.py b/api/tests/common/test_preferences.py index 7f941a450..f380e8236 100644 --- a/api/tests/common/test_preferences.py +++ b/api/tests/common/test_preferences.py @@ -1,6 +1,10 @@ import pytest + +from dynamic_preferences import types from dynamic_preferences.registries import global_preferences_registry +from rest_framework import serializers + from funkwhale_api.common import preferences as common_preferences @@ -43,3 +47,44 @@ def test_string_list_pref_default(string_list_pref, preferences): def test_string_list_pref_set(string_list_pref, preferences): preferences["test__string_list"] = ["world", "hello"] assert preferences["test__string_list"] == ["hello", "world"] + + +class PreferenceDataSerializer(serializers.Serializer): + name = serializers.CharField() + optional = serializers.BooleanField(required=False) + + +@pytest.fixture +def serialized_preference(db): + @global_preferences_registry.register + class TestSerialized(common_preferences.SerializedPreference): + section = types.Section("test") + name = "serialized" + data_serializer_class = PreferenceDataSerializer + default = None + required = False + + yield + del global_preferences_registry["test"]["serialized"] + + +@pytest.mark.parametrize( + "value", [{"name": "hello"}, {"name": "hello", "optional": True}] +) +def test_get_serialized_preference(value, preferences, serialized_preference): + pref_id = "test__serialized" + # default value + assert preferences[pref_id] is None + + preferences[pref_id] = value + assert preferences[pref_id] == value + + +@pytest.mark.parametrize( + "value", [{"noop": "hello"}, {"name": "hello", "optional": None}, "noop"] +) +def test_get_serialized_preference_error(value, preferences, serialized_preference): + pref_id = "test__serialized" + + with pytest.raises(common_preferences.JSONSerializer.exception): + preferences[pref_id] = value diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index c96251ecb..a0895d44a 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -148,6 +148,7 @@ def test_actor_stats(factories): "uploads": 0, "artists": 0, "reports": 0, + "requests": 0, "outbox_activities": 0, "received_library_follows": 0, "emitted_library_follows": 0, diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 412a7a58c..71f80bed0 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -562,3 +562,26 @@ def test_manage_note_serializer(factories, to_api_date): s = serializers.ManageNoteSerializer(note) assert s.data == expected + + +def test_manage_user_request_serializer(factories, to_api_date): + user_request = factories["moderation.UserRequest"]( + signup=True, metadata={"foo": "bar"}, assigned=True + ) + expected = { + "id": user_request.id, + "uuid": str(user_request.uuid), + "creation_date": to_api_date(user_request.creation_date), + "handled_date": None, + "status": user_request.status, + "type": user_request.type, + "submitter": serializers.ManageBaseActorSerializer(user_request.submitter).data, + "assigned_to": serializers.ManageBaseActorSerializer( + user_request.assigned_to + ).data, + "metadata": {"foo": "bar"}, + "notes": [], + } + s = serializers.ManageUserRequestSerializer(user_request) + + assert s.data == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index ca03a4279..2482e0d80 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -3,6 +3,7 @@ from django.urls import reverse from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.manage import serializers +from funkwhale_api.moderation import tasks as moderation_tasks def test_user_view(factories, superuser_api_client, mocker): @@ -409,7 +410,7 @@ def test_upload_delete(factories, superuser_api_client): assert response.status_code == 204 -def test_note_create(factories, superuser_api_client): +def test_note_create_actor(factories, superuser_api_client): actor = superuser_api_client.user.create_actor() target = factories["federation.Actor"]() data = { @@ -425,6 +426,22 @@ def test_note_create(factories, superuser_api_client): assert response.data == serializers.ManageNoteSerializer(note).data +def test_note_create_user_request(factories, superuser_api_client): + actor = superuser_api_client.user.create_actor() + target = factories["moderation.UserRequest"]() + data = { + "summary": "Hello", + "target": {"type": "request", "uuid": target.uuid}, + } + url = reverse("api:v1:manage:moderation:notes-list") + response = superuser_api_client.post(url, data, format="json") + assert response.status_code == 201 + + note = actor.moderation_notes.latest("id") + assert note.target == target + assert response.data == serializers.ManageNoteSerializer(note).data + + def test_note_list(factories, superuser_api_client, settings): note = factories["moderation.Note"]() url = reverse("api:v1:manage:moderation:notes-list") @@ -527,3 +544,58 @@ def test_report_update_is_handled_true_assigns(factories, superuser_api_client): report.refresh_from_db() assert report.is_handled is True assert report.assigned_to == actor + + +def test_request_detail(factories, superuser_api_client): + request = factories["moderation.UserRequest"]() + url = reverse( + "api:v1:manage:moderation:requests-detail", kwargs={"uuid": request.uuid} + ) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["uuid"] == str(request.uuid) + + +def test_request_list(factories, superuser_api_client, settings): + request = factories["moderation.UserRequest"]() + url = reverse("api:v1:manage:moderation:requests-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["uuid"] == str(request.uuid) + + +def test_user_request_update(factories, superuser_api_client): + user_request = factories["moderation.UserRequest"](signup=True) + url = reverse( + "api:v1:manage:moderation:requests-detail", kwargs={"uuid": user_request.uuid} + ) + response = superuser_api_client.patch(url, {"status": "approved"}) + + assert response.status_code == 200 + user_request.refresh_from_db() + assert user_request.status == "approved" + + +def test_user_request_update_status_assigns(factories, superuser_api_client, mocker): + actor = superuser_api_client.user.create_actor() + user_request = factories["moderation.UserRequest"](signup=True) + url = reverse( + "api:v1:manage:moderation:requests-detail", kwargs={"uuid": user_request.uuid} + ) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + response = superuser_api_client.patch(url, {"status": "refused"}) + + assert response.status_code == 200 + user_request.refresh_from_db() + assert user_request.status == "refused" + assert user_request.assigned_to == actor + on_commit.assert_called_once_with( + moderation_tasks.user_request_handle.delay, + user_request_id=user_request.pk, + new_status="refused", + old_status="pending", + ) diff --git a/api/tests/moderation/test_preferences.py b/api/tests/moderation/test_preferences.py new file mode 100644 index 000000000..74c350001 --- /dev/null +++ b/api/tests/moderation/test_preferences.py @@ -0,0 +1,52 @@ +import pytest + +from django.urls import reverse + +from funkwhale_api.common import preferences as common_preferences +from funkwhale_api.common import utils as common_utils + + +@pytest.mark.parametrize("value", [{"fields": {}}, {"fields": list(range(15))}]) +def test_get_serialized_preference_error(value, preferences): + pref_id = "moderation__signup_form_customization" + + with pytest.raises(common_preferences.JSONSerializer.exception): + preferences[pref_id] = value + + +@pytest.mark.parametrize( + "value", + [ + {"fields": []}, + {"help_text": {"text": "hello", "content_type": "text/markdown"}}, + {"fields": [{"label": "Message", "required": True, "input_type": "long_text"}]}, + ], +) +def test_get_serialized_preference(value, preferences): + pref_id = "moderation__signup_form_customization" + + preferences[pref_id] = value + if "help_text" in value: + value["help_text"]["html"] = common_utils.render_html( + value["help_text"]["text"], + content_type=value["help_text"]["content_type"], + permissive=True, + ) + assert preferences[pref_id] == value + + +def test_update_via_api(superuser_api_client, preferences): + pref_id = "moderation__signup_form_customization" + url = reverse("api:v1:instance:admin-settings-bulk") + new_value = { + "help_text": {"text": "hello", "content_type": "text/markdown"}, + "fields": [{"required": True, "label": "hello", "input_type": "short_text"}], + } + response = superuser_api_client.post(url, {pref_id: new_value}, format="json") + assert response.status_code == 200 + new_value["help_text"]["html"] = common_utils.render_html( + new_value["help_text"]["text"], + content_type=new_value["help_text"]["content_type"], + permissive=True, + ) + assert preferences[pref_id] == new_value diff --git a/api/tests/moderation/test_tasks.py b/api/tests/moderation/test_tasks.py index a4f287799..f51d8ff3b 100644 --- a/api/tests/moderation/test_tasks.py +++ b/api/tests/moderation/test_tasks.py @@ -46,3 +46,77 @@ def test_report_created_signal_sends_email_to_mods(factories, mailoutbox, settin assert detail_url in m.body assert unresolved_reports_url in m.body assert list(m.to) == [mod.email] + + +def test_signup_request_pending_sends_email_to_mods(factories, mailoutbox, settings): + mod1 = factories["users.User"](permission_moderation=True) + mod2 = factories["users.User"](permission_moderation=True) + + signup_request = factories["moderation.UserRequest"](signup=True) + + tasks.user_request_handle(user_request_id=signup_request.pk, new_status="pending") + + detail_url = federation_utils.full_url( + "/manage/moderation/requests/{}".format(signup_request.uuid) + ) + unresolved_requests_url = federation_utils.full_url( + "/manage/moderation/requests?q=status:pending" + ) + assert len(mailoutbox) == 2 + for i, mod in enumerate([mod1, mod2]): + m = mailoutbox[i] + assert m.subject == "[{} moderation] New sign-up request from {}".format( + settings.FUNKWHALE_HOSTNAME, signup_request.submitter.preferred_username, + ) + assert detail_url in m.body + assert unresolved_requests_url in m.body + assert list(m.to) == [mod.email] + + +def test_approved_request_sends_email_to_submitter_and_set_active( + factories, mailoutbox, settings +): + user = factories["users.User"](is_active=False) + actor = user.create_actor() + signup_request = factories["moderation.UserRequest"]( + signup=True, submitter=actor, status="approved" + ) + + tasks.user_request_handle(user_request_id=signup_request.pk, new_status="approved") + + user.refresh_from_db() + + assert user.is_active is True + assert len(mailoutbox) == 1 + m = mailoutbox[-1] + login_url = federation_utils.full_url("/login") + assert m.subject == "Welcome to {}, {}!".format( + settings.FUNKWHALE_HOSTNAME, signup_request.submitter.preferred_username, + ) + assert login_url in m.body + assert list(m.to) == [user.email] + + +def test_refused_request_sends_email_to_submitter( + factories, mailoutbox, settings, preferences +): + preferences["instance__contact_email"] = "test@pod.example" + user = factories["users.User"](is_active=False) + actor = user.create_actor() + signup_request = factories["moderation.UserRequest"]( + signup=True, submitter=actor, status="refused" + ) + + tasks.user_request_handle(user_request_id=signup_request.pk, new_status="refused") + + user.refresh_from_db() + + assert user.is_active is False + + assert len(mailoutbox) == 1 + m = mailoutbox[-1] + assert m.subject == "Your account request at {} was refused".format( + settings.FUNKWHALE_HOSTNAME, + ) + assert "test@pod.example" in m.body + assert list(m.to) == [user.email] diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py index 4943a8a1e..7261ac6b1 100644 --- a/api/tests/users/oauth/test_scopes.py +++ b/api/tests/users/oauth/test_scopes.py @@ -54,6 +54,8 @@ from funkwhale_api.users.oauth import scopes "write:instance:notes", "read:instance:reports", "write:instance:reports", + "read:instance:requests", + "write:instance:requests", }, ), ( @@ -99,6 +101,8 @@ from funkwhale_api.users.oauth import scopes "write:instance:notes", "read:instance:reports", "write:instance:reports", + "read:instance:requests", + "write:instance:requests", }, ), ( @@ -138,6 +142,8 @@ from funkwhale_api.users.oauth import scopes "write:instance:notes", "read:instance:reports", "write:instance:reports", + "read:instance:requests", + "write:instance:requests", }, ), ( diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 3b5cf4cd3..2a453ebcc 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -3,6 +3,7 @@ from django.urls import reverse from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils +from funkwhale_api.moderation import tasks as moderation_tasks from funkwhale_api.users.models import User @@ -415,3 +416,64 @@ def test_username_with_existing_local_account_are_invalid( assert response.status_code == 400 assert "username" in response.data + + +def test_signup_with_approval_enabled(preferences, factories, api_client, mocker): + url = reverse("rest_register") + data = { + "username": "test1", + "email": "test1@test.com", + "password1": "thisismypassword", + "password2": "thisismypassword", + "request_fields": {"field1": "Value 1", "field2": "Value 2", "noop": "Noop"}, + } + preferences["users__registration_enabled"] = True + preferences["moderation__signup_approval_enabled"] = True + preferences["moderation__signup_form_customization"] = { + "fields": [ + {"label": "field1", "input_type": "short_text"}, + {"label": "field2", "input_type": "short_text"}, + ] + } + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + response = api_client.post(url, data, format="json") + assert response.status_code == 201 + u = User.objects.get(email="test1@test.com") + assert u.username == "test1" + assert u.is_active is False + user_request = u.actor.requests.latest("id") + assert user_request.type == "signup" + assert user_request.status == "pending" + assert user_request.metadata == { + "field1": "Value 1", + "field2": "Value 2", + } + + on_commit.assert_any_call( + moderation_tasks.user_request_handle.delay, + user_request_id=user_request.pk, + new_status="pending", + ) + + +def test_signup_with_approval_enabled_validation_error( + preferences, factories, api_client +): + url = reverse("rest_register") + data = { + "username": "test1", + "email": "test1@test.com", + "password1": "thisismypassword", + "password2": "thisismypassword", + "request_fields": {"field1": "Value 1"}, + } + preferences["users__registration_enabled"] = True + preferences["moderation__signup_approval_enabled"] = True + preferences["moderation__signup_form_customization"] = { + "fields": [ + {"label": "field1", "input_type": "short_text"}, + {"label": "field2", "input_type": "short_text"}, + ] + } + response = api_client.post(url, data, format="json") + assert response.status_code == 400 diff --git a/changes/changelog.d/1040.feature b/changes/changelog.d/1040.feature new file mode 100644 index 000000000..ec905688b --- /dev/null +++ b/changes/changelog.d/1040.feature @@ -0,0 +1 @@ +Screening for sign-ups (#1040) diff --git a/changes/notes.rst b/changes/notes.rst index b1b9cc655..044a739c7 100644 --- a/changes/notes.rst +++ b/changes/notes.rst @@ -13,19 +13,23 @@ This release includes a full redesign of our navigation, player and queue. Overa a better, less confusing experience, especially on mobile devices. This redesign was suggested 14 months ago, and took a while, but thanks to the involvement and feedback of many people, we got it done! -Progressive web app [Manual change suggested, non-docker only] -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Screening for sign-ups and custom sign-up form +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Instance admins can now configure their pod so that registrations required manual approval from a moderator. This +is especially useful on private or semi-private pods where you don't want to close registrations completely, +but don't want spam or unwanted users to join your pod. + +When this is enabled and a new user register, their request is put in a moderation queue, and moderators +are notified by email. When the request is approved or refused, the user is also notified by email. + +In addition, it's also possible to customize the sign-up form by: + +- Providing a custom help text, in markdown format +- Including additional fields in the form, for instance to ask the user why they want to join. Data collected through these fields is included in the sign-up request and viewable by the mods -We've made Funkwhale's Web UI a Progressive Web Application (PWA), in order to improve the user experience -during offline use, and on mobile devices. -In order to fully benefit from this change, if your pod isn't deployed using Docker, ensure -the following instruction is present in your nginx configuration:: - location /front/ { - # Add the following line in the /front/ location - add_header Service-Worker-Allowed "/"; - } Federated reports ^^^^^^^^^^^^^^^^^ @@ -63,6 +67,20 @@ All user-related commands are available under the ``python manage.py fw users`` Please refer to the `Admin documentation `_ for more information and instructions. +Progressive web app [Manual change suggested, non-docker only] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We've made Funkwhale's Web UI a Progressive Web Application (PWA), in order to improve the user experience +during offline use, and on mobile devices. + +In order to fully benefit from this change, if your pod isn't deployed using Docker, ensure +the following instruction is present in your nginx configuration:: + + location /front/ { + # Add the following line in the /front/ location + add_header Service-Worker-Allowed "/"; + } + Postgres docker changed environment variable [manual action required, docker multi-container only] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/front/src/App.vue b/front/src/App.vue index 648f4cb94..b572b1bcd 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -133,6 +133,11 @@ export default { id: 'sidebarPendingReviewReportCount', handler: this.incrementPendingReviewReportsCountInSidebar }) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'user_request.created', + id: 'sidebarPendingReviewRequestCount', + handler: this.incrementPendingReviewRequestsCountInSidebar + }) }, mounted () { let self = this @@ -166,6 +171,10 @@ export default { eventName: 'mutation.updated', id: 'sidebarPendingReviewReportCount', }) + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'user_request.created', + id: 'sidebarPendingReviewRequestCount', + }) this.disconnect() }, methods: { @@ -178,6 +187,9 @@ export default { incrementPendingReviewReportsCountInSidebar (event) { this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: event.unresolved_count}) }, + incrementPendingReviewRequestsCountInSidebar (event) { + this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewRequests', value: event.pending_count}) + }, async fetchNodeInfo () { let response = await axios.get('instance/nodeinfo/2.0/') this.$store.commit('instance/nodeinfo', response.data) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 177eb0ba3..732847ef5 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -17,8 +17,8 @@