1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 21:09:37 +02:00

feat: enhance Immich integration to support image downloading for shared users and improve access control for adventure images

This commit is contained in:
Sean Morley 2025-06-03 17:59:29 -04:00
parent b336a24401
commit 442a7724a0
2 changed files with 125 additions and 14 deletions

View file

@ -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)
# 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)

View file

@ -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
.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: