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. Assignment details and dispute system endpoints.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -171,13 +171,14 @@ async def get_assignment_detail(
) )
@router.get("/assignments/{assignment_id}/proof-image") @router.get("/assignments/{assignment_id}/proof-media")
async def get_assignment_proof_image( async def get_assignment_proof_media(
assignment_id: int, assignment_id: int,
request: Request,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
): ):
"""Stream the proof image for an assignment""" """Stream the proof media (image or video) for an assignment with Range support"""
# Get assignment # Get assignment
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
@@ -205,15 +206,59 @@ async def get_assignment_proof_image(
# Check if proof exists # Check if proof exists
if not assignment.proof_path: 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 # Get file from storage
file_data = await storage_service.get_file(assignment.proof_path, "proofs") file_data = await storage_service.get_file(assignment.proof_path, "proofs")
if not file_data: 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 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( return Response(
content=content, content=content,
media_type=content_type, 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) @router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute( async def create_dispute(
assignment_id: int, assignment_id: int,

View File

@@ -23,7 +23,8 @@ class Settings(BaseSettings):
# Uploads # Uploads
UPLOAD_DIR: str = "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_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"} ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}

View File

@@ -32,11 +32,16 @@ export const assignmentsApi = {
return response.data return response.data
}, },
// Get proof image as blob URL // Get proof media as blob URL (supports both images and videos)
getProofImageUrl: async (assignmentId: number): Promise<string> => { getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(`/assignments/${assignmentId}/proof-image`, { const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
responseType: 'blob', 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 [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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 // Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false) const [showDisputeForm, setShowDisputeForm] = useState(false)
@@ -38,8 +39,8 @@ export function AssignmentDetailPage() {
loadAssignment() loadAssignment()
return () => { return () => {
// Cleanup blob URL on unmount // Cleanup blob URL on unmount
if (proofImageBlobUrl) { if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofImageBlobUrl) URL.revokeObjectURL(proofMediaBlobUrl)
} }
} }
}, [id]) }, [id])
@@ -52,13 +53,14 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id)) const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data) setAssignment(data)
// Load proof image if exists // Load proof media if exists
if (data.proof_image_url) { if (data.proof_image_url) {
try { try {
const blobUrl = await assignmentsApi.getProofImageUrl(parseInt(id)) const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofImageBlobUrl(blobUrl) setProofMediaBlobUrl(url)
setProofMediaType(type)
} catch { } catch {
// Ignore error, image just won't show // Ignore error, media just won't show
} }
} }
} catch (err: unknown) { } catch (err: unknown) {
@@ -251,15 +253,24 @@ export function AssignmentDetailPage() {
Доказательство Доказательство
</h3> </h3>
{/* Proof image */} {/* Proof media (image or video) */}
{assignment.proof_image_url && ( {assignment.proof_image_url && (
<div className="mb-4"> <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 <img
src={proofImageBlobUrl} src={proofMediaBlobUrl}
alt="Proof" alt="Proof"
className="w-full rounded-lg max-h-96 object-contain bg-gray-900" 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"> <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" /> <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 { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' 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() { export function PlayPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const toast = useToast() 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 () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
@@ -651,7 +689,7 @@ export function PlayPage() {
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
className="hidden" className="hidden"
onChange={(e) => setEventProofFile(e.target.files?.[0] || null)} onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setEventProofFile, eventFileInputRef)}
/> />
{eventProofFile ? ( {eventProofFile ? (
@@ -666,6 +704,7 @@ export function PlayPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div>
<Button <Button
variant="secondary" variant="secondary"
className="w-full" className="w-full"
@@ -674,6 +713,10 @@ export function PlayPage() {
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
Выбрать файл Выбрать файл
</Button> </Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)} )}
</div> </div>
@@ -983,7 +1026,7 @@ export function PlayPage() {
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
className="hidden" className="hidden"
onChange={(e) => setProofFile(e.target.files?.[0] || null)} onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
/> />
{proofFile ? ( {proofFile ? (
@@ -998,6 +1041,7 @@ export function PlayPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div>
<Button <Button
variant="secondary" variant="secondary"
className="w-full" className="w-full"
@@ -1006,6 +1050,10 @@ export function PlayPage() {
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
Выбрать файл Выбрать файл
</Button> </Button>
<p className="text-xs text-gray-500 mt-1 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)} )}
</div> </div>