Moved to S3
This commit is contained in:
@@ -11,5 +11,14 @@ OPENAI_API_KEY=sk-...
|
||||
# Telegram Bot
|
||||
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)
|
||||
VITE_API_URL=/api/v1
|
||||
|
||||
@@ -3,6 +3,7 @@ Assignment details and dispute system endpoints.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -17,6 +18,7 @@ from app.schemas import (
|
||||
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
|
||||
)
|
||||
from app.schemas.user import UserPublic
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter(tags=["assignments"])
|
||||
|
||||
@@ -133,10 +135,7 @@ async def get_assignment_detail(
|
||||
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||
|
||||
# Build proof URLs
|
||||
proof_image_url = None
|
||||
if assignment.proof_path:
|
||||
# Extract filename from path
|
||||
proof_image_url = f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}"
|
||||
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
|
||||
|
||||
return AssignmentDetailResponse(
|
||||
id=assignment.id,
|
||||
@@ -153,7 +152,7 @@ async def get_assignment_detail(
|
||||
game=GameShort(
|
||||
id=game.id,
|
||||
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,
|
||||
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)
|
||||
async def create_dispute(
|
||||
assignment_id: int,
|
||||
@@ -421,7 +472,7 @@ async def get_returned_assignments(
|
||||
game=GameShort(
|
||||
id=a.challenge.game.id,
|
||||
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,
|
||||
created_at=a.challenge.created_at,
|
||||
|
||||
@@ -11,8 +11,6 @@ from app.models import (
|
||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||
)
|
||||
from fastapi import UploadFile, File, Form
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from app.schemas import (
|
||||
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.user import UserPublic
|
||||
from app.services.events import event_service
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
@@ -937,13 +936,13 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
||||
game=GameShort(
|
||||
id=game.id,
|
||||
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,
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
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,
|
||||
points_earned=assignment.points_earned,
|
||||
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}",
|
||||
)
|
||||
|
||||
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
|
||||
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Upload file to storage
|
||||
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
||||
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:
|
||||
f.write(contents)
|
||||
|
||||
assignment.proof_path = str(filepath)
|
||||
assignment.proof_path = file_path
|
||||
else:
|
||||
assignment.proof_url = proof_url
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.api.deps import (
|
||||
DbSession, CurrentUser,
|
||||
@@ -11,6 +9,7 @@ from app.api.deps import (
|
||||
from app.core.config import settings
|
||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter(tags=["games"])
|
||||
|
||||
@@ -35,7 +34,7 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
||||
return GameResponse(
|
||||
id=game.id,
|
||||
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,
|
||||
genre=game.genre,
|
||||
status=game.status,
|
||||
@@ -354,15 +353,20 @@ async def upload_cover(
|
||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||
)
|
||||
|
||||
# Save file
|
||||
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}"
|
||||
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Delete old cover if exists
|
||||
if game.cover_path:
|
||||
await storage_service.delete_file(game.cover_path)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
# Upload file
|
||||
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()
|
||||
|
||||
return await get_game(game_id, current_user, db)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
from app.models import User
|
||||
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -64,16 +63,21 @@ async def upload_avatar(
|
||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||
)
|
||||
|
||||
# Save file
|
||||
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}"
|
||||
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Delete old avatar if exists
|
||||
if current_user.avatar_path:
|
||||
await storage_service.delete_file(current_user.avatar_path)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
# Upload file
|
||||
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
|
||||
current_user.avatar_path = str(filepath)
|
||||
current_user.avatar_path = file_path
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.api.deps import DbSession, CurrentUser
|
||||
from app.core.config import settings
|
||||
@@ -19,6 +17,7 @@ from app.schemas import (
|
||||
)
|
||||
from app.services.points import PointsService
|
||||
from app.services.events import event_service
|
||||
from app.services.storage import storage_service
|
||||
|
||||
router = APIRouter(tags=["wheel"])
|
||||
|
||||
@@ -195,7 +194,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
game=GameResponse(
|
||||
id=game.id,
|
||||
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,
|
||||
genre=game.genre,
|
||||
added_by=None,
|
||||
@@ -250,7 +249,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
||||
created_at=challenge.created_at,
|
||||
),
|
||||
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,
|
||||
points_earned=assignment.points_earned,
|
||||
streak_at_completion=assignment.streak_at_completion,
|
||||
@@ -313,14 +312,16 @@ async def complete_assignment(
|
||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
||||
)
|
||||
|
||||
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}"
|
||||
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Upload file to storage
|
||||
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
||||
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:
|
||||
f.write(contents)
|
||||
|
||||
assignment.proof_path = str(filepath)
|
||||
assignment.proof_path = file_path
|
||||
else:
|
||||
assignment.proof_url = proof_url
|
||||
|
||||
@@ -571,7 +572,7 @@ async def get_my_history(
|
||||
created_at=a.challenge.created_at,
|
||||
),
|
||||
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,
|
||||
points_earned=a.points_earned,
|
||||
streak_at_completion=a.streak_at_completion,
|
||||
|
||||
@@ -27,6 +27,15 @@ class Settings(BaseSettings):
|
||||
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
|
||||
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
|
||||
def ALLOWED_EXTENSIONS(self) -> set:
|
||||
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
|
||||
|
||||
@@ -52,5 +52,7 @@ class User(Base):
|
||||
@property
|
||||
def avatar_url(self) -> str | None:
|
||||
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
|
||||
|
||||
269
backend/app/services/storage.py
Normal file
269
backend/app/services/storage.py
Normal 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()
|
||||
@@ -28,5 +28,8 @@ httpx==0.26.0
|
||||
aiofiles==23.2.1
|
||||
python-magic==0.4.27
|
||||
|
||||
# S3 Storage
|
||||
boto3==1.34.0
|
||||
|
||||
# Utils
|
||||
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 |
@@ -28,6 +28,14 @@ services:
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
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:
|
||||
- ./backend/uploads:/app/uploads
|
||||
- ./backend/app:/app/app
|
||||
|
||||
@@ -31,4 +31,12 @@ export const assignmentsApi = {
|
||||
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
Send, Flag
|
||||
} from 'lucide-react'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
export function AssignmentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -20,6 +18,7 @@ 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)
|
||||
|
||||
// Dispute creation
|
||||
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||
@@ -35,6 +34,12 @@ export function AssignmentDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadAssignment()
|
||||
return () => {
|
||||
// Cleanup blob URL on unmount
|
||||
if (proofImageBlobUrl) {
|
||||
URL.revokeObjectURL(proofImageBlobUrl)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const loadAssignment = async () => {
|
||||
@@ -44,6 +49,16 @@ export function AssignmentDetailPage() {
|
||||
try {
|
||||
const data = await assignmentsApi.getDetail(parseInt(id))
|
||||
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) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
||||
@@ -237,11 +252,17 @@ export function AssignmentDetailPage() {
|
||||
{/* Proof image */}
|
||||
{assignment.proof_image_url && (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={`${API_URL}${assignment.proof_image_url}`}
|
||||
alt="Proof"
|
||||
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
|
||||
/>
|
||||
{proofImageBlobUrl ? (
|
||||
<img
|
||||
src={proofImageBlobUrl}
|
||||
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" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user