from __future__ import annotations import base64 import binascii import io import os import mimetypes from typing import Optional, Any from PIL import Image as PilImage from django.conf import settings from django.core.files.base import ContentFile from django.core.exceptions import ValidationError from django.db import transaction from rest_framework.request import Request from rest_framework.decorators import api_view, authentication_classes, permission_classes, parser_classes from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response from rest_framework import status from core.models.session import ServiceSession, ProjectSession from core.models.session_image import ServiceSessionImage, ProjectSessionImage from core.models.session_video import ServiceSessionVideo, ProjectSessionVideo from core.permissions import IsProfileAuthenticated from core.services.video import verify_video_bytes, extract_video_metadata, generate_video_thumbnail def _verify_image_bytes_or_400(data: bytes) -> Optional[Response]: try: PilImage.open(io.BytesIO(data)).verify() return None except (PilImage.UnidentifiedImageError, OSError, ValueError): return Response({"detail": "Uploaded file is not a valid image."}, status=status.HTTP_400_BAD_REQUEST) def _normalize_image_to_jpeg(data: bytes, filename: str, content_type: str) -> tuple[bytes, str, str]: """ Convert uploaded image to JPEG if it's in HEIC format. Returns (normalized_bytes, normalized_filename, normalized_content_type). HEIC files from iOS devices are converted to JPEG for compatibility and storage normalization. Other image formats are passed through unchanged. """ # Check if file is HEIC by extension or content type is_heic = ( filename.lower().endswith(('.heic', '.heif')) or content_type in ('image/heic', 'image/heif') ) if is_heic: # Convert HEIC to JPEG try: img = PilImage.open(io.BytesIO(data)) # Convert to RGB if needed (HEIC can have alpha channel) if img.mode in ('RGBA', 'LA', 'P'): # Create white background for transparent images background = PilImage.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) img = background elif img.mode != 'RGB': img = img.convert('RGB') # Save as JPEG output = io.BytesIO() img.save(output, format='JPEG', quality=90, optimize=True) jpeg_data = output.getvalue() # Update filename and content type new_filename = filename.rsplit('.', 1)[0] + '.jpg' new_content_type = 'image/jpeg' return jpeg_data, new_filename, new_content_type except Exception as e: raise ValidationError(f"Failed to convert HEIC image to JPEG: {str(e)}") # Not HEIC, return unchanged return data, filename, content_type def decode_global_id(gid: Optional[str]) -> Optional[str]: """ Decode a Relay Global ID ("Type:uuid") or return the input if it's already a raw ID. """ if gid is None: return None try: decoded = base64.b64decode(gid).decode("utf-8") if ":" in decoded: return decoded.split(":", 1)[1] except (binascii.Error, UnicodeDecodeError): pass return gid def _save_image_for_session( *, request: Request, sess: ServiceSession | ProjectSession, image_model: type[ServiceSessionImage] | type[ProjectSessionImage], session_field_name: str, file_obj, title: str, notes: str = "", ) -> Response: """ Persist an uploaded image for the given session and return a JSON response. Assumes file_obj has already been validated and read to bytes. """ data = file_obj.read() if not data: return Response({"detail": "Empty file upload."}, status=status.HTTP_400_BAD_REQUEST) bad = _verify_image_bytes_or_400(data) if bad: return bad content_type = getattr(file_obj, "content_type", "") or "" filename = getattr(file_obj, "name", "upload.jpg") # Normalize HEIC images to JPEG for storage compatibility try: data, filename, content_type = _normalize_image_to_jpeg(data, filename, content_type) except ValidationError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) with transaction.atomic(): payload = { "title": title, "notes": notes, session_field_name: sess, "uploaded_by_team_profile": request.profile, "content_type": content_type, } img = image_model(**payload) img.image.save(filename, ContentFile(data), save=True) i: Any = img session_key = f"{session_field_name}Id" return Response( { "id": str(i.id), "title": i.title, "notes": i.notes, session_key: str(getattr(sess, "id", "")), "contentType": i.content_type, "width": i.width, "height": i.height, "image": getattr(i.image, "url", None), "thumbnail": getattr(i.thumbnail, "url", None), "createdAt": i.created_at.isoformat(), "uploadedByTeamProfileId": str(i.uploaded_by_team_profile.id) if i.uploaded_by_team_profile else None, }, status=status.HTTP_201_CREATED, ) @api_view(["POST"]) @authentication_classes([]) @permission_classes([IsProfileAuthenticated]) @parser_classes([MultiPartParser, FormParser]) def upload_service_session_image(request: Request) -> Response: """ POST multipart/form-data: - file: image file - sessionId: Relay Global ID or raw UUID of ServiceSession - title: optional string """ file_obj = request.FILES.get("file") session_id = request.data.get("sessionId") title = request.data.get("title") or "" notes = request.data.get("notes") or "" if not file_obj: return Response({"detail": "No file provided."}, status=status.HTTP_400_BAD_REQUEST) if not session_id: return Response({"detail": "sessionId is required."}, status=status.HTTP_400_BAD_REQUEST) sess_pk = decode_global_id(session_id) if not sess_pk: return Response({"detail": "Invalid sessionId."}, status=status.HTTP_400_BAD_REQUEST) try: sess = ServiceSession.objects.get(pk=sess_pk) except ServiceSession.DoesNotExist: return Response({"detail": "Session not found."}, status=status.HTTP_404_NOT_FOUND) return _save_image_for_session( request=request, sess=sess, image_model=ServiceSessionImage, session_field_name="service_session", file_obj=file_obj, title=title, notes=notes, ) @api_view(["POST"]) @authentication_classes([]) @permission_classes([IsProfileAuthenticated]) @parser_classes([MultiPartParser, FormParser]) def upload_project_session_image(request: Request) -> Response: """ POST multipart/form-data: - file: image file - sessionId: Relay Global ID or raw UUID of ProjectSession - title: optional string """ file_obj = request.FILES.get("file") session_id = request.data.get("sessionId") title = request.data.get("title") or "" notes = request.data.get("notes") or "" if not file_obj: return Response({"detail": "No file provided."}, status=status.HTTP_400_BAD_REQUEST) if not session_id: return Response({"detail": "sessionId is required."}, status=status.HTTP_400_BAD_REQUEST) sess_pk = decode_global_id(session_id) if not sess_pk: return Response({"detail": "Invalid sessionId."}, status=status.HTTP_400_BAD_REQUEST) try: sess = ProjectSession.objects.get(pk=sess_pk) except ProjectSession.DoesNotExist: return Response({"detail": "Session not found."}, status=status.HTTP_404_NOT_FOUND) return _save_image_for_session( request=request, sess=sess, image_model=ProjectSessionImage, session_field_name="project_session", file_obj=file_obj, title=title, notes=notes, ) def _save_video_for_session( *, request: Request, sess: ServiceSession | ProjectSession, video_model: type[ServiceSessionVideo] | type[ProjectSessionVideo], session_field_name: str, file_obj, title: str, notes: str = "", ) -> Response: """ Persist an uploaded video for the given session and return a JSON response. Validates video, extracts metadata, generates thumbnail, and saves to the database. Note: Video processing (ffmpeg) requires local file paths, so we write to temp files before saving to S3 storage. This works for both local and S3 storage backends. """ import tempfile from django.core.files import File data = file_obj.read() if not data: return Response({"detail": "Empty file upload."}, status=status.HTTP_400_BAD_REQUEST) filename = getattr(file_obj, "name", "upload.mp4") # Validate video file and get content type try: content_type = verify_video_bytes(data, filename) except ValidationError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) # Write video to temp file for ffmpeg processing (required for S3 storage) video_ext = os.path.splitext(filename)[1] or '.mp4' video_fd, video_tmp_path = tempfile.mkstemp(suffix=video_ext) thumb_fd, thumb_tmp_path = tempfile.mkstemp(suffix='.jpg') try: # Write video bytes to temp file os.write(video_fd, data) os.close(video_fd) os.close(thumb_fd) # Extract metadata from temp file (before saving to S3) metadata = extract_video_metadata(video_tmp_path) # Generate thumbnail from temp file thumbnail_generated = generate_video_thumbnail(video_tmp_path, thumb_tmp_path, timestamp=1.0) with transaction.atomic(): payload = { "title": title, "notes": notes, session_field_name: sess, "uploaded_by_team_profile": request.profile, "content_type": content_type, } vid = video_model(**payload) # Set metadata before saving if metadata: vid.width, vid.height, vid.duration_seconds = metadata # Save video to storage (S3 or local) vid.video.save(filename, ContentFile(data), save=True) # Save thumbnail if generated if thumbnail_generated and os.path.exists(thumb_tmp_path): with open(thumb_tmp_path, 'rb') as thumb_file: vid.thumbnail.save( f'thumb_{vid.id}.jpg', File(thumb_file), save=False ) vid.save() finally: # Clean up temp files if os.path.exists(video_tmp_path): os.unlink(video_tmp_path) if os.path.exists(thumb_tmp_path): os.unlink(thumb_tmp_path) v: Any = vid session_key = f"{session_field_name}Id" return Response( { "id": str(v.id), "title": v.title, "notes": v.notes, session_key: str(getattr(sess, "id", "")), "contentType": v.content_type, "width": v.width, "height": v.height, "durationSeconds": v.duration_seconds, "fileSizeBytes": v.file_size_bytes, "video": getattr(v.video, "url", None), "thumbnail": getattr(v.thumbnail, "url", None), "createdAt": v.created_at.isoformat(), "uploadedByTeamProfileId": str(v.uploaded_by_team_profile.id) if v.uploaded_by_team_profile else None, }, status=status.HTTP_201_CREATED, ) @api_view(["POST"]) @authentication_classes([]) @permission_classes([IsProfileAuthenticated]) @parser_classes([MultiPartParser, FormParser]) def upload_service_session_video(request: Request) -> Response: """ POST multipart/form-data: - file: video file - sessionId: Relay Global ID or raw UUID of ServiceSession - title: optional string - notes: optional string """ file_obj = request.FILES.get("file") session_id = request.data.get("sessionId") title = request.data.get("title") or "" notes = request.data.get("notes") or "" if not file_obj: return Response({"detail": "No file provided."}, status=status.HTTP_400_BAD_REQUEST) if not session_id: return Response({"detail": "sessionId is required."}, status=status.HTTP_400_BAD_REQUEST) sess_pk = decode_global_id(session_id) if not sess_pk: return Response({"detail": "Invalid sessionId."}, status=status.HTTP_400_BAD_REQUEST) try: sess = ServiceSession.objects.get(pk=sess_pk) except ServiceSession.DoesNotExist: return Response({"detail": "Session not found."}, status=status.HTTP_404_NOT_FOUND) return _save_video_for_session( request=request, sess=sess, video_model=ServiceSessionVideo, session_field_name="service_session", file_obj=file_obj, title=title, notes=notes, ) @api_view(["POST"]) @authentication_classes([]) @permission_classes([IsProfileAuthenticated]) @parser_classes([MultiPartParser, FormParser]) def upload_project_session_video(request: Request) -> Response: """ POST multipart/form-data: - file: video file - sessionId: Relay Global ID or raw UUID of ProjectSession - title: optional string - notes: optional string """ file_obj = request.FILES.get("file") session_id = request.data.get("sessionId") title = request.data.get("title") or "" notes = request.data.get("notes") or "" if not file_obj: return Response({"detail": "No file provided."}, status=status.HTTP_400_BAD_REQUEST) if not session_id: return Response({"detail": "sessionId is required."}, status=status.HTTP_400_BAD_REQUEST) sess_pk = decode_global_id(session_id) if not sess_pk: return Response({"detail": "Invalid sessionId."}, status=status.HTTP_400_BAD_REQUEST) try: sess = ProjectSession.objects.get(pk=sess_pk) except ProjectSession.DoesNotExist: return Response({"detail": "Session not found."}, status=status.HTTP_404_NOT_FOUND) return _save_video_for_session( request=request, sess=sess, video_model=ProjectSessionVideo, session_field_name="project_session", file_obj=file_obj, title=title, notes=notes, ) @api_view(["GET"]) @permission_classes([IsProfileAuthenticated]) def serve_protected_media(request, path: str): """ DEPRECATED: Legacy auth-gated media serving for filesystem storage. Kept for backwards compatibility during S3 migration. With S3 storage, nginx uses auth_request to media_auth_check() instead. """ from django.http import HttpResponse, Http404 # Normalize and prevent path traversal media_root = os.path.abspath(str(settings.MEDIA_ROOT)) requested_path = os.path.abspath(os.path.join(media_root, path)) if not requested_path.startswith(media_root) or not os.path.isfile(requested_path): raise Http404("Media file not found") # Guess content-type (fallback to octet-stream) content_type, _ = mimetypes.guess_type(requested_path) content_type = content_type or "application/octet-stream" # Construct the internal path for nginx internal_prefix = "/media-internal/" # must match nginx internal location internal_path = internal_prefix + path # Use Django's HttpResponse instead of DRF's Response # This respects the ConditionalCorsMiddleware and avoids duplicate CORS headers resp = HttpResponse(status=200) resp["Content-Type"] = content_type resp["X-Accel-Redirect"] = internal_path # Optionally set caching headers or Content-Disposition return resp @api_view(["GET", "HEAD"]) @permission_classes([IsProfileAuthenticated]) def media_auth_check(request, path: str = ""): """ Lightweight auth check endpoint for nginx auth_request. Nginx calls this before proxying to S3. If the user is authenticated (via Oathkeeper session cookie), returns 204 to allow access. The IsProfileAuthenticated permission class handles the actual auth check and will return 401/403 if the user is not authenticated. Args: path: The media path being requested (for logging/auditing) Returns: 204 No Content if authenticated (nginx proceeds to S3) 401/403 if not authenticated (handled by permission class) """ from django.http import HttpResponse return HttpResponse(status=204)