From 4b6746d87f0916bd3027d0a91899f71ea4cdd7be Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:30:40 +0000 Subject: [PATCH] Add validation for Mastodon handles to prevent invalid URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3363 Previously, users could enter invalid Mastodon handles like just "username", which resulted in broken URLs like "https://undefined/@username" on the public pages. This change adds validation to enforce proper Mastodon handle formats: - username@instance.social - @username@instance.social - https://instance.social/@username The validation is applied in: - Participant profile updates (api/participants/mutations.py) - Submission forms for talks/workshops (api/submissions/mutations.py) - Grant applications (api/grants/mutations.py) Also added a test case to verify the validation works correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marco Acierno --- backend/api/grants/mutations.py | 30 ++++++++++ backend/api/participants/mutations.py | 11 ++++ backend/api/submissions/mutations.py | 11 ++++ .../submissions/tests/test_edit_submission.py | 56 +++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 161e25ba49..5f543b6137 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Annotated, Union, Optional from participants.models import Participant +import re from privacy_policy.record import record_privacy_policy_acceptance import strawberry @@ -24,6 +25,12 @@ from grants.tasks import get_name from notifications.models import EmailTemplate, EmailTemplateIdentifier +FACEBOOK_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?facebook\.com\/") +LINKEDIN_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?linkedin\.com\/") +MASTODON_HANDLE_MATCH = re.compile( + r"^(https?:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}\/@[a-zA-Z0-9_]+|@?[a-zA-Z0-9_]+@[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,})$" +) + @strawberry.type class GrantErrors(BaseErrorType): @@ -101,6 +108,29 @@ def validate(self, conference: Conference, user: User) -> GrantErrors: errors.add_error(field, f"{field}: Cannot be empty") continue + # Validate social media fields + if self.participant_linkedin_url and not LINKEDIN_LINK_MATCH.match( + self.participant_linkedin_url + ): + errors.add_error( + "participant_linkedin_url", "Linkedin URL should be a linkedin.com link" + ) + + if self.participant_facebook_url and not FACEBOOK_LINK_MATCH.match( + self.participant_facebook_url + ): + errors.add_error( + "participant_facebook_url", "Facebook URL should be a facebook.com link" + ) + + if self.participant_mastodon_handle and not MASTODON_HANDLE_MATCH.match( + self.participant_mastodon_handle + ): + errors.add_error( + "participant_mastodon_handle", + "Mastodon handle should be in format: username@instance.social or @username@instance.social or https://instance.social/@username", + ) + return errors diff --git a/backend/api/participants/mutations.py b/backend/api/participants/mutations.py index 10f1b815a0..55f18ff41a 100644 --- a/backend/api/participants/mutations.py +++ b/backend/api/participants/mutations.py @@ -13,6 +13,9 @@ FACEBOOK_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?facebook\.com\/") LINKEDIN_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?linkedin\.com\/") +MASTODON_HANDLE_MATCH = re.compile( + r"^(https?:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}\/@[a-zA-Z0-9_]+|@?[a-zA-Z0-9_]+@[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,})$" +) @strawberry.type @@ -77,6 +80,14 @@ def validate(self) -> UpdateParticipantErrors: "facebook_url", "Facebook URL should be a facebook.com link" ) + if self.mastodon_handle and not MASTODON_HANDLE_MATCH.match( + self.mastodon_handle + ): + errors.add_error( + "mastodon_handle", + "Mastodon handle should be in format: username@instance.social or @username@instance.social or https://instance.social/@username", + ) + return errors.if_has_errors diff --git a/backend/api/submissions/mutations.py b/backend/api/submissions/mutations.py index 5249b6a141..7ed5531cdb 100644 --- a/backend/api/submissions/mutations.py +++ b/backend/api/submissions/mutations.py @@ -28,6 +28,9 @@ FACEBOOK_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?facebook\.com\/") LINKEDIN_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?linkedin\.com\/") +MASTODON_HANDLE_MATCH = re.compile( + r"^(https?:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}\/@[a-zA-Z0-9_]+|@?[a-zA-Z0-9_]+@[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,})$" +) @strawberry.type @@ -197,6 +200,14 @@ def validate(self, conference: Conference): "speaker_facebook_url", "Facebook URL should be a facebook.com link" ) + if self.speaker_mastodon_handle and not MASTODON_HANDLE_MATCH.match( + self.speaker_mastodon_handle + ): + errors.add_error( + "speaker_mastodon_handle", + "Mastodon handle should be in format: username@instance.social or @username@instance.social or https://instance.social/@username", + ) + return errors diff --git a/backend/api/submissions/tests/test_edit_submission.py b/backend/api/submissions/tests/test_edit_submission.py index 7a671420fb..cc2214a2c8 100644 --- a/backend/api/submissions/tests/test_edit_submission.py +++ b/backend/api/submissions/tests/test_edit_submission.py @@ -132,6 +132,7 @@ def _update_submission( validationSpeakerInstagramHandle: speakerInstagramHandle validationSpeakerLinkedinUrl: speakerLinkedinUrl validationSpeakerFacebookUrl: speakerFacebookUrl + validationSpeakerMastodonHandle: speakerMastodonHandle validationMaterials: materials { fileId url @@ -922,6 +923,61 @@ def test_update_submission_with_invalid_linkedin_social_url(graphql_client, user ] == ["Linkedin URL should be a linkedin.com link"] +def test_update_submission_with_invalid_mastodon_handle(graphql_client, user): + conference = ConferenceFactory( + topics=("life", "diy"), + languages=("it", "en"), + durations=("10", "20"), + active_cfp=True, + audience_levels=("adult", "senior"), + submission_types=("talk", "workshop"), + ) + + submission = SubmissionFactory( + speaker_id=user.id, + custom_topic="life", + custom_duration="10m", + custom_audience_level="adult", + custom_submission_type="talk", + languages=["it"], + tags=["python", "ml"], + conference=conference, + speaker_level=Submission.SPEAKER_LEVELS.intermediate, + previous_talk_video="https://www.youtube.com/watch?v=SlPhMPnQ58k", + ) + + graphql_client.force_login(user) + + new_topic = conference.topics.filter(name="diy").first() + new_audience = conference.audience_levels.filter(name="senior").first() + new_tag = SubmissionTagFactory(name="yello") + new_duration = conference.durations.filter(name="20m").first() + new_type = conference.submission_types.filter(name="workshop").first() + + response = _update_submission( + graphql_client, + submission=submission, + new_topic=new_topic, + new_audience=new_audience, + new_tag=new_tag, + new_duration=new_duration, + new_type=new_type, + new_speaker_level=Submission.SPEAKER_LEVELS.experienced, + new_previous_talk_video="https://www.youtube.com/watch?v=dQw4w9WgXcQ", + new_short_social_summary="test", + new_speaker_mastodon_handle="justusername", + ) + + submission.refresh_from_db() + + assert response["data"]["updateSubmission"]["__typename"] == "SendSubmissionErrors" + assert response["data"]["updateSubmission"]["errors"][ + "validationSpeakerMastodonHandle" + ] == [ + "Mastodon handle should be in format: username@instance.social or @username@instance.social or https://instance.social/@username" + ] + + def test_update_submission_with_photo_to_upload( graphql_client, user,