mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-23 14:59:36 +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:
parent
b336a24401
commit
442a7724a0
2 changed files with 125 additions and 14 deletions
|
@ -3,9 +3,14 @@ from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from adventures.models import Adventure, AdventureImage
|
from adventures.models import Adventure, AdventureImage
|
||||||
from adventures.serializers import AdventureImageSerializer
|
from adventures.serializers import AdventureImageSerializer
|
||||||
|
from integrations.models import ImmichIntegration
|
||||||
import uuid
|
import uuid
|
||||||
|
import requests
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
class AdventureImageViewSet(viewsets.ModelViewSet):
|
class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = AdventureImageSerializer
|
serializer_class = AdventureImageSerializer
|
||||||
|
@ -56,6 +61,77 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
else:
|
else:
|
||||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
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)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
|
@ -110,15 +186,25 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST)
|
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(
|
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})
|
serializer = self.get_serializer(queryset, many=True, context={'request': request})
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
def get_queryset(self):
|
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):
|
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)
|
|
@ -232,9 +232,12 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
def get_by_integration(self, request, integration_id=None, imageid=None):
|
def get_by_integration(self, request, integration_id=None, imageid=None):
|
||||||
"""
|
"""
|
||||||
GET an Immich image using the integration and asset ID.
|
GET an Immich image using the integration and asset ID.
|
||||||
- Public adventures: accessible by anyone
|
Access levels (in order of priority):
|
||||||
- Private adventures: accessible only to the owner
|
1. Public adventures: accessible by anyone
|
||||||
- No AdventureImage: owner can still view via integration
|
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:
|
if not imageid or not integration_id:
|
||||||
return Response({
|
return Response({
|
||||||
|
@ -247,22 +250,45 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
integration = get_object_or_404(ImmichIntegration, id=integration_id)
|
integration = get_object_or_404(ImmichIntegration, id=integration_id)
|
||||||
owner_id = integration.user_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 = (
|
image_entry = (
|
||||||
AdventureImage.objects
|
AdventureImage.objects
|
||||||
.filter(immich_id=imageid, user_id=owner_id)
|
.filter(immich_id=imageid, user_id=owner_id)
|
||||||
.select_related('adventure')
|
.select_related('adventure', 'adventure__collection')
|
||||||
.order_by('-adventure__is_public') # True (1) first, False (0) last
|
.prefetch_related('adventure__collection__shared_with')
|
||||||
|
.order_by(
|
||||||
|
'-adventure__is_public', # Public adventures first
|
||||||
|
'-adventure__collection__is_public' # Then public collections
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
if image_entry:
|
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
|
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:
|
elif request.user.is_authenticated and request.user.id == owner_id:
|
||||||
is_authorized = True
|
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({
|
return Response({
|
||||||
'message': 'This image belongs to a private adventure and you are not authorized.',
|
'message': 'This image belongs to a private adventure and you are not authorized.',
|
||||||
'error': True,
|
'error': True,
|
||||||
|
@ -276,7 +302,6 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
'error': True,
|
'error': True,
|
||||||
'code': 'immich.not_found'
|
'code': 'immich.not_found'
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
is_authorized = True # Integration owner fallback
|
|
||||||
|
|
||||||
# Fetch from Immich
|
# Fetch from Immich
|
||||||
try:
|
try:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue