Moved to S3
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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 |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<img
|
{proofImageBlobUrl ? (
|
||||||
src={`${API_URL}${assignment.proof_image_url}`}
|
<img
|
||||||
alt="Proof"
|
src={proofImageBlobUrl}
|
||||||
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user