From 442a7724a0c7472b578e1467ad3b0dee81735e1e Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 3 Jun 2025 17:59:29 -0400 Subject: [PATCH] feat: enhance Immich integration to support image downloading for shared users and improve access control for adventure images --- .../adventures/views/adventure_image_view.py | 94 ++++++++++++++++++- backend/server/integrations/views.py | 45 +++++++-- 2 files changed, 125 insertions(+), 14 deletions(-) diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py index d76f6a5..9aa82a9 100644 --- a/backend/server/adventures/views/adventure_image_view.py +++ b/backend/server/adventures/views/adventure_image_view.py @@ -3,9 +3,14 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.db.models import Q +from django.core.files.base import ContentFile from adventures.models import Adventure, AdventureImage from adventures.serializers import AdventureImageSerializer +from integrations.models import ImmichIntegration import uuid +import requests +import tempfile +import os class AdventureImageViewSet(viewsets.ModelViewSet): serializer_class = AdventureImageSerializer @@ -56,6 +61,77 @@ class AdventureImageViewSet(viewsets.ModelViewSet): else: return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + # Handle Immich ID for shared users by downloading the image + if (request.user != adventure.user_id and + 'immich_id' in request.data and + request.data.get('immich_id')): + + immich_id = request.data.get('immich_id') + + # Get the shared user's Immich integration + try: + user_integration = ImmichIntegration.objects.get(user_id=request.user) + except ImmichIntegration.DoesNotExist: + return Response({ + "error": "No Immich integration found for your account. Please set up Immich integration first.", + "code": "immich_integration_not_found" + }, status=status.HTTP_400_BAD_REQUEST) + + # Download the image from the shared user's Immich server + try: + immich_response = requests.get( + f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview', + headers={'x-api-key': user_integration.api_key}, + timeout=10 + ) + immich_response.raise_for_status() + + # Create a temporary file with the downloaded content + content_type = immich_response.headers.get('Content-Type', 'image/jpeg') + if not content_type.startswith('image/'): + return Response({ + "error": "Invalid content type returned from Immich server.", + "code": "invalid_content_type" + }, status=status.HTTP_400_BAD_REQUEST) + + # Determine file extension from content type + ext_map = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif' + } + file_ext = ext_map.get(content_type, '.jpg') + filename = f"immich_{immich_id}{file_ext}" + + # Create a Django ContentFile from the downloaded image + image_file = ContentFile(immich_response.content, name=filename) + + # Modify request data to use the downloaded image instead of immich_id + request_data = request.data.copy() + request_data.pop('immich_id', None) # Remove immich_id + + # Create the serializer with the modified data + serializer = self.get_serializer(data=request_data) + serializer.is_valid(raise_exception=True) + + # Save with the downloaded image + adventure = serializer.validated_data['adventure'] + serializer.save(user_id=adventure.user_id, image=image_file) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except requests.exceptions.RequestException as e: + return Response({ + "error": f"Failed to fetch image from Immich server: {str(e)}", + "code": "immich_fetch_failed" + }, status=status.HTTP_502_BAD_GATEWAY) + except Exception as e: + return Response({ + "error": f"Unexpected error processing Immich image: {str(e)}", + "code": "immich_processing_error" + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return super().create(request, *args, **kwargs) def update(self, request, *args, **kwargs): @@ -110,15 +186,25 @@ class AdventureImageViewSet(viewsets.ModelViewSet): except ValueError: return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST) + # Updated queryset to include images from adventures the user owns OR has shared access to queryset = AdventureImage.objects.filter( - Q(adventure__id=adventure_uuid) & Q(user_id=request.user) - ) + Q(adventure__id=adventure_uuid) & ( + Q(adventure__user_id=request.user) | # User owns the adventure + Q(adventure__collection__shared_with=request.user) # User has shared access via collection + ) + ).distinct() serializer = self.get_serializer(queryset, many=True, context={'request': request}) return Response(serializer.data) def get_queryset(self): - return AdventureImage.objects.filter(user_id=self.request.user) + # Updated to include images from adventures the user owns OR has shared access to + return AdventureImage.objects.filter( + Q(adventure__user_id=self.request.user) | # User owns the adventure + Q(adventure__collection__shared_with=self.request.user) # User has shared access via collection + ).distinct() def perform_create(self, serializer): - serializer.save(user_id=self.request.user) \ No newline at end of file + # Always set the image owner to the adventure owner, not the current user + adventure = serializer.validated_data['adventure'] + serializer.save(user_id=adventure.user_id) \ No newline at end of file diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views.py index 82fdde3..ad7a314 100644 --- a/backend/server/integrations/views.py +++ b/backend/server/integrations/views.py @@ -232,9 +232,12 @@ class ImmichIntegrationView(viewsets.ViewSet): def get_by_integration(self, request, integration_id=None, imageid=None): """ GET an Immich image using the integration and asset ID. - - Public adventures: accessible by anyone - - Private adventures: accessible only to the owner - - No AdventureImage: owner can still view via integration + Access levels (in order of priority): + 1. Public adventures: accessible by anyone + 2. Private adventures in public collections: accessible by anyone + 3. Private adventures in private collections shared with user: accessible by shared users + 4. Private adventures: accessible only to the owner + 5. No AdventureImage: owner can still view via integration """ if not imageid or not integration_id: return Response({ @@ -247,22 +250,45 @@ class ImmichIntegrationView(viewsets.ViewSet): integration = get_object_or_404(ImmichIntegration, id=integration_id) owner_id = integration.user_id - # Try to find the image entry + # Try to find the image entry with collection and sharing information image_entry = ( - AdventureImage.objects + AdventureImage.objects .filter(immich_id=imageid, user_id=owner_id) - .select_related('adventure') - .order_by('-adventure__is_public') # True (1) first, False (0) last + .select_related('adventure', 'adventure__collection') + .prefetch_related('adventure__collection__shared_with') + .order_by( + '-adventure__is_public', # Public adventures first + '-adventure__collection__is_public' # Then public collections + ) .first() ) # Access control if image_entry: - if image_entry.adventure.is_public: + adventure = image_entry.adventure + collection = adventure.collection + + # Determine access level + is_authorized = False + + # Level 1: Public adventure (highest priority) + if adventure.is_public: is_authorized = True + + # Level 2: Private adventure in public collection + elif collection and collection.is_public: + is_authorized = True + + # Level 3: Owner access elif request.user.is_authenticated and request.user.id == owner_id: is_authorized = True - else: + + # Level 4: Shared collection access + elif (request.user.is_authenticated and collection and + collection.shared_with.filter(id=request.user.id).exists()): + is_authorized = True + + if not is_authorized: return Response({ 'message': 'This image belongs to a private adventure and you are not authorized.', 'error': True, @@ -276,7 +302,6 @@ class ImmichIntegrationView(viewsets.ViewSet): 'error': True, 'code': 'immich.not_found' }, status=status.HTTP_404_NOT_FOUND) - is_authorized = True # Integration owner fallback # Fetch from Immich try: