nexus-5/core/views.py
2026-01-26 11:09:40 -05:00

483 lines
17 KiB
Python

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)