Files
yakpanel-core/YakPanel-server/backend/app/api/database.py
2026-04-07 02:04:22 +05:30

275 lines
10 KiB
Python

"""YakPanel - Database API"""
import os
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from app.core.database import get_db
from app.core.config import get_runtime_config
from app.core.notification import send_email
from app.api.auth import get_current_user
from app.models.user import User
from app.models.database import Database
from app.services.database_service import (
create_mysql_database,
drop_mysql_database,
backup_mysql_database,
restore_mysql_database,
change_mysql_password,
create_postgresql_database,
drop_postgresql_database,
backup_postgresql_database,
restore_postgresql_database,
change_postgresql_password,
create_mongodb_database,
drop_mongodb_database,
backup_mongodb_database,
restore_mongodb_database,
change_mongodb_password,
)
router = APIRouter(prefix="/database", tags=["database"])
class CreateDatabaseRequest(BaseModel):
name: str
username: str
password: str
db_type: str = "MySQL"
ps: str = ""
class UpdateDbPasswordRequest(BaseModel):
password: str
@router.get("/list")
async def database_list(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List databases"""
result = await db.execute(select(Database).order_by(Database.id))
rows = result.scalars().all()
return [{"id": r.id, "name": r.name, "username": r.username, "db_type": r.db_type, "ps": r.ps} for r in rows]
@router.post("/create")
async def database_create(
body: CreateDatabaseRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create database (panel DB + actual MySQL/PostgreSQL when supported)"""
result = await db.execute(select(Database).where(Database.name == body.name))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Database already exists")
if body.db_type == "MySQL":
ok, msg = create_mysql_database(body.name, body.username, body.password)
if not ok:
raise HTTPException(status_code=400, detail=msg)
elif body.db_type == "PostgreSQL":
ok, msg = create_postgresql_database(body.name, body.username, body.password)
if not ok:
raise HTTPException(status_code=400, detail=msg)
elif body.db_type == "MongoDB":
ok, msg = create_mongodb_database(body.name, body.username, body.password)
if not ok:
raise HTTPException(status_code=400, detail=msg)
dbo = Database(
name=body.name,
username=body.username,
password=body.password,
db_type=body.db_type,
ps=body.ps,
)
db.add(dbo)
await db.commit()
return {"status": True, "msg": "Database created", "id": dbo.id}
@router.put("/{db_id}/password")
async def database_update_password(
db_id: int,
body: UpdateDbPasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Change database user password (MySQL, PostgreSQL, or MongoDB)"""
result = await db.execute(select(Database).where(Database.id == db_id))
dbo = result.scalar_one_or_none()
if not dbo:
raise HTTPException(status_code=404, detail="Database not found")
if dbo.db_type not in ("MySQL", "PostgreSQL", "MongoDB"):
raise HTTPException(status_code=400, detail="Only MySQL, PostgreSQL, and MongoDB supported")
if not body.password or len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
if dbo.db_type == "MySQL":
ok, msg = change_mysql_password(dbo.username, body.password)
elif dbo.db_type == "PostgreSQL":
ok, msg = change_postgresql_password(dbo.username, body.password)
else:
ok, msg = change_mongodb_password(dbo.username, dbo.name, body.password)
if not ok:
raise HTTPException(status_code=400, detail=msg)
dbo.password = body.password
await db.commit()
return {"status": True, "msg": "Password updated"}
@router.delete("/{db_id}")
async def database_delete(
db_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete database (panel record + actual MySQL/PostgreSQL when supported)"""
result = await db.execute(select(Database).where(Database.id == db_id))
dbo = result.scalar_one_or_none()
if not dbo:
raise HTTPException(status_code=404, detail="Database not found")
if dbo.db_type == "MySQL":
ok, msg = drop_mysql_database(dbo.name, dbo.username)
if not ok:
raise HTTPException(status_code=400, detail=msg)
elif dbo.db_type == "PostgreSQL":
ok, msg = drop_postgresql_database(dbo.name, dbo.username)
if not ok:
raise HTTPException(status_code=400, detail=msg)
elif dbo.db_type == "MongoDB":
ok, msg = drop_mongodb_database(dbo.name, dbo.username)
if not ok:
raise HTTPException(status_code=400, detail=msg)
await db.delete(dbo)
await db.commit()
return {"status": True, "msg": "Database deleted"}
@router.get("/count")
async def database_count(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get database count"""
result = await db.execute(select(func.count()).select_from(Database))
return {"count": result.scalar() or 0}
@router.post("/{db_id}/backup")
async def database_backup(
db_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create database backup (MySQL: mysqldump, PostgreSQL: pg_dump, MongoDB: mongodump)"""
result = await db.execute(select(Database).where(Database.id == db_id))
dbo = result.scalar_one_or_none()
if not dbo:
raise HTTPException(status_code=404, detail="Database not found")
if dbo.db_type not in ("MySQL", "PostgreSQL", "MongoDB"):
raise HTTPException(status_code=400, detail="Backup not supported for this database type")
cfg = get_runtime_config()
backup_dir = os.path.join(cfg["backup_path"], "database")
if dbo.db_type == "MySQL":
ok, msg, filename = backup_mysql_database(dbo.name, backup_dir)
elif dbo.db_type == "PostgreSQL":
ok, msg, filename = backup_postgresql_database(dbo.name, backup_dir)
else:
ok, msg, filename = backup_mongodb_database(dbo.name, backup_dir)
if not ok:
raise HTTPException(status_code=500, detail=msg)
send_email(
subject=f"YakPanel - Database backup: {dbo.name}",
body=f"Backup completed: {filename}\nDatabase: {dbo.name}",
)
return {"status": True, "msg": "Backup created", "filename": filename}
@router.get("/{db_id}/backups")
async def database_backups_list(
db_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List backups for a database"""
result = await db.execute(select(Database).where(Database.id == db_id))
dbo = result.scalar_one_or_none()
if not dbo:
raise HTTPException(status_code=404, detail="Database not found")
cfg = get_runtime_config()
backup_dir = os.path.join(cfg["backup_path"], "database")
if not os.path.isdir(backup_dir):
return {"backups": []}
prefix = f"{dbo.name}_"
backups = []
for f in os.listdir(backup_dir):
if f.startswith(prefix) and f.endswith(".sql.gz"):
p = os.path.join(backup_dir, f)
backups.append({"filename": f, "size": os.path.getsize(p) if os.path.isfile(p) else 0})
backups.sort(key=lambda x: x["filename"], reverse=True)
return {"backups": backups}
@router.get("/{db_id}/backups/download")
async def database_backup_download(
db_id: int,
file: str = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Download database backup"""
result = await db.execute(select(Database).where(Database.id == db_id))
dbo = result.scalar_one_or_none()
if not dbo:
raise HTTPException(status_code=404, detail="Database not found")
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{dbo.name}_"):
raise HTTPException(status_code=400, detail="Invalid filename")
if not (file.endswith(".sql.gz") or file.endswith(".tar.gz")):
raise HTTPException(status_code=400, detail="Invalid filename")
cfg = get_runtime_config()
path = os.path.join(cfg["backup_path"], "database", file)
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Backup not found")
return FileResponse(path, filename=file)
class RestoreRequest(BaseModel):
filename: str
@router.post("/{db_id}/restore")
async def database_restore(
db_id: int,
body: RestoreRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Restore database from backup"""
result = await db.execute(select(Database).where(Database.id == db_id))
dbo = result.scalar_one_or_none()
if not dbo:
raise HTTPException(status_code=404, detail="Database not found")
file = body.filename
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{dbo.name}_"):
raise HTTPException(status_code=400, detail="Invalid filename")
valid_ext = file.endswith(".sql.gz") or file.endswith(".tar.gz")
if not valid_ext:
raise HTTPException(status_code=400, detail="Invalid filename")
if dbo.db_type in ("MySQL", "PostgreSQL") and not file.endswith(".sql.gz"):
raise HTTPException(status_code=400, detail="Wrong backup format for this database type")
if dbo.db_type == "MongoDB" and not file.endswith(".tar.gz"):
raise HTTPException(status_code=400, detail="Wrong backup format for MongoDB")
cfg = get_runtime_config()
backup_path = os.path.join(cfg["backup_path"], "database", file)
if dbo.db_type == "MySQL":
ok, msg = restore_mysql_database(dbo.name, backup_path)
elif dbo.db_type == "PostgreSQL":
ok, msg = restore_postgresql_database(dbo.name, backup_path)
else:
ok, msg = restore_mongodb_database(dbo.name, backup_path)
if not ok:
raise HTTPException(status_code=500, detail=msg)
return {"status": True, "msg": "Restored"}