Files
bckWinRsync/server/app/main.py
2026-01-23 11:12:31 +01:00

208 lines
7.0 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta
from typing import List
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlmodel import select
from .config import settings
from .database import get_session, init_db
from .job_runner import JobPayload, runner
from .models import Job, JobStatus, Profile, Token, RunHistory
from .schemas import (
AuthToken,
BackupLog,
BackupStartRequest,
HealthResponse,
JobStatusResponse,
ProfileCreate,
ProfileRead,
TokenType,
)
app = FastAPI(title="Backup Orchestrator", version="0.1.0")
oauth_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _collect_folders(text: str) -> List[str]:
return [entry.strip() for entry in text.split("||") if entry.strip()]
def _profile_to_response(profile: Profile) -> ProfileRead:
return ProfileRead(
id=profile.id, # type: ignore[arg-type]
name=profile.name,
folders=_collect_folders(profile.folders),
description=profile.description,
schedule_enabled=profile.schedule_enabled,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
def _get_current_user(token: str = Depends(oauth_scheme)): # noqa: C901
session = get_session()
try:
record = session.exec(select(Token).where(Token.token == token)).first()
if not record or record.expires_at < datetime.utcnow():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
return record.user
finally:
session.close()
@app.on_event("startup")
def on_startup() -> None:
init_db()
@app.post("/auth/login", response_model=AuthToken)
def login(form_data: OAuth2PasswordRequestForm = Depends()) -> AuthToken:
if form_data.username != settings.api_username or form_data.password != settings.api_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
session = get_session()
expires_at = datetime.utcnow() + timedelta(minutes=settings.token_lifetime_minutes)
token_value = Token(token=_generate_token(), user=form_data.username, expires_at=expires_at)
session.add(token_value)
session.commit()
session.close()
return AuthToken(access_token=token_value.token, token_type=TokenType.bearer, expires_at=expires_at)
def _generate_token(length: int = 36) -> str:
from secrets import token_urlsafe
return token_urlsafe(length)
@app.get("/profiles", response_model=List[ProfileRead])
def list_profiles(_: str = Depends(_get_current_user)) -> List[ProfileRead]:
session = get_session()
try:
results = session.exec(select(Profile).order_by(Profile.created_at)).all()
return [_profile_to_response(profile) for profile in results]
finally:
session.close()
@app.post("/profiles", response_model=ProfileRead, status_code=status.HTTP_201_CREATED)
def create_profile(data: ProfileCreate, _: str = Depends(_get_current_user)) -> ProfileRead:
session = get_session()
try:
profile = Profile(
name=data.name,
folders="||".join(data.folders),
description=data.description,
schedule_enabled=data.schedule_enabled,
)
session.add(profile)
session.commit()
session.refresh(profile)
return _profile_to_response(profile)
finally:
session.close()
@app.put("/profiles/{profile_id}", response_model=ProfileRead)
def update_profile(profile_id: int, data: ProfileCreate, _: str = Depends(_get_current_user)) -> ProfileRead:
session = get_session()
try:
profile = session.get(Profile, profile_id)
if not profile:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
profile.name = data.name
profile.folders = "||".join(data.folders)
profile.description = data.description
profile.schedule_enabled = data.schedule_enabled
profile.updated_at = datetime.utcnow()
session.add(profile)
session.commit()
session.refresh(profile)
return _profile_to_response(profile)
finally:
session.close()
@app.delete("/profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_profile(profile_id: int, _: str = Depends(_get_current_user)) -> None:
session = get_session()
try:
profile = session.get(Profile, profile_id)
if not profile:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
session.delete(profile)
session.commit()
finally:
session.close()
@app.post("/backup/start")
def start_backup(request: BackupStartRequest, _: str = Depends(_get_current_user)) -> dict:
session = get_session()
try:
profile = session.get(Profile, request.profile_id)
if not profile:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
folders = request.folders or _collect_folders(profile.folders)
if not folders:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No folders provided")
job = Job(
profile_name=profile.name,
client_host=request.client_host,
folders="||".join(folders),
ssh_username=request.ssh_username,
run_id=_generate_run_id(),
)
session.add(job)
session.commit()
session.refresh(job)
runner.enqueue(JobPayload(job_id=job.id, ssh_password=request.ssh_password))
return {"job_id": job.id}
finally:
session.close()
def _generate_run_id() -> str:
from uuid import uuid4
return uuid4().hex
@app.get("/backup/status/{job_id}", response_model=JobStatusResponse)
def job_status(job_id: int, _: str = Depends(_get_current_user)) -> JobStatusResponse:
session = get_session()
try:
job = session.get(Job, job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
return JobStatusResponse(
job_id=job.id, # type: ignore[arg-type]
status=job.status.value,
progress=job.progress,
summary=job.summary,
last_log_lines=runner.tail_log(job),
created_at=job.created_at,
updated_at=job.updated_at,
)
finally:
session.close()
@app.get("/backup/log/{job_id}", response_model=BackupLog)
def job_log(job_id: int, _: str = Depends(_get_current_user)) -> BackupLog:
session = get_session()
try:
job = session.get(Job, job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
return BackupLog(job_id=job.id, lines=runner.tail_log(job, lines=200))
finally:
session.close()
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", version=app.version)