Add limits for content + fix video playback

This commit is contained in:
2025-12-16 02:01:03 +07:00
parent 574140e67d
commit d96f8de568
5 changed files with 166 additions and 44 deletions

View File

@@ -2,8 +2,8 @@
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -171,13 +171,14 @@ async def get_assignment_detail(
)
@router.get("/assignments/{assignment_id}/proof-image")
async def get_assignment_proof_image(
@router.get("/assignments/{assignment_id}/proof-media")
async def get_assignment_proof_media(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof image for an assignment"""
"""Stream the proof media (image or video) for an assignment with Range support"""
# Get assignment
result = await db.execute(
select(Assignment)
@@ -205,15 +206,59 @@ async def get_assignment_proof_image(
# Check if proof exists
if not assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof image for this assignment")
raise HTTPException(status_code=404, detail="No proof media for this assignment")
# Get file from storage
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof image not found in storage")
raise HTTPException(status_code=404, detail="Proof media not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
# Parse range header
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
# Ensure valid range
if start >= file_size:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, file_size - 1)
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
# No range header - return full video with Accept-Ranges
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
@@ -223,6 +268,18 @@ async def get_assignment_proof_image(
)
# Keep old endpoint for backwards compatibility
@router.get("/assignments/{assignment_id}/proof-image")
async def get_assignment_proof_image(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
return await get_assignment_proof_media(assignment_id, request, current_user, db)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,

View File

@@ -23,7 +23,8 @@ class Settings(BaseSettings):
# Uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}

View File

@@ -32,11 +32,16 @@ export const assignmentsApi = {
return response.data
},
// Get proof image as blob URL
getProofImageUrl: async (assignmentId: number): Promise<string> => {
const response = await client.get(`/assignments/${assignmentId}/proof-image`, {
// Get proof media as blob URL (supports both images and videos)
getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
responseType: 'blob',
})
return URL.createObjectURL(response.data)
const contentType = response.headers['content-type'] || ''
const isVideo = contentType.startsWith('video/')
return {
url: URL.createObjectURL(response.data),
type: isVideo ? 'video' : 'image',
}
},
}

View File

@@ -20,7 +20,8 @@ export function AssignmentDetailPage() {
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [proofImageBlobUrl, setProofImageBlobUrl] = useState<string | null>(null)
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
// Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false)
@@ -38,8 +39,8 @@ export function AssignmentDetailPage() {
loadAssignment()
return () => {
// Cleanup blob URL on unmount
if (proofImageBlobUrl) {
URL.revokeObjectURL(proofImageBlobUrl)
if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofMediaBlobUrl)
}
}
}, [id])
@@ -52,13 +53,14 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data)
// Load proof image if exists
// Load proof media if exists
if (data.proof_image_url) {
try {
const blobUrl = await assignmentsApi.getProofImageUrl(parseInt(id))
setProofImageBlobUrl(blobUrl)
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url)
setProofMediaType(type)
} catch {
// Ignore error, image just won't show
// Ignore error, media just won't show
}
}
} catch (err: unknown) {
@@ -251,15 +253,24 @@ export function AssignmentDetailPage() {
Доказательство
</h3>
{/* Proof image */}
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
<div className="mb-4">
{proofImageBlobUrl ? (
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
<video
src={proofMediaBlobUrl}
controls
className="w-full rounded-lg max-h-96 bg-gray-900"
preload="metadata"
/>
) : (
<img
src={proofImageBlobUrl}
src={proofMediaBlobUrl}
alt="Proof"
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
/>
)
) : (
<div className="w-full h-48 bg-gray-900 rounded-lg flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />

View File

@@ -9,6 +9,12 @@ import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Se
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
// File size limits
const MAX_IMAGE_SIZE = 15 * 1024 * 1024 // 15 MB
const MAX_VIDEO_SIZE = 30 * 1024 * 1024 // 30 MB
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov']
export function PlayPage() {
const { id } = useParams<{ id: string }>()
const toast = useToast()
@@ -142,6 +148,38 @@ export function PlayPage() {
}
}
const validateAndSetFile = (
file: File | null,
setFile: (file: File | null) => void,
inputRef: React.RefObject<HTMLInputElement>
) => {
if (!file) {
setFile(null)
return
}
const ext = file.name.split('.').pop()?.toLowerCase() || ''
const isImage = IMAGE_EXTENSIONS.includes(ext)
const isVideo = VIDEO_EXTENSIONS.includes(ext)
if (!isImage && !isVideo) {
toast.error('Неподдерживаемый формат файла')
if (inputRef.current) inputRef.current.value = ''
return
}
const maxSize = isImage ? MAX_IMAGE_SIZE : MAX_VIDEO_SIZE
const maxSizeMB = isImage ? 15 : 30
if (file.size > maxSize) {
toast.error(`Файл слишком большой. Максимум ${maxSizeMB} МБ для ${isImage ? 'изображений' : 'видео'}`)
if (inputRef.current) inputRef.current.value = ''
return
}
setFile(file)
}
const loadData = async () => {
if (!id) return
try {
@@ -651,7 +689,7 @@ export function PlayPage() {
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setEventProofFile(e.target.files?.[0] || null)}
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setEventProofFile, eventFileInputRef)}
/>
{eventProofFile ? (
@@ -666,6 +704,7 @@ export function PlayPage() {
</Button>
</div>
) : (
<div>
<Button
variant="secondary"
className="w-full"
@@ -674,6 +713,10 @@ export function PlayPage() {
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
</div>
@@ -983,7 +1026,7 @@ export function PlayPage() {
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setProofFile(e.target.files?.[0] || null)}
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
/>
{proofFile ? (
@@ -998,6 +1041,7 @@ export function PlayPage() {
</Button>
</div>
) : (
<div>
<Button
variant="secondary"
className="w-full"
@@ -1006,6 +1050,10 @@ export function PlayPage() {
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
</div>