Files
remnawave-minishop/backend/bot/app/web/webapp/devices.py
T

218 lines
8.2 KiB
Python

# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
async def devices_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
result = await webapp_cached_user_payload(
settings,
"devices",
user_id,
int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0),
lambda: _load_devices_payload(subscription_service, session, user_id),
)
if isinstance(result, dict) and result.get("ok") is True:
return web.json_response({"ok": True, **(result.get("payload") or {})})
if isinstance(result, dict) and not result.get("error"):
# Backward-compatible with payloads written by older versions under
# the same Redis cache key.
return web.json_response({"ok": True, **result})
if not isinstance(result, dict):
result = {}
if not result.get("ok"):
return _json_error(
int(result.get("status") or 500),
str(result.get("error") or "devices_load_failed"),
str(result.get("message") or "Failed to load devices"),
)
return web.json_response({"ok": True, **(result.get("payload") or {})})
async def _load_devices_payload(
subscription_service: SubscriptionService,
session: AsyncSession,
user_id: int,
) -> Dict[str, Any]:
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return {
"ok": False,
"status": 400,
"error": "subscription_not_active",
"message": "Subscription is not active",
}
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return {
"ok": False,
"status": 503,
"error": "panel_unavailable",
"message": "Panel service unavailable",
}
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices for user %s", user_id)
return {
"ok": False,
"status": 502,
"error": "devices_load_failed",
"message": "Failed to load devices",
}
devices = _normalize_devices_response(devices_response)
max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None
return {
"ok": True,
"payload": {
"enabled": True,
"current_devices": len(devices),
"max_devices": max_devices,
"max_devices_label": _format_devices_limit(max_devices),
"devices": [
_serialize_device(device, index)
for index, device in enumerate(devices, start=1)
],
},
}
async def disconnect_device_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="devices_disconnect",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
if not settings.MY_DEVICES_SECTION_ENABLED:
return _json_error(404, "devices_disabled", "Devices section is disabled")
payload = await _read_json(request)
disconnect_payload, validation_error = _validate_model_payload(
WebAppDeviceDisconnectPayload, payload
)
if validation_error:
return validation_error
token = str(disconnect_payload.token or "").strip()
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
active = await subscription_service.get_active_subscription_details(session, user_id)
panel_user_uuid = active.get("user_id") if active else None
if not panel_user_uuid:
return _json_error(400, "subscription_not_active", "Subscription is not active")
panel_service = getattr(subscription_service, "panel_service", None)
if not panel_service:
return _json_error(503, "panel_unavailable", "Panel service unavailable")
try:
devices_response = await panel_service.get_user_devices(panel_user_uuid)
except Exception:
logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id)
return _json_error(502, "devices_load_failed", "Failed to load devices")
target_hwid = None
for device in _normalize_devices_response(devices_response):
hwid = str(device.get("hwid") or "").strip()
if hwid and hmac.compare_digest(_device_hwid_token(hwid), token):
target_hwid = hwid
break
if not target_hwid:
return _json_error(404, "device_not_found", "Device not found")
success = await panel_service.disconnect_device(panel_user_uuid, target_hwid)
if not success:
return _json_error(502, "device_disconnect_failed", "Failed to disconnect device")
await cache_delete(settings, redis_key(settings, "cache", "webapp", "devices", user_id))
await session.commit()
return web.json_response({"ok": True})
def _device_hwid_token(hwid: str) -> str:
return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32]
def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str:
value = str(hwid or "").strip()
if len(value) <= max_length:
return value
return f"{value[:8]}...{value[-6:]}"
def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]:
if isinstance(devices_response, dict):
devices = devices_response.get("devices") or []
else:
devices = devices_response or []
if not isinstance(devices, list):
return []
return [device for device in devices if isinstance(device, dict)]
def _format_devices_limit(max_devices: Optional[int]) -> str:
if max_devices in (None, 0):
return "Unlimited"
return str(max_devices)
def _format_device_datetime(value: Any) -> str:
if not value:
return ""
text = str(value)
try:
normalized = datetime.fromisoformat(text.replace("Z", "+00:00"))
return normalized.strftime("%d.%m.%Y %H:%M")
except Exception:
return text
def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]:
hwid = str(device.get("hwid") or "").strip()
model = str(device.get("deviceModel") or "").strip()
platform = str(device.get("platform") or "").strip()
os_version = str(device.get("osVersion") or "").strip()
user_agent = str(device.get("userAgent") or "").strip()
display_name = model or platform or f"Device {index}"
platform_label = " ".join(part for part in (platform, os_version) if part).strip()
return {
"index": index,
"display_name": display_name,
"platform": platform,
"os_version": os_version,
"platform_label": platform_label,
"user_agent": user_agent,
"created_at": device.get("createdAt"),
"created_at_text": _format_device_datetime(device.get("createdAt")),
"hwid_short": _shorten_hwid_for_display(hwid),
"token": _device_hwid_token(hwid) if hwid else "",
"can_disconnect": bool(hwid),
}