feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash, create_access_token
from app.models.models import User
from app.schemas.schemas import LoginRequest, TokenResponse, UserResponse
from app.api.deps import get_current_user
from app.services.event_service import log_event
router = APIRouter(prefix="/auth", tags=["Authentication"])
@router.post("/login", response_model=TokenResponse)
async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == credentials.email.strip().lower()))
user = result.scalar_one_or_none()
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated"
)
access_token = create_access_token(subject=user.id, role=user.role)
await log_event(
db=db,
event_type="LOGIN",
message=f"User {user.email} logged in successfully.",
severity="INFO",
user_email=user.email
)
return {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"role": user.role
}
}
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/seed-admin")
async def seed_admin(db: AsyncSession = Depends(get_db)):
"""Creates default admin if no users exist in the database."""
result = await db.execute(select(User))
first_user = result.scalar_one_or_none()
if first_user:
return {"message": "Admin already exists"}
admin = User(
email="admin@oneverdrive.local",
hashed_password=get_password_hash("Admin1234!"),
full_name="System Administrator",
role="ADMIN",
is_active=True
)
db.add(admin)
await db.commit()
return {"message": "Default admin created: admin@oneverdrive.local / Admin1234!"}
+116
View File
@@ -0,0 +1,116 @@
import os
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.models.models import User, BackupFile, Client
from app.schemas.schemas import BackupFileResponse
from app.api.deps import get_current_user, require_admin
from app.storage.local import storage_provider
from app.services.event_service import log_event
router = APIRouter(prefix="/backups", tags=["Backups & Restore"])
@router.get("", response_model=List[BackupFileResponse])
async def list_backups(
client_id: Optional[int] = None,
job_id: Optional[int] = None,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(BackupFile).where(BackupFile.is_active == True)
if client_id:
query = query.where(BackupFile.client_id == client_id)
if job_id:
query = query.where(BackupFile.job_id == job_id)
query = query.order_by(desc(BackupFile.created_at)).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.get("/{backup_id}", response_model=BackupFileResponse)
async def get_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id))
bf = res.scalar_one_or_none()
if not bf:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found")
return bf
@router.get("/{backup_id}/download")
async def download_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Allows downloading a backup file directly from the Web interface."""
res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id))
bf = res.scalar_one_or_none()
if not bf or not bf.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found")
try:
abs_path = await storage_provider.get_file_path(bf.relative_path)
except Exception as ex:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex))
await log_event(
db=db,
event_type="RESTORE_STARTED",
message=f"Backup download initiated for '{bf.filename}' ({bf.file_size / (1024*1024):.2f} MB).",
severity="INFO",
client_id=bf.client_id,
job_id=bf.job_id,
user_email=current_user.email
)
return FileResponse(
path=abs_path,
filename=bf.filename,
media_type="application/octet-stream"
)
@router.delete("/{backup_id}")
async def delete_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id))
bf = res.scalar_one_or_none()
if not bf:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found")
try:
await storage_provider.delete_backup_file(bf.relative_path)
except Exception:
pass
bf.is_active = False
# Update client storage counter
client_res = await db.execute(select(Client).where(Client.id == bf.client_id))
client = client_res.scalar_one_or_none()
if client:
client.storage_used_bytes = max(0, client.storage_used_bytes - bf.file_size)
await db.commit()
await log_event(
db=db,
event_type="FILE_DELETED",
message=f"Manual deletion of backup '{bf.filename}'.",
severity="WARNING",
client_id=bf.client_id,
job_id=bf.job_id,
user_email=admin_user.email
)
return {"message": "Backup file deleted"}
+245
View File
@@ -0,0 +1,245 @@
import uuid
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.core.database import get_db
from app.core.security import generate_registration_code, generate_device_token, hash_token
from app.models.models import User, Client, ClientCredential, RegistrationCode
from app.schemas.schemas import (
ClientResponse, ClientRegisterRequest, ClientRegisterResponse,
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest
)
from app.api.deps import get_current_user, require_admin, get_current_client
from app.services.event_service import log_event
from app.ws.manager import ws_manager
router = APIRouter(prefix="/clients", tags=["Clients"])
@router.get("", response_model=List[ClientResponse])
async def list_clients(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Client).order_by(desc(Client.created_at)))
return result.scalars().all()
@router.post("/registration-code", response_model=RegistrationCodeResponse)
async def create_registration_code(
payload: RegistrationCodeCreate,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
code_str = generate_registration_code()
expires = datetime.now(timezone.utc) + timedelta(hours=payload.expires_in_hours)
reg_code = RegistrationCode(
code=code_str,
client_name_hint=payload.client_name_hint,
expires_at=expires,
is_used=False
)
db.add(reg_code)
await db.commit()
await db.refresh(reg_code)
await log_event(
db=db,
event_type="REGISTRATION_CODE_GENERATED",
message=f"Generated registration code {code_str} (hint: {payload.client_name_hint or 'None'}).",
severity="INFO",
user_email=admin_user.email
)
return reg_code
@router.post("/register", response_model=ClientRegisterResponse)
async def register_client(
payload: ClientRegisterRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""Called by the Windows Agent during initial setup to register against the server."""
# Find valid registration code
now = datetime.now(timezone.utc)
res = await db.execute(
select(RegistrationCode).where(
RegistrationCode.code == payload.registration_code.strip().upper(),
RegistrationCode.is_used == False,
RegistrationCode.expires_at > now
)
)
reg_code = res.scalar_one_or_none()
if not reg_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid, expired, or already used registration code."
)
# Determine next client code (e.g., CLIENT-0001)
count_res = await db.execute(select(func.count(Client.id)))
client_count = count_res.scalar() or 0
client_code = f"CLIENT-{client_count + 1:04d}"
client_ip = request.client.host if request.client else None
# Create Client
client = Client(
client_code=client_code,
name=payload.name or reg_code.client_name_hint or payload.hostname,
hostname=payload.hostname,
os_info=payload.os_info,
ip_address=client_ip,
agent_version=payload.agent_version,
status="ONLINE",
last_seen_at=now,
is_active=True
)
db.add(client)
await db.flush()
# Generate device unique ID and secret token
device_id = str(uuid.uuid4())
device_token = generate_device_token()
token_hash = hash_token(device_token)
credential = ClientCredential(
client_id=client.id,
device_id=device_id,
token_hash=token_hash,
name=f"{payload.hostname} Agent",
is_revoked=False,
created_at=now,
last_used_at=now
)
db.add(credential)
# Mark registration code as used
reg_code.is_used = True
await db.commit()
await log_event(
db=db,
event_type="CLIENT_REGISTERED",
message=f"Windows client registered: {client.name} ({client.client_code}, Hostname: {client.hostname}, IP: {client_ip})",
severity="INFO",
client_id=client.id,
ip_address=client_ip,
details={"device_id": device_id, "os_info": payload.os_info}
)
await ws_manager.broadcast("CLIENT_REGISTERED", {
"id": client.id,
"client_code": client.client_code,
"name": client.name,
"hostname": client.hostname,
"status": client.status
})
return {
"client_code": client.client_code,
"device_id": device_id,
"device_token": device_token,
"name": client.name,
"server_time": now
}
@router.post("/{client_id}/heartbeat")
async def client_heartbeat(
client_id: int,
payload: ClientHeartbeatRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_client: Client = Depends(get_current_client)
):
"""Heartbeat endpoint invoked periodically by the Windows Agent."""
if current_client.id != client_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client ID mismatch")
now = datetime.now(timezone.utc)
current_client.last_seen_at = now
current_client.status = payload.status
if payload.agent_version:
current_client.agent_version = payload.agent_version
client_ip = payload.ip_address or (request.client.host if request.client else None)
if client_ip:
current_client.ip_address = client_ip
await db.commit()
await ws_manager.broadcast("CLIENT_HEARTBEAT", {
"client_id": current_client.id,
"status": current_client.status,
"last_seen_at": now.isoformat()
})
return {"status": "ok", "server_time": now}
@router.get("/{client_id}", response_model=ClientResponse)
async def get_client(
client_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
res = await db.execute(select(Client).where(Client.id == client_id))
client = res.scalar_one_or_none()
if not client:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
return client
@router.post("/{client_id}/revoke")
async def revoke_client_credentials(
client_id: int,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
"""Revokes all active authentication tokens for a client."""
res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id))
creds = res.scalars().all()
for cred in creds:
cred.is_revoked = True
client_res = await db.execute(select(Client).where(Client.id == client_id))
client = client_res.scalar_one_or_none()
if client:
client.status = "OFFLINE"
await db.commit()
await log_event(
db=db,
event_type="CREDENTIALS_REVOKED",
message=f"Revoked credentials for client ID {client_id}.",
severity="WARNING",
client_id=client_id,
user_email=admin_user.email
)
return {"message": "Client credentials successfully revoked"}
@router.delete("/{client_id}")
async def delete_client(
client_id: int,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
res = await db.execute(select(Client).where(Client.id == client_id))
client = res.scalar_one_or_none()
if not client:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
await db.delete(client)
await db.commit()
await log_event(
db=db,
event_type="CLIENT_DELETED",
message=f"Deleted client {client.name} ({client.client_code}).",
severity="WARNING",
user_email=admin_user.email
)
return {"message": f"Client {client.client_code} deleted"}
+87
View File
@@ -0,0 +1,87 @@
from typing import Optional
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import decode_access_token, hash_token
from app.models.models import User, Client, ClientCredential
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
"""Authenticates web UI users via JWT Bearer token."""
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"}
)
payload = decode_access_token(token)
if not payload or "sub" not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired authentication token",
headers={"WWW-Authenticate": "Bearer"}
)
user_id = int(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or deactivated"
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""Ensures current user has ADMIN role."""
if user.role != "ADMIN":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privilege required"
)
return user
async def get_current_client(
x_device_id: Optional[str] = Header(None, alias="X-Device-Id"),
x_device_token: Optional[str] = Header(None, alias="X-Device-Token"),
db: AsyncSession = Depends(get_db)
) -> Client:
"""Authenticates Windows Agent devices via individual device ID and secret token."""
if not x_device_id or not x_device_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Device authentication headers (X-Device-Id, X-Device-Token) required"
)
token_hash = hash_token(x_device_token)
result = await db.execute(
select(ClientCredential)
.where(
ClientCredential.device_id == x_device_id,
ClientCredential.token_hash == token_hash,
ClientCredential.is_revoked == False
)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or revoked device credentials"
)
client_res = await db.execute(select(Client).where(Client.id == credential.client_id, Client.is_active == True))
client = client_res.scalar_one_or_none()
if not client:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Client device not found or inactive"
)
return client
+32
View File
@@ -0,0 +1,32 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.models.models import EventLog, User
from app.schemas.schemas import EventLogResponse
from app.api.deps import get_current_user
router = APIRouter(prefix="/events", tags=["Audit & Events"])
@router.get("", response_model=List[EventLogResponse])
async def list_events(
client_id: Optional[int] = None,
job_id: Optional[int] = None,
event_type: Optional[str] = None,
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(EventLog)
if client_id:
query = query.where(EventLog.client_id == client_id)
if job_id:
query = query.where(EventLog.job_id == job_id)
if event_type:
query = query.where(EventLog.event_type == event_type)
query = query.order_by(desc(EventLog.timestamp)).limit(limit)
result = await db.execute(query)
return result.scalars().all()
+175
View File
@@ -0,0 +1,175 @@
from typing import List, Optional
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.core.database import get_db
from app.models.models import User, Client, BackupJob
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse
from app.api.deps import get_current_user, require_admin, get_current_client
from app.services.event_service import log_event
from app.ws.manager import ws_manager
router = APIRouter(prefix="/jobs", tags=["Backup Jobs"])
@router.get("", response_model=List[JobResponse])
async def list_jobs(
client_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(BackupJob)
if client_id:
query = query.where(BackupJob.client_id == client_id)
query = query.order_by(desc(BackupJob.created_at))
result = await db.execute(query)
return result.scalars().all()
@router.get("/agent/assigned", response_model=List[JobResponse])
async def get_agent_jobs(
db: AsyncSession = Depends(get_db),
current_client: Client = Depends(get_current_client)
):
"""Called by the Windows Agent to query its active backup jobs."""
query = select(BackupJob).where(BackupJob.client_id == current_client.id, BackupJob.is_active == True)
result = await db.execute(query)
return result.scalars().all()
@router.post("", response_model=JobResponse)
async def create_job(
payload: JobCreate,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
# Verify client exists
client_res = await db.execute(select(Client).where(Client.id == payload.client_id))
client = client_res.scalar_one_or_none()
if not client:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
count_res = await db.execute(select(func.count(BackupJob.id)))
job_count = count_res.scalar() or 0
job_code = f"JOB-{job_count + 1:03d}"
job = BackupJob(
job_code=job_code,
client_id=payload.client_id,
name=payload.name,
source_path=payload.source_path,
file_patterns=payload.file_patterns,
schedule_cron=payload.schedule_cron,
keep_daily=payload.keep_daily,
keep_weekly=payload.keep_weekly,
keep_monthly=payload.keep_monthly,
min_stable_time_seconds=payload.min_stable_time_seconds,
status="IDLE",
is_active=True
)
db.add(job)
await db.commit()
await db.refresh(job)
await log_event(
db=db,
event_type="JOB_CREATED",
message=f"Created backup job '{job.name}' ({job.job_code}) for client {client.name}.",
severity="INFO",
client_id=client.id,
job_id=job.id,
user_email=admin_user.email
)
return job
@router.get("/{job_id}", response_model=JobResponse)
async def get_job(
job_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
job = res.scalar_one_or_none()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
return job
@router.put("/{job_id}", response_model=JobResponse)
async def update_job(
job_id: int,
payload: JobUpdate,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
job = res.scalar_one_or_none()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(job, field, value)
await db.commit()
await db.refresh(job)
await log_event(
db=db,
event_type="CONFIG_CHANGED",
message=f"Updated backup job '{job.name}' ({job.job_code}).",
severity="INFO",
client_id=job.client_id,
job_id=job.id,
user_email=admin_user.email
)
return job
@router.delete("/{job_id}")
async def delete_job(
job_id: int,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_admin)
):
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
job = res.scalar_one_or_none()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
await db.delete(job)
await db.commit()
await log_event(
db=db,
event_type="JOB_DELETED",
message=f"Deleted backup job {job.job_code}.",
severity="WARNING",
client_id=job.client_id,
user_email=admin_user.email
)
return {"message": f"Job {job.job_code} deleted"}
@router.post("/{job_id}/trigger")
async def trigger_job(
job_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Notifies the connected agent to start executing this backup job immediately."""
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
job = res.scalar_one_or_none()
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
job.status = "QUEUED"
await db.commit()
# Broadcast event so the agent / web UI knows the job was triggered
await ws_manager.broadcast("JOB_TRIGGERED", {
"job_id": job.id,
"job_code": job.job_code,
"client_id": job.client_id,
"timestamp": datetime.now(timezone.utc).isoformat()
})
return {"message": f"Job {job.job_code} triggered"}
+81
View File
@@ -0,0 +1,81 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.models import Client, BackupJob, BackupSession, User
from app.schemas.schemas import DashboardStatsResponse
from app.api.deps import get_current_user
from app.storage.local import storage_provider
router = APIRouter(prefix="/stats", tags=["Dashboard Statistics"])
@router.get("", response_model=DashboardStatsResponse)
async def get_dashboard_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Clients aggregation
total_clients_res = await db.execute(select(func.count(Client.id)).where(Client.is_active == True))
total_clients = total_clients_res.scalar() or 0
# Consider online if last_seen_at was in the last 5 minutes
cutoff_online = datetime.now(timezone.utc) - timedelta(minutes=5)
online_clients_res = await db.execute(
select(func.count(Client.id)).where(
Client.is_active == True,
Client.status == "ONLINE",
Client.last_seen_at >= cutoff_online
)
)
online_clients = online_clients_res.scalar() or 0
offline_clients = max(0, total_clients - online_clients)
# Jobs count
total_jobs_res = await db.execute(select(func.count(BackupJob.id)).where(BackupJob.is_active == True))
total_jobs = total_jobs_res.scalar() or 0
# Backups today
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
backups_today_res = await db.execute(
select(func.count(BackupSession.id)).where(BackupSession.started_at >= today_start)
)
backups_today = backups_today_res.scalar() or 0
backups_success_res = await db.execute(
select(func.count(BackupSession.id)).where(
BackupSession.started_at >= today_start,
BackupSession.status == "SUCCESS"
)
)
backups_success = backups_success_res.scalar() or 0
backups_failed_res = await db.execute(
select(func.count(BackupSession.id)).where(
BackupSession.started_at >= today_start,
BackupSession.status == "FAILED"
)
)
backups_failed = backups_failed_res.scalar() or 0
# Active uploads
active_uploads_res = await db.execute(
select(func.count(BackupSession.id)).where(BackupSession.status.in_(["PENDING", "UPLOADING", "ASSEMBLING"]))
)
active_uploads = active_uploads_res.scalar() or 0
# Storage metrics
storage_stats = await storage_provider.get_storage_stats()
return {
"total_clients": total_clients,
"online_clients": online_clients,
"offline_clients": offline_clients,
"total_jobs": total_jobs,
"backups_today_count": backups_today,
"backups_today_success": backups_success,
"backups_today_failed": backups_failed,
"active_uploads_count": active_uploads,
"storage": storage_stats
}
+153
View File
@@ -0,0 +1,153 @@
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)}"
)