562 lines
16 KiB
Python
562 lines
16 KiB
Python
"""Admin routes for managing VPN applications in app-config.json."""
|
|
|
|
import json
|
|
import logging
|
|
from typing import List, Optional, Dict, Any
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.models import User
|
|
from app.config import settings
|
|
from app.services.remnawave_service import RemnaWaveService
|
|
from app.services.system_settings_service import bot_configuration_service
|
|
|
|
from ..dependencies import get_cabinet_db, get_current_admin_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/admin/apps", tags=["Cabinet Admin Apps"])
|
|
|
|
|
|
# ============ Schemas ============
|
|
|
|
class LocalizedText(BaseModel):
|
|
"""Localized text for multiple languages."""
|
|
en: str = ""
|
|
ru: str = ""
|
|
zh: Optional[str] = ""
|
|
fa: Optional[str] = ""
|
|
|
|
|
|
class AppButton(BaseModel):
|
|
"""Button with link and localized text."""
|
|
buttonLink: str
|
|
buttonText: LocalizedText
|
|
|
|
|
|
class AppStep(BaseModel):
|
|
"""Step with description and optional buttons/title."""
|
|
description: LocalizedText
|
|
buttons: Optional[List[AppButton]] = None
|
|
title: Optional[LocalizedText] = None
|
|
|
|
|
|
class AppDefinition(BaseModel):
|
|
"""VPN application definition."""
|
|
id: str
|
|
name: str
|
|
isFeatured: bool = False
|
|
urlScheme: str
|
|
isNeedBase64Encoding: Optional[bool] = None
|
|
installationStep: AppStep
|
|
addSubscriptionStep: AppStep
|
|
connectAndUseStep: AppStep
|
|
additionalBeforeAddSubscriptionStep: Optional[AppStep] = None
|
|
additionalAfterAddSubscriptionStep: Optional[AppStep] = None
|
|
|
|
|
|
class PlatformApps(BaseModel):
|
|
"""Apps for a specific platform."""
|
|
platform: str
|
|
apps: List[AppDefinition]
|
|
|
|
|
|
class AppConfigBranding(BaseModel):
|
|
"""Branding configuration."""
|
|
name: str
|
|
logoUrl: str
|
|
supportUrl: str
|
|
|
|
|
|
class AppConfigConfig(BaseModel):
|
|
"""Top-level config section."""
|
|
additionalLocales: List[str]
|
|
branding: AppConfigBranding
|
|
|
|
|
|
class AppConfigResponse(BaseModel):
|
|
"""Full app config response."""
|
|
config: AppConfigConfig
|
|
platforms: Dict[str, List[AppDefinition]]
|
|
|
|
|
|
class CreateAppRequest(BaseModel):
|
|
"""Request to create a new app."""
|
|
platform: str
|
|
app: AppDefinition
|
|
|
|
|
|
class UpdateAppRequest(BaseModel):
|
|
"""Request to update an app."""
|
|
app: AppDefinition
|
|
|
|
|
|
class ReorderAppsRequest(BaseModel):
|
|
"""Request to reorder apps in a platform."""
|
|
app_ids: List[str]
|
|
|
|
|
|
class UpdateBrandingRequest(BaseModel):
|
|
"""Request to update branding."""
|
|
branding: AppConfigBranding
|
|
|
|
|
|
# ============ Helpers ============
|
|
|
|
def _get_config_path() -> Path:
|
|
"""Get path to app-config.json."""
|
|
return Path(settings.get_app_config_path())
|
|
|
|
|
|
def _load_config() -> dict:
|
|
"""Load app config from file."""
|
|
config_path = _get_config_path()
|
|
if not config_path.exists():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"App config file not found: {config_path}",
|
|
)
|
|
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to parse app config: {e}",
|
|
)
|
|
|
|
|
|
def _save_config(config: dict) -> None:
|
|
"""Save app config to file."""
|
|
config_path = _get_config_path()
|
|
|
|
try:
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to save app config: {e}",
|
|
)
|
|
|
|
|
|
VALID_PLATFORMS = ["ios", "android", "macos", "windows", "linux", "androidTV", "appleTV"]
|
|
|
|
|
|
# ============ Routes ============
|
|
|
|
@router.get("", response_model=AppConfigResponse)
|
|
async def get_app_config(
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Get full app configuration."""
|
|
config = _load_config()
|
|
return config
|
|
|
|
|
|
@router.get("/platforms", response_model=List[str])
|
|
async def get_platforms(
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Get list of available platforms."""
|
|
return VALID_PLATFORMS
|
|
|
|
|
|
@router.get("/platforms/{platform}", response_model=List[AppDefinition])
|
|
async def get_platform_apps(
|
|
platform: str,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Get apps for a specific platform."""
|
|
if platform not in VALID_PLATFORMS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}",
|
|
)
|
|
|
|
config = _load_config()
|
|
platforms = config.get("platforms", {})
|
|
return platforms.get(platform, [])
|
|
|
|
|
|
@router.post("/platforms/{platform}", response_model=AppDefinition)
|
|
async def create_app(
|
|
platform: str,
|
|
request: CreateAppRequest,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Create a new app for a platform."""
|
|
if platform not in VALID_PLATFORMS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid platform: {platform}",
|
|
)
|
|
|
|
config = _load_config()
|
|
platforms = config.get("platforms", {})
|
|
|
|
if platform not in platforms:
|
|
platforms[platform] = []
|
|
|
|
# Check if app with same ID already exists
|
|
existing_ids = [app.get("id") for app in platforms[platform]]
|
|
if request.app.id in existing_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"App with ID '{request.app.id}' already exists in {platform}",
|
|
)
|
|
|
|
# Add new app
|
|
app_dict = request.app.model_dump(exclude_none=True)
|
|
platforms[platform].append(app_dict)
|
|
config["platforms"] = platforms
|
|
|
|
_save_config(config)
|
|
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
|
|
|
|
return request.app
|
|
|
|
|
|
@router.put("/platforms/{platform}/{app_id}", response_model=AppDefinition)
|
|
async def update_app(
|
|
platform: str,
|
|
app_id: str,
|
|
request: UpdateAppRequest,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Update an existing app."""
|
|
if platform not in VALID_PLATFORMS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid platform: {platform}",
|
|
)
|
|
|
|
config = _load_config()
|
|
platforms = config.get("platforms", {})
|
|
apps = platforms.get(platform, [])
|
|
|
|
# Find and update app
|
|
app_index = None
|
|
for i, app in enumerate(apps):
|
|
if app.get("id") == app_id:
|
|
app_index = i
|
|
break
|
|
|
|
if app_index is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"App '{app_id}' not found in platform '{platform}'",
|
|
)
|
|
|
|
# Update app
|
|
app_dict = request.app.model_dump(exclude_none=True)
|
|
apps[app_index] = app_dict
|
|
platforms[platform] = apps
|
|
config["platforms"] = platforms
|
|
|
|
_save_config(config)
|
|
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
|
|
|
|
return request.app
|
|
|
|
|
|
@router.delete("/platforms/{platform}/{app_id}")
|
|
async def delete_app(
|
|
platform: str,
|
|
app_id: str,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Delete an app from a platform."""
|
|
if platform not in VALID_PLATFORMS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid platform: {platform}",
|
|
)
|
|
|
|
config = _load_config()
|
|
platforms = config.get("platforms", {})
|
|
apps = platforms.get(platform, [])
|
|
|
|
# Find and remove app
|
|
original_length = len(apps)
|
|
apps = [app for app in apps if app.get("id") != app_id]
|
|
|
|
if len(apps) == original_length:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"App '{app_id}' not found in platform '{platform}'",
|
|
)
|
|
|
|
platforms[platform] = apps
|
|
config["platforms"] = platforms
|
|
|
|
_save_config(config)
|
|
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
|
|
|
|
return {"status": "deleted", "app_id": app_id}
|
|
|
|
|
|
@router.post("/platforms/{platform}/reorder")
|
|
async def reorder_apps(
|
|
platform: str,
|
|
request: ReorderAppsRequest,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Reorder apps in a platform."""
|
|
if platform not in VALID_PLATFORMS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid platform: {platform}",
|
|
)
|
|
|
|
config = _load_config()
|
|
platforms = config.get("platforms", {})
|
|
apps = platforms.get(platform, [])
|
|
|
|
# Create a map of apps by ID
|
|
apps_map = {app.get("id"): app for app in apps}
|
|
|
|
# Verify all IDs exist
|
|
for app_id in request.app_ids:
|
|
if app_id not in apps_map:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"App '{app_id}' not found in platform '{platform}'",
|
|
)
|
|
|
|
# Reorder apps
|
|
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
|
|
|
|
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
|
|
for app in apps:
|
|
if app.get("id") not in request.app_ids:
|
|
reordered_apps.append(app)
|
|
|
|
platforms[platform] = reordered_apps
|
|
config["platforms"] = platforms
|
|
|
|
_save_config(config)
|
|
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
|
|
|
|
return {"status": "reordered", "order": request.app_ids}
|
|
|
|
|
|
@router.put("/branding", response_model=AppConfigBranding)
|
|
async def update_branding(
|
|
request: UpdateBrandingRequest,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Update branding configuration."""
|
|
config = _load_config()
|
|
|
|
if "config" not in config:
|
|
config["config"] = {}
|
|
|
|
config["config"]["branding"] = request.branding.model_dump()
|
|
|
|
_save_config(config)
|
|
logger.info(f"Admin {admin.id} updated branding")
|
|
|
|
return request.branding
|
|
|
|
|
|
@router.get("/branding", response_model=AppConfigBranding)
|
|
async def get_branding(
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Get branding configuration."""
|
|
config = _load_config()
|
|
branding = config.get("config", {}).get("branding", {})
|
|
return branding
|
|
|
|
|
|
@router.post("/platforms/{platform}/copy/{app_id}")
|
|
async def copy_app_to_platform(
|
|
platform: str,
|
|
app_id: str,
|
|
target_platform: str,
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Copy an app from one platform to another."""
|
|
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid platform(s)",
|
|
)
|
|
|
|
config = _load_config()
|
|
platforms = config.get("platforms", {})
|
|
source_apps = platforms.get(platform, [])
|
|
|
|
# Find source app
|
|
source_app = None
|
|
for app in source_apps:
|
|
if app.get("id") == app_id:
|
|
source_app = app.copy()
|
|
break
|
|
|
|
if not source_app:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"App '{app_id}' not found in platform '{platform}'",
|
|
)
|
|
|
|
# Generate new ID for copied app
|
|
import time
|
|
new_id = f"{app_id}-copy-{int(time.time())}"
|
|
source_app["id"] = new_id
|
|
|
|
# Add to target platform
|
|
if target_platform not in platforms:
|
|
platforms[target_platform] = []
|
|
|
|
platforms[target_platform].append(source_app)
|
|
config["platforms"] = platforms
|
|
|
|
_save_config(config)
|
|
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
|
|
|
return {"status": "copied", "new_id": new_id, "target_platform": target_platform}
|
|
|
|
|
|
# ============ RemnaWave Config Routes ============
|
|
|
|
class RemnaWaveConfigStatus(BaseModel):
|
|
"""Status of RemnaWave config integration."""
|
|
enabled: bool
|
|
config_uuid: Optional[str] = None
|
|
|
|
|
|
class UpdateRemnaWaveUuidRequest(BaseModel):
|
|
"""Request to update RemnaWave config UUID."""
|
|
uuid: Optional[str] = None
|
|
|
|
|
|
def _get_remnawave_config_uuid() -> Optional[str]:
|
|
"""Get RemnaWave config UUID from system settings or env."""
|
|
try:
|
|
return bot_configuration_service.get_current_value("CABINET_REMNA_SUB_CONFIG")
|
|
except Exception:
|
|
return settings.CABINET_REMNA_SUB_CONFIG
|
|
|
|
|
|
@router.get("/remnawave/status", response_model=RemnaWaveConfigStatus)
|
|
async def get_remnawave_config_status(
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Get RemnaWave config integration status."""
|
|
config_uuid = _get_remnawave_config_uuid()
|
|
return RemnaWaveConfigStatus(
|
|
enabled=bool(config_uuid),
|
|
config_uuid=config_uuid,
|
|
)
|
|
|
|
|
|
@router.put("/remnawave/uuid", response_model=RemnaWaveConfigStatus)
|
|
async def set_remnawave_config_uuid(
|
|
request: UpdateRemnaWaveUuidRequest,
|
|
admin: User = Depends(get_current_admin_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Set RemnaWave subscription config UUID."""
|
|
uuid_value = request.uuid.strip() if request.uuid else None
|
|
|
|
# Validate UUID format if provided
|
|
if uuid_value:
|
|
import re
|
|
uuid_pattern = re.compile(
|
|
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
|
)
|
|
if not uuid_pattern.match(uuid_value):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid UUID format",
|
|
)
|
|
|
|
try:
|
|
await bot_configuration_service.set_value(db, "CABINET_REMNA_SUB_CONFIG", uuid_value)
|
|
await db.commit()
|
|
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
|
|
except Exception as e:
|
|
logger.error(f"Error saving RemnaWave config UUID: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to save configuration",
|
|
)
|
|
|
|
return RemnaWaveConfigStatus(
|
|
enabled=bool(uuid_value),
|
|
config_uuid=uuid_value,
|
|
)
|
|
|
|
|
|
@router.get("/remnawave/config")
|
|
async def get_remnawave_subscription_config(
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""
|
|
Fetch subscription page config from RemnaWave panel.
|
|
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
|
|
"""
|
|
config_uuid = _get_remnawave_config_uuid()
|
|
if not config_uuid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="CABINET_REMNA_SUB_CONFIG is not configured",
|
|
)
|
|
|
|
try:
|
|
service = RemnaWaveService()
|
|
async with service.get_api_client() as api:
|
|
config = await api.get_subscription_page_config(config_uuid)
|
|
if not config:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
|
|
)
|
|
|
|
# Return the raw config data from RemnaWave
|
|
return {
|
|
"uuid": config.uuid,
|
|
"name": config.name,
|
|
"view_position": config.view_position,
|
|
"config": config.config,
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching RemnaWave config: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to fetch config from RemnaWave: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/remnawave/configs")
|
|
async def list_remnawave_subscription_configs(
|
|
admin: User = Depends(get_current_admin_user),
|
|
):
|
|
"""List available subscription page configs from RemnaWave panel."""
|
|
try:
|
|
service = RemnaWaveService()
|
|
async with service.get_api_client() as api:
|
|
configs = await api.get_subscription_page_configs()
|
|
return [
|
|
{
|
|
"uuid": c.uuid,
|
|
"name": c.name,
|
|
"view_position": c.view_position,
|
|
}
|
|
for c in configs
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"Error listing RemnaWave configs: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to fetch configs from RemnaWave: {str(e)}",
|
|
)
|