483 lines
17 KiB
Python
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)
|