154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
from typing import Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Header, Request, Query, UploadFile, File
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from app.core.database import get_db
|
|
from app.models.models import Client, BackupSession
|
|
from app.schemas.schemas import (
|
|
UploadSessionInitRequest, UploadSessionInitResponse,
|
|
UploadSessionStatusResponse, ChunkUploadResponse,
|
|
UploadSessionCompleteResponse
|
|
)
|
|
from app.api.deps import get_current_client
|
|
from app.services.upload_service import (
|
|
create_or_resume_session, process_chunk_upload,
|
|
get_session_status_info, complete_session
|
|
)
|
|
|
|
router = APIRouter(prefix="/upload", tags=["Chunk Upload Engine"])
|
|
|
|
@router.post("/session", response_model=UploadSessionInitResponse)
|
|
async def init_session(
|
|
payload: UploadSessionInitRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""
|
|
Initializes a new upload session or resumes an existing incomplete session
|
|
for the specified file. Returns list of previously received chunks so the agent
|
|
only transmits the remaining blocks.
|
|
"""
|
|
session, received_chunks = await create_or_resume_session(
|
|
db=db,
|
|
client=current_client,
|
|
filename=payload.filename,
|
|
file_size=payload.file_size,
|
|
sha256_full=payload.sha256,
|
|
chunk_size=payload.chunk_size,
|
|
job_id=payload.job_id
|
|
)
|
|
|
|
return {
|
|
"session_code": session.session_code,
|
|
"filename": session.filename,
|
|
"file_size": session.file_size,
|
|
"chunk_size": session.chunk_size,
|
|
"total_chunks": session.total_chunks,
|
|
"received_chunks": received_chunks,
|
|
"status": session.status
|
|
}
|
|
|
|
@router.post("/{session_code}/chunk", response_model=ChunkUploadResponse)
|
|
async def upload_chunk(
|
|
session_code: str,
|
|
request: Request,
|
|
chunk_index: int = Query(..., description="0-indexed chunk number"),
|
|
chunk_sha256: Optional[str] = Query(None, description="SHA-256 hash of this specific chunk"),
|
|
x_chunk_index: Optional[int] = Header(None, alias="X-Chunk-Index"),
|
|
x_chunk_sha256: Optional[str] = Header(None, alias="X-Chunk-SHA256"),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""
|
|
Receives and stores a single chunk of data for an active upload session.
|
|
Accepts raw binary body directly via streaming.
|
|
"""
|
|
idx = x_chunk_index if x_chunk_index is not None else chunk_index
|
|
sha = x_chunk_sha256 or chunk_sha256
|
|
|
|
res = await db.execute(
|
|
select(BackupSession).where(
|
|
BackupSession.session_code == session_code,
|
|
BackupSession.client_id == current_client.id
|
|
)
|
|
)
|
|
session = res.scalar_one_or_none()
|
|
if not session:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found")
|
|
|
|
chunk_data = await request.body()
|
|
if not chunk_data:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty chunk payload")
|
|
|
|
try:
|
|
result = await process_chunk_upload(
|
|
db=db,
|
|
session=session,
|
|
chunk_index=idx,
|
|
chunk_data=chunk_data,
|
|
chunk_sha256=sha
|
|
)
|
|
return result
|
|
except ValueError as ex:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex))
|
|
|
|
@router.get("/{session_code}/status", response_model=UploadSessionStatusResponse)
|
|
async def get_session_status(
|
|
session_code: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""
|
|
Returns the current status of an upload session, including the list of received
|
|
chunks and missing chunks for easy re-connection and resumption.
|
|
"""
|
|
res = await db.execute(
|
|
select(BackupSession).where(
|
|
BackupSession.session_code == session_code,
|
|
BackupSession.client_id == current_client.id
|
|
)
|
|
)
|
|
session = res.scalar_one_or_none()
|
|
if not session:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found")
|
|
|
|
return await get_session_status_info(db, session)
|
|
|
|
@router.post("/{session_code}/complete", response_model=UploadSessionCompleteResponse)
|
|
async def complete_upload(
|
|
session_code: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_client: Client = Depends(get_current_client)
|
|
):
|
|
"""
|
|
Triggers sequential file assembly and final SHA-256 integrity verification.
|
|
If integrity passes, the backup file is registered and retention policies are applied.
|
|
"""
|
|
res = await db.execute(
|
|
select(BackupSession).where(
|
|
BackupSession.session_code == session_code,
|
|
BackupSession.client_id == current_client.id
|
|
)
|
|
)
|
|
session = res.scalar_one_or_none()
|
|
if not session:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found")
|
|
|
|
try:
|
|
backup_file = await complete_session(db, session)
|
|
return {
|
|
"session_code": session.session_code,
|
|
"filename": backup_file.filename,
|
|
"relative_path": backup_file.relative_path,
|
|
"file_size": backup_file.file_size,
|
|
"sha256": backup_file.sha256,
|
|
"status": "SUCCESS",
|
|
"completed_at": backup_file.created_at
|
|
}
|
|
except Exception as ex:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Integrity check or assembly failed: {str(ex)}"
|
|
)
|