diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 67b3d2e..3210f7a 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -71,7 +71,7 @@ class CustomUserAdmin(UserAdmin): readonly_fields = ('uuid',) search_fields = ('username',) fieldsets = UserAdmin.fieldsets + ( - (None, {'fields': ('profile_pic', 'uuid', 'public_profile')}), + (None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password')}), ) def image_display(self, obj): if obj.profile_pic: diff --git a/backend/server/adventures/migrations/0024_alter_attachment_file.py b/backend/server/adventures/migrations/0024_alter_attachment_file.py new file mode 100644 index 0000000..e63bb0e --- /dev/null +++ b/backend/server/adventures/migrations/0024_alter_attachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-03-17 01:15 + +import adventures.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0023_lodging_delete_hotel'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/'), validators=[adventures.models.validate_file_extension]), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index ad52e89..c7f78ca 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -1,4 +1,4 @@ -from collections.abc import Collection +from django.core.exceptions import ValidationError import os from typing import Iterable import uuid @@ -10,6 +10,13 @@ from django.contrib.postgres.fields import ArrayField from django.forms import ValidationError from django_resized import ResizedImageField +def validate_file_extension(value): + import os + from django.core.exceptions import ValidationError + ext = os.path.splitext(value.name)[1] # [0] returns path+filename + valid_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.wma', '.aac', '.opus', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.lz4', '.lzma', '.lzo', '.z', '.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tar.lz4', '.tar.lzma', '.tar.lzo', '.tar.z', 'gpx', 'md', 'pdf'] + if not ext.lower() in valid_extensions: + raise ValidationError('Unsupported file extension.') ADVENTURE_TYPES = [ ('general', 'General 🌍'), @@ -306,7 +313,7 @@ class Attachment(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - file = models.FileField(upload_to=PathAndRename('attachments/')) + file = models.FileField(upload_to=PathAndRename('attachments/'),validators=[validate_file_extension]) adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE) name = models.CharField(max_length=200, null=True, blank=True) diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 539106f..406e37a 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -227,6 +227,10 @@ HEADLESS_FRONTEND_URLS = { "socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback", } +AUTHENTICATION_BACKENDS = [ + 'users.backends.NoPasswordAuthBackend', +] + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' SITE_ID = 1 ACCOUNT_EMAIL_REQUIRED = True diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 2007662..3b8415e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -1,7 +1,7 @@ from django.urls import include, re_path, path from django.contrib import admin from django.views.generic import RedirectView, TemplateView -from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView +from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView from .views import get_csrf_token, get_public_url, serve_protected_media from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -29,6 +29,8 @@ urlpatterns = [ path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), + path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'), + path('csrf/', get_csrf_token, name='get_csrf_token'), path('public-url/', get_public_url, name='get_public_url'), diff --git a/backend/server/users/backends.py b/backend/server/users/backends.py new file mode 100644 index 0000000..a099f11 --- /dev/null +++ b/backend/server/users/backends.py @@ -0,0 +1,16 @@ +from django.contrib.auth.backends import ModelBackend +from allauth.socialaccount.models import SocialAccount + +class NoPasswordAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + print("NoPasswordAuthBackend") + # First, attempt normal authentication + user = super().authenticate(request, username=username, password=password, **kwargs) + if user is None: + return None + + if SocialAccount.objects.filter(user=user).exists() and user.disable_password: + # If yes, disable login via password + return None + + return user diff --git a/backend/server/users/migrations/0004_customuser_disable_password.py b/backend/server/users/migrations/0004_customuser_disable_password.py new file mode 100644 index 0000000..3b11ad4 --- /dev/null +++ b/backend/server/users/migrations/0004_customuser_disable_password.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-03-17 01:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_customuser_email'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='disable_password', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/server/users/models.py b/backend/server/users/models.py index 670d6e3..bec028f 100644 --- a/backend/server/users/models.py +++ b/backend/server/users/models.py @@ -8,6 +8,7 @@ class CustomUser(AbstractUser): profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/') uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) public_profile = models.BooleanField(default=False) + disable_password = models.BooleanField(default=False) def __str__(self): return self.username \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index 81bacd6..9d9bd33 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -64,9 +64,11 @@ class UserDetailsSerializer(serializers.ModelSerializer): extra_fields.append('date_joined') if hasattr(UserModel, 'is_staff'): extra_fields.append('is_staff') + if hasattr(UserModel, 'disable_password'): + extra_fields.append('disable_password') fields = ['pk', *extra_fields] - read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') + read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk', 'disable_password') def handle_public_profile_change(self, instance, validated_data): """ @@ -94,8 +96,8 @@ class CustomUserDetailsSerializer(UserDetailsSerializer): class Meta(UserDetailsSerializer.Meta): model = CustomUser - fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password'] - read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password') + fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password', 'disable_password'] + read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password', 'disable_password') @staticmethod def get_has_password(instance): @@ -120,5 +122,5 @@ class CustomUserDetailsSerializer(UserDetailsSerializer): representation.pop('pk', None) # Remove the email field representation.pop('email', None) - + return representation diff --git a/backend/server/users/views.py b/backend/server/users/views.py index 428c5b8..7c763f5 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -13,6 +13,7 @@ from .serializers import CustomUserDetailsSerializer as PublicUserSerializer from allauth.socialaccount.models import SocialApp from adventures.serializers import AdventureSerializer, CollectionSerializer from adventures.models import Adventure, Collection +from allauth.socialaccount.models import SocialAccount User = get_user_model() @@ -71,6 +72,7 @@ class PublicUserListView(APIView): # for every user, remove the field has_password for user in serializer.data: user.pop('has_password', None) + user.pop('disable_password', None) return Response(serializer.data, status=status.HTTP_200_OK) class PublicUserDetailView(APIView): @@ -171,4 +173,35 @@ class EnabledSocialProvidersView(APIView): 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/", 'name': provider.name }) - return Response(providers, status=status.HTTP_200_OK) \ No newline at end of file + return Response(providers, status=status.HTTP_200_OK) + + +class DisablePasswordAuthenticationView(APIView): + """ + Disable password authentication for a user. This is used when a user signs up with a social provider. + """ + +# Allows the user to set the disable_password field to True if they have a social account linked + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Password authentication disabled'), + 400: 'Bad Request' + }, + operation_description="Disable password authentication." + ) + def post(self, request): + user = request.user + if SocialAccount.objects.filter(user=user).exists(): + user.disable_password = True + user.save() + return Response({"detail": "Password authentication disabled."}, status=status.HTTP_200_OK) + return Response({"detail": "No social account linked."}, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request): + user = request.user + user.disable_password = False + user.save() + return Response({"detail": "Password authentication enabled."}, status=status.HTTP_200_OK) + \ No newline at end of file diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 99c7379..6472125 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -34,19 +34,23 @@ class CountrySerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer): num_cities = serializers.SerializerMethodField() + country_name = serializers.CharField(source='country.name', read_only=True) class Meta: model = Region fields = '__all__' - read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities'] + read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities', 'country_name'] def get_num_cities(self, obj): return City.objects.filter(region=obj).count() class CitySerializer(serializers.ModelSerializer): + region_name = serializers.CharField(source='region.name', read_only=True) + country_name = serializers.CharField(source='region.country.name', read_only=True + ) class Meta: model = City fields = '__all__' - read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude'] + read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude', 'region_name', 'country_name'] class VisitedRegionSerializer(CustomModelSerializer): longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) diff --git a/frontend/package.json b/frontend/package.json index ae71362..a2ed840 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "dependencies": { "@lukulent/svelte-umami": "^0.0.3", "@mapbox/togeojson": "^0.16.2", + "dompurify": "^3.2.4", "emoji-picker-element": "^1.26.0", "gsap": "^3.12.7", "marked": "^15.0.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index edde452..318a11b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@mapbox/togeojson': specifier: ^0.16.2 version: 0.16.2 + dompurify: + specifier: ^3.2.4 + version: 3.2.4 emoji-picker-element: specifier: ^1.26.0 version: 1.26.0 @@ -816,6 +819,9 @@ packages: '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vercel/nft@0.27.2': resolution: {integrity: sha512-7LeioS1yE5hwPpQfD3DdH04tuugKjo5KrJk3yK5kAI3Lh76iSsK/ezoFQfzuT08X3ZASQOd1y9ePjLNI9+TxTQ==} engines: {node: '>=16'} @@ -1093,6 +1099,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dompurify@3.2.4: + resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} + earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} @@ -2828,6 +2837,9 @@ snapshots: dependencies: '@types/geojson': 7946.0.14 + '@types/trusted-types@2.0.7': + optional: true + '@vercel/nft@0.27.2': dependencies: '@mapbox/node-pre-gyp': 1.0.11 @@ -3094,6 +3106,10 @@ snapshots: dlv@1.1.3: {} + dompurify@3.2.4: + optionalDependencies: + '@types/trusted-types': 2.0.7 + earcut@2.2.4: {} eastasianwidth@0.2.0: {} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 56c5f94..cfe51ed 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -16,6 +16,7 @@ declare global { uuid: string; public_profile: boolean; has_password: boolean; + disable_password: boolean; } | null; locale: string; } diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index 96648d7..c8b0743 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -14,6 +14,57 @@ let categories: Category[] = []; + const allowedFileTypes = [ + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.txt', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.mp4', + '.mov', + '.avi', + '.mkv', + '.mp3', + '.wav', + '.flac', + '.ogg', + '.m4a', + '.wma', + '.aac', + '.opus', + '.zip', + '.rar', + '.7z', + '.tar', + '.gz', + '.bz2', + '.xz', + '.zst', + '.lz4', + '.lzma', + '.lzo', + '.z', + '.tar.gz', + '.tar.bz2', + '.tar.xz', + '.tar.zst', + '.tar.lz4', + '.tar.lzma', + '.tar.lzo', + '.tar.z', + 'gpx', + 'md', + 'pdf' + ]; + export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal let fileInput: HTMLInputElement; @@ -783,7 +834,7 @@ type="file" id="fileInput" class="file-input file-input-bordered w-full max-w-xs" - accept="image/*,video/*,audio/*,application/pdf,.gpx" + accept={allowedFileTypes.join(',')} on:change={handleFileChange} /> diff --git a/frontend/src/lib/components/CityCard.svelte b/frontend/src/lib/components/CityCard.svelte index 5678af6..6be3b9e 100644 --- a/frontend/src/lib/components/CityCard.svelte +++ b/frontend/src/lib/components/CityCard.svelte @@ -1,4 +1,5 @@