first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 11:39:46 +02:00
commit 3da6c2832c
125 changed files with 30111 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.routes import auth, backups, dashboard, health, logs, routers, settings
api_router = APIRouter()
api_router.include_router(auth.router, prefix='/auth', tags=['auth'])
api_router.include_router(dashboard.router, prefix='/dashboard', tags=['dashboard'])
api_router.include_router(routers.router, prefix='/routers', tags=['routers'])
api_router.include_router(backups.router, prefix='/backups', tags=['backups'])
api_router.include_router(settings.router, prefix='/settings', tags=['settings'])
api_router.include_router(logs.router, prefix='/logs', tags=['logs'])
api_router.include_router(health.router, tags=['health'])

40
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,40 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError as exc:
raise credentials_exception from exc
user = db.query(User).filter(User.username == username).first()
if not user:
raise credentials_exception
return user

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,128 @@
from datetime import date
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),
created_on: date | 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,
created_on=created_on,
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,120 @@
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()
def serialize_router(router: Router, global_settings) -> RouterResponse:
has_router_key = bool((router.ssh_key or '').strip())
has_global_key = bool((global_settings.global_ssh_key or '').strip())
router_user = (router.ssh_user or '').strip() or None
router_password = (router.ssh_password or '').strip() or None
default_swos_user = (global_settings.default_switchos_username or '').strip() or None
default_swos_password = (global_settings.default_switchos_password or '').strip() or None
effective_username = router_user
uses_global_switchos_credentials = False
has_effective_password = bool(router_password)
if router.device_type == 'switchos':
effective_username = router_user or default_swos_user
uses_global_switchos_credentials = bool(
(not router_user and default_swos_user) or (not router_password and default_swos_password)
)
has_effective_password = bool(router_password or default_swos_password)
payload = RouterResponse.model_validate(router, from_attributes=True).model_dump()
payload['effective_username'] = effective_username
payload['uses_global_ssh_key'] = router.device_type == 'routeros' and has_global_key and not has_router_key
payload['has_effective_ssh_key'] = router.device_type == 'routeros' and (has_router_key or has_global_key)
payload['uses_global_switchos_credentials'] = uses_global_switchos_credentials
payload['has_effective_password'] = has_effective_password
payload['supports_export'] = router.device_type == 'routeros'
payload['supports_restore_upload'] = router.device_type == 'routeros'
return RouterResponse.model_validate(payload)
@router.get('', response_model=list[RouterResponse])
def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
global_settings = settings_service.get_or_create(db)
routers = db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all()
return [serialize_router(router, global_settings) for router in routers]
@router.post('', response_model=RouterResponse)
def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
router_data = payload.model_dump()
if router_data.get('device_type') == 'switchos' and router_data.get('ssh_user') is None:
router_data['ssh_user'] = ''
router = Router(**router_data, owner_id=current_user.id)
db.add(router)
db.commit()
db.refresh(router)
global_settings = settings_service.get_or_create(db)
return serialize_router(router, global_settings)
@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='Device not found')
global_settings = settings_service.get_or_create(db)
return serialize_router(router, global_settings)
@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='Device not found')
changes = payload.model_dump(exclude_unset=True)
target_device_type = changes.get('device_type', router.device_type)
if target_device_type == 'switchos':
changes['ssh_key'] = None
if 'port' not in changes:
changes['port'] = 80
if changes.get('ssh_user') is None:
changes['ssh_user'] = ''
elif target_device_type == 'routeros' and 'port' not in changes and router.device_type != 'routeros':
changes['port'] = 22
if not changes.get('ssh_user'):
changes['ssh_user'] = router.ssh_user or 'admin'
for key, value in changes.items():
setattr(router, key, value)
db.add(router)
db.commit()
db.refresh(router)
global_settings = settings_service.get_or_create(db)
return serialize_router(router, global_settings)
@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='Device 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': 'Device 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='Device not found')
global_settings = settings_service.get_or_create(db)
return router_service.test_connection(db, router, global_settings)

View File

@@ -0,0 +1,78 @@
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())
payload['has_default_switchos_credentials'] = bool(
(settings.default_switchos_username or '').strip() or (settings.default_switchos_password 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}"'},
)