first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-12 21:26:12 +02:00
commit ff7dbcb4e4
123 changed files with 27749 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import settings
from app.core.security import create_access_token, get_password_hash, verify_password
from app.models.user import User
from app.schemas.auth import (
ChangePasswordRequest,
RegisterRequest,
TokenResponse,
UpdateUserPreferencesRequest,
UserResponse,
)
router = APIRouter()
@router.post("/register", response_model=UserResponse)
def register(payload: RegisterRequest, db: Session = Depends(get_db)):
if not settings.allow_registration:
raise HTTPException(status_code=403, detail="Registration is disabled")
existing = db.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=409, detail="Username already exists")
user = User(username=payload.username, password_hash=get_password_hash(payload.password))
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
async def login(request: Request, db: Session = Depends(get_db)):
username = None
password = None
content_type = (request.headers.get("content-type") or "").lower()
if "application/json" in content_type:
try:
payload = await request.json()
except Exception:
payload = {}
username = payload.get("username")
password = payload.get("password")
else:
form_data = await request.form()
username = form_data.get("username")
password = form_data.get("password")
if not username or not password:
raise HTTPException(status_code=422, detail="Username and password are required")
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(
subject=user.username,
expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
)
return TokenResponse(access_token=token, user=UserResponse.model_validate(user))
@router.get("/me", response_model=UserResponse)
def me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/preferences", response_model=UserResponse)
def update_preferences(
payload: UpdateUserPreferencesRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
preferred_language = (payload.preferred_language or 'pl').strip().lower()
preferred_font = (payload.preferred_font or 'default').strip().lower()
if preferred_language not in {'pl', 'en', 'es', 'no'}:
raise HTTPException(status_code=422, detail='Unsupported language')
if preferred_font not in {'default', 'adwaita_mono', 'hack'}:
raise HTTPException(status_code=422, detail='Unsupported font')
current_user.preferred_language = preferred_language
current_user.preferred_font = preferred_font
db.add(current_user)
db.commit()
db.refresh(current_user)
return current_user
@router.post("/change-password")
def change_password(
payload: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is invalid")
current_user.password_hash = get_password_hash(payload.new_password)
db.add(current_user)
db.commit()
return {"message": "Password changed successfully"}

View File

@@ -0,0 +1,125 @@
import io
import zipfile
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.user import User
from app.schemas.backup import BackupDiffResponse, BackupResponse, BulkActionRequest
from app.services.backup_service import backup_service
router = APIRouter()
@router.get("", response_model=list[BackupResponse])
def list_backups(
search: str | None = Query(default=None),
backup_type: str | None = Query(default=None, pattern="^(export|binary)$"),
router_id: int | None = Query(default=None),
sort_by: str = Query(default="created_at"),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return backup_service.list_backups(
db,
current_user,
search=search,
backup_type=backup_type,
router_id=router_id,
sort_by=sort_by,
order=order,
)
@router.get("/router/{router_id}", response_model=list[BackupResponse])
def list_router_backups(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return backup_service.list_router_backups(db, current_user, router_id)
@router.post("/routers/export-all")
def export_all(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return backup_service.export_all(db, current_user)
@router.post("/routers/binary-all")
def binary_all(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return backup_service.binary_all(db, current_user)
@router.post("/router/{router_id}/export", response_model=BackupResponse)
def export_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return backup_service.export_router(db, current_user, router_id)
@router.post("/router/{router_id}/binary", response_model=BackupResponse)
def binary_backup(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return backup_service.binary_backup(db, current_user, router_id)
@router.post("/router/{router_id}/upload/{backup_id}")
def upload_to_router(router_id: int, backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
backup_service.upload_backup_to_router(db, current_user, router_id, backup_id)
return {"message": "Backup uploaded to router"}
@router.delete("/{backup_id}")
def delete_backup(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
backup_service.delete_backup(db, current_user, backup_id)
return {"message": "Backup deleted"}
@router.get("/{backup_id}/download")
def download_backup(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
backup = backup_service.get_backup_for_user(db, current_user, backup_id)
return FileResponse(path=backup.file_path, filename=backup.file_name)
@router.get("/{backup_id}/view")
def view_export(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
backup = backup_service.get_backup_for_user(db, current_user, backup_id)
if backup.backup_type != "export":
raise HTTPException(status_code=400, detail="Only export backups can be viewed")
with open(backup.file_path, "r", encoding="utf-8", errors="ignore") as handle:
return {"content": handle.read(), "backup": BackupResponse.model_validate(backup_service._serialize_backup(backup))}
@router.post("/{backup_id}/email")
def email_backup(backup_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
backup_service.email_backup(db, current_user, backup_id)
return {"message": "Backup sent by email"}
@router.get("/{left_id}/diff/{right_id}", response_model=BackupDiffResponse)
def diff_backups(left_id: int, right_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return backup_service.diff_backups(db, current_user, left_id, right_id)
@router.get("/{left_id}/diff/{right_id}/html", response_class=HTMLResponse)
def diff_backups_html(left_id: int, right_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
result = backup_service.diff_backups(db, current_user, left_id, right_id)
return HTMLResponse(result["diff_html"])
@router.post("/bulk")
def bulk_action(payload: BulkActionRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
backups = [backup_service.get_backup_for_user(db, current_user, backup_id) for backup_id in payload.backup_ids]
if payload.action == "delete":
for backup in backups:
backup_service.delete_backup(db, current_user, backup.id, commit=False)
db.commit()
return {"message": f"Deleted {len(backups)} backups"}
if payload.action == "download":
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w") as archive:
for backup in backups:
archive.write(backup.file_path, backup.file_name)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="backups.zip"'},
)
raise HTTPException(status_code=400, detail="Unsupported bulk action")

View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.backup import Backup
from app.models.log import OperationLog
from app.models.router import Router
from app.models.user import User
from app.schemas.dashboard import DashboardResponse
from app.services.file_service import get_storage_stats
router = APIRouter()
@router.get("", response_model=DashboardResponse)
def get_dashboard(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
routers_count = db.query(func.count(Router.id)).filter(Router.owner_id == current_user.id).scalar() or 0
export_count = (
db.query(func.count(Backup.id))
.join(Router)
.filter(Router.owner_id == current_user.id, Backup.backup_type == "export")
.scalar()
or 0
)
binary_count = (
db.query(func.count(Backup.id))
.join(Router)
.filter(Router.owner_id == current_user.id, Backup.backup_type == "binary")
.scalar()
or 0
)
recent_logs = db.query(OperationLog).order_by(OperationLog.timestamp.desc()).limit(10).all()
storage = get_storage_stats()
return DashboardResponse(
routers_count=routers_count,
export_count=export_count,
binary_count=binary_count,
total_backups=export_count + binary_count,
storage=storage,
recent_logs=recent_logs,
)

View File

@@ -0,0 +1,19 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.deps import get_db
router = APIRouter()
@router.get("/health")
def health(db: Session = Depends(get_db)):
status = "ok"
try:
db.execute(text("SELECT 1"))
except Exception:
status = "error"
return {"status": status, "timestamp": datetime.now(timezone.utc).isoformat()}

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.log import OperationLog
from app.models.user import User
from app.services.log_service import log_service
router = APIRouter()
@router.get("")
def get_logs(
limit: int = Query(100, ge=1, le=500),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_ = current_user
return db.query(OperationLog).order_by(OperationLog.timestamp.desc()).limit(limit).all()
@router.delete("/older-than/{days}")
def delete_logs(
days: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_ = current_user
if days < 1:
raise HTTPException(status_code=400, detail="Days must be >= 1")
deleted = log_service.delete_older_than(db, days)
return {"message": f"Deleted {deleted} logs", "deleted": deleted}

View File

@@ -0,0 +1,71 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.router import Router
from app.models.user import User
from app.schemas.router import RouterCreate, RouterResponse, RouterTestConnection, RouterUpdate
from app.services.router_service import router_service
from app.services.settings_service import settings_service
router = APIRouter()
@router.get("", response_model=list[RouterResponse])
def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all()
@router.post("", response_model=RouterResponse)
def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = Router(**payload.model_dump(), owner_id=current_user.id)
db.add(router)
db.commit()
db.refresh(router)
return router
@router.get("/{router_id}", response_model=RouterResponse)
def get_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
return router
@router.put("/{router_id}", response_model=RouterResponse)
def update_router(router_id: int, payload: RouterUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(router, key, value)
db.add(router)
db.commit()
db.refresh(router)
return router
@router.delete("/{router_id}")
def delete_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
for backup in list(router.backups):
path = Path(backup.file_path)
if path.exists():
path.unlink()
db.delete(router)
db.commit()
return {"message": "Router deleted"}
@router.get("/{router_id}/test-connection", response_model=RouterTestConnection)
def test_connection(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first()
if not router:
raise HTTPException(status_code=404, detail="Router not found")
settings = settings_service.get_or_create(db)
return router_service.test_connection(db, router, settings.global_ssh_key)

View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.security import verify_password
from app.models.settings import GlobalSettings
from app.models.user import User
from app.schemas.settings import (
RevealSshKeyRequest,
RevealSshKeyResponse,
SchedulerStatusResponse,
SettingsResponse,
SettingsUpdate,
)
from app.services.notification_service import notification_service
from app.services.scheduler import scheduler_service
from app.services.settings_service import settings_service
router = APIRouter()
def serialize_settings(settings: GlobalSettings) -> SettingsResponse:
payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump()
payload['global_ssh_key'] = None
payload['has_global_ssh_key'] = bool((settings.global_ssh_key or '').strip())
return SettingsResponse.model_validate(payload)
@router.get('', response_model=SettingsResponse)
def get_settings(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
_ = current_user
settings = settings_service.get_or_create(db)
return serialize_settings(settings)
@router.get('/scheduler-status', response_model=SchedulerStatusResponse)
def get_scheduler_status(current_user: User = Depends(get_current_user)):
_ = current_user
return scheduler_service.scheduler_status()
@router.put('', response_model=SettingsResponse)
def update_settings(payload: SettingsUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
_ = current_user
settings = settings_service.update(db, payload)
scheduler_service.reschedule()
return serialize_settings(settings)
@router.post('/reveal-ssh-key', response_model=RevealSshKeyResponse)
def reveal_ssh_key(
payload: RevealSshKeyRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not verify_password(payload.password, current_user.password_hash):
raise HTTPException(status_code=400, detail='Current password is invalid')
settings = settings_service.get_or_create(db)
return {'global_ssh_key': settings.global_ssh_key}
@router.post('/test-email')
def test_email(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
_ = current_user
settings = settings_service.get_or_create(db)
notification_service.send_test_email(settings)
return {'message': 'Test email sent'}
@router.post('/test-pushover')
def test_pushover(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
_ = current_user
settings = settings_service.get_or_create(db)
notification_service.send_test_pushover(settings)
return {'message': 'Test pushover sent'}

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from app.api.deps import get_current_user
from app.models.user import User
from app.schemas.swos_beta import SwosBetaCredentials, SwosBetaProbeResponse
from app.services.swos_beta_service import swos_beta_service
router = APIRouter()
@router.post('/probe', response_model=SwosBetaProbeResponse)
def probe_swos(payload: SwosBetaCredentials, current_user: User = Depends(get_current_user)):
del current_user
try:
return swos_beta_service.probe(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post('/download')
def download_swos_backup(payload: SwosBetaCredentials, current_user: User = Depends(get_current_user)):
del current_user
try:
backup = swos_beta_service.download_backup(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return StreamingResponse(
iter([backup.content]),
media_type=backup.content_type,
headers={'Content-Disposition': f'attachment; filename="{backup.filename}"'},
)