Moved to S3

This commit is contained in:
2025-12-16 01:25:21 +07:00
parent c7966656d8
commit 87ecd9756c
15 changed files with 446 additions and 56 deletions

View File

@@ -11,5 +11,14 @@ OPENAI_API_KEY=sk-...
# Telegram Bot # Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF... TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
S3_ENABLED=false
S3_BUCKET_NAME=your-bucket-name
S3_REGION=ru-1
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_ENDPOINT_URL=https://s3.firstvds.ru
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
# Frontend (for build) # Frontend (for build)
VITE_API_URL=/api/v1 VITE_API_URL=/api/v1

View File

@@ -3,6 +3,7 @@ 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
from fastapi.responses import Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -17,6 +18,7 @@ from app.schemas import (
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse, MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
) )
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.services.storage import storage_service
router = APIRouter(tags=["assignments"]) router = APIRouter(tags=["assignments"])
@@ -133,10 +135,7 @@ async def get_assignment_detail(
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS) can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build proof URLs # Build proof URLs
proof_image_url = None proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
if assignment.proof_path:
# Extract filename from path
proof_image_url = f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}"
return AssignmentDetailResponse( return AssignmentDetailResponse(
id=assignment.id, id=assignment.id,
@@ -153,7 +152,7 @@ async def get_assignment_detail(
game=GameShort( game=GameShort(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, cover_url=storage_service.get_url(game.cover_path, "covers"),
), ),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
@@ -172,6 +171,58 @@ async def get_assignment_detail(
) )
@router.get("/assignments/{assignment_id}/proof-image")
async def get_assignment_proof_image(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof image for an assignment"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if proof exists
if not assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof image 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")
content, content_type = file_data
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
@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,
@@ -421,7 +472,7 @@ async def get_returned_assignments(
game=GameShort( game=GameShort(
id=a.challenge.game.id, id=a.challenge.game.id,
title=a.challenge.game.title, title=a.challenge.game.title,
cover_url=f"/uploads/covers/{a.challenge.game.cover_path.split('/')[-1]}" if a.challenge.game.cover_path else None, cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
), ),
is_generated=a.challenge.is_generated, is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at, created_at=a.challenge.created_at,

View File

@@ -11,8 +11,6 @@ from app.models import (
SwapRequest as SwapRequestModel, SwapRequestStatus, User, SwapRequest as SwapRequestModel, SwapRequestStatus, User,
) )
from fastapi import UploadFile, File, Form from fastapi import UploadFile, File, Form
from pathlib import Path
import uuid
from app.schemas import ( from app.schemas import (
EventCreate, EventResponse, ActiveEventResponse, EventEffects, EventCreate, EventResponse, ActiveEventResponse, EventEffects,
@@ -24,6 +22,7 @@ from app.core.config import settings
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["events"]) router = APIRouter(tags=["events"])
@@ -937,13 +936,13 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
game=GameShort( game=GameShort(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, cover_url=storage_service.get_url(game.cover_path, "covers"),
), ),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
status=assignment.status, status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment, proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned, points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
@@ -1065,14 +1064,16 @@ async def complete_event_assignment(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
) )
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" # Upload file to storage
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename filename = storage_service.generate_filename(assignment_id, proof_file.filename)
filepath.parent.mkdir(parents=True, exist_ok=True) file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
with open(filepath, "wb") as f: assignment.proof_path = file_path
f.write(contents)
assignment.proof_path = str(filepath)
else: else:
assignment.proof_url = proof_url assignment.proof_url = proof_url

View File

@@ -1,8 +1,6 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import ( from app.api.deps import (
DbSession, CurrentUser, DbSession, CurrentUser,
@@ -11,6 +9,7 @@ from app.api.deps import (
from app.core.config import settings from app.core.config import settings
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.services.storage import storage_service
router = APIRouter(tags=["games"]) router = APIRouter(tags=["games"])
@@ -35,7 +34,7 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
return GameResponse( return GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url, download_url=game.download_url,
genre=game.genre, genre=game.genre,
status=game.status, status=game.status,
@@ -354,15 +353,20 @@ async def upload_cover(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
) )
# Save file # Delete old cover if exists
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}" if game.cover_path:
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename await storage_service.delete_file(game.cover_path)
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f: # Upload file
f.write(contents) filename = storage_service.generate_filename(game_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
game.cover_path = str(filepath) game.cover_path = file_path
await db.commit() await db.commit()
return await get_game(game_id, current_user, db) return await get_game(game_id, current_user, db)

View File

@@ -1,12 +1,11 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select from sqlalchemy import select
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
from app.models import User from app.models import User
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
from app.services.storage import storage_service
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -64,16 +63,21 @@ async def upload_avatar(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
) )
# Save file # Delete old avatar if exists
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}" if current_user.avatar_path:
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename await storage_service.delete_file(current_user.avatar_path)
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f: # Upload file
f.write(contents) filename = storage_service.generate_filename(current_user.id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="avatars",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update user # Update user
current_user.avatar_path = str(filepath) current_user.avatar_path = file_path
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)

View File

@@ -3,8 +3,6 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
@@ -19,6 +17,7 @@ from app.schemas import (
) )
from app.services.points import PointsService from app.services.points import PointsService
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["wheel"]) router = APIRouter(tags=["wheel"])
@@ -195,7 +194,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game=GameResponse( game=GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url, download_url=game.download_url,
genre=game.genre, genre=game.genre,
added_by=None, added_by=None,
@@ -250,7 +249,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
status=assignment.status, status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment, proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned, points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
@@ -313,14 +312,16 @@ async def complete_assignment(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
) )
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" # Upload file to storage
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename filename = storage_service.generate_filename(assignment_id, proof_file.filename)
filepath.parent.mkdir(parents=True, exist_ok=True) file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
with open(filepath, "wb") as f: assignment.proof_path = file_path
f.write(contents)
assignment.proof_path = str(filepath)
else: else:
assignment.proof_url = proof_url assignment.proof_url = proof_url
@@ -571,7 +572,7 @@ async def get_my_history(
created_at=a.challenge.created_at, created_at=a.challenge.created_at,
), ),
status=a.status, status=a.status,
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url, proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
proof_comment=a.proof_comment, proof_comment=a.proof_comment,
points_earned=a.points_earned, points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion, streak_at_completion=a.streak_at_completion,

View File

@@ -27,6 +27,15 @@ class Settings(BaseSettings):
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"}
# S3 Storage (FirstVDS)
S3_ENABLED: bool = False
S3_BUCKET_NAME: str = ""
S3_REGION: str = "ru-1"
S3_ACCESS_KEY_ID: str = ""
S3_SECRET_ACCESS_KEY: str = ""
S3_ENDPOINT_URL: str = ""
S3_PUBLIC_URL: str = ""
@property @property
def ALLOWED_EXTENSIONS(self) -> set: def ALLOWED_EXTENSIONS(self) -> set:
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS

View File

@@ -52,5 +52,7 @@ class User(Base):
@property @property
def avatar_url(self) -> str | None: def avatar_url(self) -> str | None:
if self.avatar_path: if self.avatar_path:
return f"/uploads/avatars/{self.avatar_path.split('/')[-1]}" # Lazy import to avoid circular dependency
from app.services.storage import storage_service
return storage_service.get_url(self.avatar_path, "avatars")
return None return None

View File

@@ -0,0 +1,269 @@
"""
Storage service for file uploads.
Supports both local filesystem and S3-compatible storage (FirstVDS).
"""
import logging
import uuid
from pathlib import Path
from typing import Literal
import boto3
from botocore.exceptions import ClientError, BotoCoreError
from botocore.config import Config
from app.core.config import settings
logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"]
class StorageService:
"""Unified storage service with S3 and local filesystem support."""
def __init__(self):
self._s3_client = None
@property
def s3_client(self):
"""Lazy initialization of S3 client."""
if self._s3_client is None and settings.S3_ENABLED:
logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}")
try:
# Use signature_version=s3v4 for S3-compatible storage
self._s3_client = boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT_URL,
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
region_name=settings.S3_REGION or "us-east-1",
config=Config(signature_version="s3v4"),
)
logger.info("S3 client initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize S3 client: {e}")
self._s3_client = None
return self._s3_client
def generate_filename(self, prefix: str | int, original_filename: str | None) -> str:
"""Generate unique filename with prefix."""
ext = "jpg"
if original_filename and "." in original_filename:
ext = original_filename.rsplit(".", 1)[-1].lower()
return f"{prefix}_{uuid.uuid4().hex}.{ext}"
async def upload_file(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str = "application/octet-stream",
) -> str:
"""
Upload file to storage.
Returns:
Path/key to the uploaded file (relative path for local, S3 key for S3)
"""
if settings.S3_ENABLED:
try:
return await self._upload_to_s3(content, folder, filename, content_type)
except Exception as e:
logger.error(f"S3 upload failed, falling back to local: {e}")
return await self._upload_to_local(content, folder, filename)
else:
return await self._upload_to_local(content, folder, filename)
async def _upload_to_s3(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str,
) -> str:
"""Upload file to S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
raise RuntimeError("S3 client not initialized")
try:
logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}")
self.s3_client.put_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
Body=content,
ContentType=content_type,
)
logger.info(f"Successfully uploaded to S3: {key}")
return key
except (ClientError, BotoCoreError) as e:
logger.error(f"S3 upload error: {e}")
raise RuntimeError(f"Failed to upload to S3: {e}")
async def _upload_to_local(
self,
content: bytes,
folder: StorageFolder,
filename: str,
) -> str:
"""Upload file to local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(content)
return str(filepath)
def get_url(self, path: str | None, folder: StorageFolder) -> str | None:
"""
Get public URL for a file.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder (avatars, covers, proofs)
Returns:
Public URL or None if path is None
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
# S3 URL
return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}"
else:
# Local URL
return f"/uploads/{folder}/{filename}"
async def delete_file(self, path: str | None) -> bool:
"""
Delete file from storage.
Args:
path: File path/key
Returns:
True if deleted, False otherwise
"""
if not path:
return False
if settings.S3_ENABLED:
return await self._delete_from_s3(path)
else:
return await self._delete_from_local(path)
async def _delete_from_s3(self, key: str) -> bool:
"""Delete file from S3."""
try:
self.s3_client.delete_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
return True
except ClientError:
return False
async def _delete_from_local(self, path: str) -> bool:
"""Delete file from local filesystem."""
try:
filepath = Path(path)
if filepath.exists():
filepath.unlink()
return True
return False
except Exception:
return False
async def get_file(
self,
path: str,
folder: StorageFolder,
) -> tuple[bytes, str] | None:
"""
Get file content from storage.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder
Returns:
Tuple of (content bytes, content_type) or None if not found
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
return await self._get_from_s3(folder, filename)
else:
return await self._get_from_local(folder, filename)
async def _get_from_s3(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
logger.error("S3 client not initialized")
return None
try:
response = self.s3_client.get_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
content = response["Body"].read()
content_type = response.get("ContentType", "application/octet-stream")
return content, content_type
except ClientError as e:
logger.error(f"S3 get error for {key}: {e}")
return None
async def _get_from_local(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
if not filepath.exists():
return None
try:
with open(filepath, "rb") as f:
content = f.read()
# Determine content type from extension
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
content_types = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"mp4": "video/mp4",
"webm": "video/webm",
"mov": "video/quicktime",
}
content_type = content_types.get(ext, "application/octet-stream")
return content, content_type
except Exception as e:
logger.error(f"Local get error for {filepath}: {e}")
return None
# Singleton instance
storage_service = StorageService()

View File

@@ -28,5 +28,8 @@ httpx==0.26.0
aiofiles==23.2.1 aiofiles==23.2.1
python-magic==0.4.27 python-magic==0.4.27
# S3 Storage
boto3==1.34.0
# Utils # Utils
python-dotenv==1.0.0 python-dotenv==1.0.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -28,6 +28,14 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
S3_REGION: ${S3_REGION:-ru-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
S3_PUBLIC_URL: ${S3_PUBLIC_URL:-}
volumes: volumes:
- ./backend/uploads:/app/uploads - ./backend/uploads:/app/uploads
- ./backend/app:/app/app - ./backend/app:/app/app

View File

@@ -31,4 +31,12 @@ export const assignmentsApi = {
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`) const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
return response.data return response.data
}, },
// Get proof image as blob URL
getProofImageUrl: async (assignmentId: number): Promise<string> => {
const response = await client.get(`/assignments/${assignmentId}/proof-image`, {
responseType: 'blob',
})
return URL.createObjectURL(response.data)
},
} }

View File

@@ -10,8 +10,6 @@ import {
Send, Flag Send, Flag
} from 'lucide-react' } from 'lucide-react'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export function AssignmentDetailPage() { export function AssignmentDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -20,6 +18,7 @@ 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)
// Dispute creation // Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false) const [showDisputeForm, setShowDisputeForm] = useState(false)
@@ -35,6 +34,12 @@ export function AssignmentDetailPage() {
useEffect(() => { useEffect(() => {
loadAssignment() loadAssignment()
return () => {
// Cleanup blob URL on unmount
if (proofImageBlobUrl) {
URL.revokeObjectURL(proofImageBlobUrl)
}
}
}, [id]) }, [id])
const loadAssignment = async () => { const loadAssignment = async () => {
@@ -44,6 +49,16 @@ export function AssignmentDetailPage() {
try { try {
const data = await assignmentsApi.getDetail(parseInt(id)) const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data) setAssignment(data)
// Load proof image if exists
if (data.proof_image_url) {
try {
const blobUrl = await assignmentsApi.getProofImageUrl(parseInt(id))
setProofImageBlobUrl(blobUrl)
} catch {
// Ignore error, image just won't show
}
}
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || 'Не удалось загрузить данные') setError(error.response?.data?.detail || 'Не удалось загрузить данные')
@@ -237,11 +252,17 @@ export function AssignmentDetailPage() {
{/* Proof image */} {/* Proof image */}
{assignment.proof_image_url && ( {assignment.proof_image_url && (
<div className="mb-4"> <div className="mb-4">
{proofImageBlobUrl ? (
<img <img
src={`${API_URL}${assignment.proof_image_url}`} src={proofImageBlobUrl}
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">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
)}
</div> </div>
)} )}