diff --git a/.dockerignore b/.dockerignore index 434ec09..0af9869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,9 @@ frontend/node_modules/ docs-site/node_modules/ docs-site/.astro/ docs-site/dist/ +docs-site/public/demo/runtime/ docs-site/src/content/docs/ +frontend-nginx-dist/ deploy/compose/docker-compose-dev.yml data/* !data/tariffs.example.json @@ -51,6 +53,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br bot/app/web/templates/subscription_webapp_admin.min.*.js.gz bot/app/web/templates/subscription_webapp_admin.*.css.br bot/app/web/templates/subscription_webapp_admin.*.css.gz +bot/app/web/templates/subscription_webapp_docs_demo.css +bot/app/web/templates/subscription_webapp_docs_demo.js +bot/app/web/templates/subscription_webapp_docs_demo.*.css +bot/app/web/templates/subscription_webapp_docs_demo.*.css.br +bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz backend/bot/app/web/templates/subscription_webapp.css backend/bot/app/web/templates/subscription_webapp.js backend/bot/app/web/templates/subscription_webapp.min.*.js @@ -67,6 +74,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz backend/bot/app/web/templates/subscription_webapp_admin.*.css.br backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz +backend/bot/app/web/templates/subscription_webapp_docs_demo.css +backend/bot/app/web/templates/subscription_webapp_docs_demo.js +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br +backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/.env.example b/.env.example index 63920b6..0c00ca2 100644 --- a/.env.example +++ b/.env.example @@ -71,16 +71,18 @@ WEB_SERVER_PORT=8080 FRONTEND_PORT=8082 # Reverse proxy IPs/CIDRs trusted for X-Forwarded-For. -# Keep loopback for local proxy; add your proxy network if needed. -TRUSTED_PROXIES=127.0.0.1,::1 +# Keep loopback and private network ranges so payment provider IP allowlists +# see the original webhook sender behind Docker/LAN/Kubernetes proxies. +TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 # ─── Anonymous install telemetry (opt-out) ────────────────────────────── # Once a day the worker sends a single anonymous "heartbeat" so the project # maintainer can see how many installs are active and which versions/OSes are # used. It contains an opaque random install id and coarse facts only: -# version, OS/arch, Python version, language, enabled payment providers and a -# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any -# personal information is ever sent. Full details: docs/configuration/telemetry.md +# version, official/custom image provenance, OS/arch, Python version, language, +# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot +# token, domain, user data or any personal information is ever sent. +# Full details: docs/configuration/telemetry.md # # Set to False to disable, or toggle it any time in Admin -> System -> # "Anonymous install analytics" (applies without a restart). diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml index e428d86..d9137a8 100644 --- a/.github/workflows/_docker-build-push.yml +++ b/.github/workflows/_docker-build-push.yml @@ -2,11 +2,11 @@ name: Docker build & push (reusable) # Reusable workflow that builds the three image targets defined in # deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes -# them to both ghcr.io and Docker Hub under the 3252a8/ namespace. +# them to the selected registries under the repository owner's namespace. # # Called by: # - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev -# - docker-release.yml (tag_mode: release, push: true) on pushes to main +# - docker-release.yml (tag_mode: release, push: true) on release tags # - ci.yml (tag_mode: dev, push: false) on pull requests on: @@ -20,6 +20,10 @@ on: description: "Tagging strategy: 'dev' or 'release'" type: string required: true + publish_dockerhub: + description: "Include Docker Hub tags and login when pushing" + type: boolean + default: true # No permissions block here on purpose: a reusable workflow cannot request more # than its caller grants, so the token scope is set by each caller @@ -78,19 +82,36 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub - if: inputs.push + if: inputs.push && inputs.publish_dockerhub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Resolve image namespaces + id: image_namespaces + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + github_owner="${{ github.repository_owner }}" + echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT" + dockerhub_owner="${github_owner,,}" + if [ "${{ inputs.publish_dockerhub }}" = "true" ]; then + if [ -z "$DOCKERHUB_USERNAME" ]; then + echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing" + exit 1 + fi + dockerhub_owner="${DOCKERHUB_USERNAME,,}" + fi + echo "dockerhub_owner=$dockerhub_owner" >> "$GITHUB_OUTPUT" + - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: | - 3252a8/${{ matrix.image }} - ghcr.io/3252a8/${{ matrix.image }} + name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.publish_dockerhub }} + name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true tags: | type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }} @@ -111,6 +132,7 @@ jobs: # (the ref is the tag, not a branch) so release images stay un-suffixed. build-args: | GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }} + REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }} cache-from: type=gha,scope=${{ matrix.target }} cache-to: type=gha,mode=max,scope=${{ matrix.target }} provenance: false diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 93d3f88..9c3e3e9 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -1,7 +1,7 @@ name: Dev images # On every push to the dev branch, build all three images and push them to -# ghcr.io and Docker Hub tagged `dev` and `dev-`. +# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI. on: push: @@ -22,4 +22,5 @@ jobs: with: push: true tag_mode: dev + publish_dockerhub: false secrets: inherit diff --git a/.gitignore b/.gitignore index ade87d4..8ff0f22 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ docs-site/.astro/ docs-site/dist/ docs-site/public/demo/runtime/ docs-site/src/content/docs/ +frontend-nginx-dist/ # WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build) bot/app/web/templates/subscription_webapp.css diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..2285851 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,72 @@ +stages: + - docker + +workflow: + rules: + - if: '$CI_COMMIT_BRANCH == "dev"' + - when: never + +variables: + DOCKER_BUILDKIT: "1" + DOCKER_DRIVER: overlay2 + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + GIT_DEPTH: "0" + +docker-dev: + stage: docker + image: docker:27.5.1 + services: + - name: docker:27.5.1-dind + alias: docker + interruptible: true + parallel: + matrix: + - TARGET: backend + IMAGE: remnawave-minishop-backend + - TARGET: worker + IMAGE: remnawave-minishop-worker + - TARGET: frontend + IMAGE: remnawave-minishop-frontend + before_script: + - apk add --no-cache git + - test -n "$DOCKERHUB_USERNAME" + - test -n "$DOCKERHUB_TOKEN" + - echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin + - docker buildx create --name gitlab-builder --driver docker-container --use + - docker buildx inspect --bootstrap + script: + - git fetch origin dev --tags + - | + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then + echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE." + exit 0 + fi + - | + provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}" + if [ -z "$provenance" ]; then + dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')" + if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then + provenance="official" + else + provenance="custom" + fi + fi + docker buildx build \ + --load \ + --platform linux/amd64 \ + --file deploy/docker/Dockerfile \ + --target "$TARGET" \ + --build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \ + --build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \ + --build-arg "BUILDKIT_INLINE_CACHE=1" \ + --cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \ + --tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \ + . + - git fetch origin dev --tags + - | + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then + echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE." + exit 0 + fi + - docker push "$DOCKERHUB_USERNAME/$IMAGE:dev" diff --git a/README.md b/README.md index 374e55a..63281ad 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ docker compose logs -f backend worker frontend - `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты; - `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`; - `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave; +- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера; - остальные настройки удобнее задать в Web App админке. В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке. @@ -98,10 +99,12 @@ docker compose logs -f backend worker frontend Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md). +В Docker этот файл должен быть доступен не только `backend` и `worker`, но и одноразовому сервису `migrate`: мигратор читает каталог тарифов при привязке существующих подписок к тарифу по умолчанию. В текущих compose-файлах весь `/app/data` уже смонтирован в `migrate`, `backend` и `worker`; если переносите compose вручную, сохраните одинаковый mount для всех трех сервисов. + В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App: ```bash -mkdir -p data/themes data/webapp-logo +mkdir -p data/themes data/webapp-logo data/tariffs touch data/locales-overrides.json chown -R 10001:10001 data chmod -R u+rwX data diff --git a/backend/bot/app/web/admin_api.py b/backend/bot/app/web/admin_api.py index 29e0f0a..5a2d273 100644 --- a/backend/bot/app/web/admin_api.py +++ b/backend/bot/app/web/admin_api.py @@ -9,6 +9,7 @@ from bot.app.web.admin_api_impl import ( backups as _backups, broadcast as _broadcast, common as _common, + health as _health, logs as _logs, panel as _panel, payments as _payments, @@ -28,6 +29,7 @@ _MODULES = ( _runtime, _auth, _common, + _health, _stats, _users, _payments, diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index 7daae95..986988a 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -1,5 +1,165 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +from .common import _panel_user_connection_activity + +import asyncio +from collections import defaultdict + +from bot.utils.ttl_cache import AsyncTTLCache + + +BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected" +BROADCAST_TARGETS = { + "all", + "active", + "inactive", + "expired", + "never", + BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED, +} +PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10 +_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {} + + +def _resolve_panel_service(request: web.Request) -> Any: + subscription_service = request.app.get("subscription_service") + return getattr(subscription_service, "panel_service", None) + + +async def _active_subscription_panel_uuids_by_user( + session: AsyncSession, +) -> Dict[int, List[str]]: + now = datetime.now(timezone.utc) + stmt = ( + select(Subscription.user_id, Subscription.panel_user_uuid) + .join(User, Subscription.user_id == User.user_id) + .where( + User.is_banned == False, + Subscription.is_active == True, + Subscription.end_date > now, + Subscription.panel_user_uuid.is_not(None), + Subscription.panel_user_uuid != "", + ) + .order_by(Subscription.user_id.asc(), Subscription.end_date.desc()) + ) + result = await session.execute(stmt) + + grouped: Dict[int, List[str]] = defaultdict(list) + seen: Dict[int, set[str]] = defaultdict(set) + for user_id, panel_uuid in result.all(): + user_id_int = int(user_id) + panel_uuid_str = str(panel_uuid or "").strip() + if panel_uuid_str and panel_uuid_str not in seen[user_id_int]: + grouped[user_id_int].append(panel_uuid_str) + seen[user_id_int].add(panel_uuid_str) + return dict(grouped) + + +async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str: + try: + panel_user = await panel_service.get_user_by_uuid(panel_uuid) + except Exception as exc: + logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc) + return "unknown" + activity = _panel_user_connection_activity(panel_user) + return str(activity.get("status") or "unknown") + + +async def _user_ids_with_active_subscription_never_connected( + session: AsyncSession, + panel_service: Any, +) -> List[int]: + panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session) + semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY) + + async def lookup(panel_uuid: str) -> str: + async with semaphore: + return await _panel_connection_status(panel_service, panel_uuid) + + panel_uuids = list( + dict.fromkeys( + panel_uuid + for user_panel_uuids in panel_uuids_by_user.values() + for panel_uuid in user_panel_uuids + ) + ) + statuses_by_uuid = dict( + zip( + panel_uuids, + await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)), + ) + ) + + user_ids: List[int] = [] + for user_id, panel_uuids in panel_uuids_by_user.items(): + statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids] + if statuses and all(status == "never" for status in statuses): + user_ids.append(user_id) + return user_ids + + +def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]: + ttl_seconds = int( + getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0 + ) + if ttl_seconds <= 0: + return None + cache_key = (id(settings), ttl_seconds) + cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key) + if cache is None: + cache = AsyncTTLCache( + ttl_seconds=ttl_seconds, + settings=settings, + namespace="admin:broadcast_audience_counts", + ) + _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache + return cache + + +async def _load_broadcast_audience_counts( + settings: Settings, + async_session_factory: sessionmaker, + panel_service: Any, +) -> Dict[str, Optional[int]]: + cache = _admin_broadcast_audience_counts_cache(settings) + if cache is None: + return await _load_broadcast_audience_counts_uncached( + async_session_factory, + panel_service, + ) + cache_key = "with-panel" if panel_service is not None else "without-panel" + return await cache.get_or_load( + cache_key, + lambda: _load_broadcast_audience_counts_uncached( + async_session_factory, + panel_service, + ), + ) + + +async def _load_broadcast_audience_counts_uncached( + async_session_factory: sessionmaker, + panel_service: Any, +) -> Dict[str, Optional[int]]: + async with async_session_factory() as session: + counts: Dict[str, Optional[int]] = { + "all": await user_dal.count_all_active_users_for_broadcast(session), + "active": await user_dal.count_users_with_active_subscription_for_broadcast(session), + "inactive": await user_dal.count_users_without_active_subscription_for_broadcast( + session + ), + "expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session), + "never": await user_dal.count_users_without_any_subscription_for_broadcast(session), + BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None, + } + if panel_service is not None: + counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len( + await _user_ids_with_active_subscription_never_connected( + session, + panel_service, + ) + ) + return counts async def admin_broadcast_route(request: web.Request) -> web.Response: @@ -9,7 +169,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive", "expired", "never"}: + if target not in BROADCAST_TARGETS: target = "all" queue_manager = get_queue_manager() @@ -18,7 +178,15 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: - if target == "active": + if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: + panel_service = _resolve_panel_service(request) + if panel_service is None: + return _error(503, "panel_service_unavailable") + user_ids = await _user_ids_with_active_subscription_never_connected( + session, + panel_service, + ) + elif target == "active": user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) @@ -62,14 +230,13 @@ async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Res """Return how many users each broadcast audience currently resolves to.""" _require_admin_user_id(request) + settings: Settings = request.app["settings"] async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - counts = { - "all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)), - "active": len(await user_dal.get_user_ids_with_active_subscription(session)), - "inactive": len(await user_dal.get_user_ids_without_active_subscription(session)), - "expired": len(await user_dal.get_user_ids_with_expired_subscription(session)), - "never": len(await user_dal.get_user_ids_without_any_subscription(session)), - } + panel_service = _resolve_panel_service(request) + counts = await _load_broadcast_audience_counts( + settings, + async_session_factory, + panel_service, + ) return _ok({"counts": counts}) diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index 907914d..7d1c36b 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]: return {} +_PANEL_LAST_CONNECTED_KEYS = ( + "onlineAt", + "online_at", + "lastSeenAt", + "last_seen_at", + "lastConnectedAt", + "last_connected_at", + "lastConnectionAt", + "last_connection_at", +) +_PANEL_CONNECTION_MARKER_KEYS = ( + *_PANEL_LAST_CONNECTED_KEYS, + "firstConnectedAt", + "first_connected_at", + "lastConnectedNodeUuid", + "last_connected_node_uuid", +) +_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node") +_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats") +_PANEL_TRAFFIC_USED_KEYS = ( + "lifetimeUsedTrafficBytes", + "lifetime_used_traffic_bytes", + "usedTrafficBytes", + "used_traffic_bytes", + "trafficUsedBytes", + "traffic_used_bytes", + "downloadBytes", + "download_bytes", + "uploadBytes", + "upload_bytes", +) + + +def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]: + if not isinstance(panel_user_data, dict): + return {} + response = panel_user_data.get("response") + if isinstance(response, dict) and not any( + key in panel_user_data + for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status") + ): + return response + return panel_user_data + + +def _coerce_panel_datetime(value: Any) -> Optional[str]: + if value is None or value is False: + return None + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (int, float)): + if value <= 0: + return None + seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value) + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat() + except (OSError, OverflowError, ValueError): + return None + text = str(value).strip() + if not text or text.lower() in {"0", "null", "none", "never"}: + return None + if text.isdigit(): + return _coerce_panel_datetime(int(text)) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + return parsed.isoformat() + + +def _coerce_panel_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(float(value)) + except (TypeError, ValueError): + return None + + +def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for key in keys: + value = panel_user.get(key) + if isinstance(value, dict): + out.append(value) + return out + + +def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]: + traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS) + marker_containers = _panel_nested_dicts( + panel_user, + _PANEL_CONNECTION_MARKER_OBJECT_KEYS, + ) + for traffic_container in traffic_containers: + marker_containers.extend( + _panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS) + ) + return [panel_user, *traffic_containers, *marker_containers] + + +def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]: + panel_user = _panel_user_payload(panel_user_data) + if not panel_user: + return None + for container in _panel_user_connection_containers(panel_user): + for key in _PANEL_LAST_CONNECTED_KEYS: + connected_at = _coerce_panel_datetime(container.get(key)) + if connected_at: + return connected_at + return None + + +def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool: + containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)] + for container in containers: + for key in _PANEL_TRAFFIC_USED_KEYS: + value = _coerce_panel_int(container.get(key)) + if value is not None and value > 0: + return True + return False + + +def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool: + for container in _panel_user_connection_containers(panel_user): + for key in _PANEL_CONNECTION_MARKER_KEYS: + if key in container: + return True + for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]: + for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS: + if key in container: + return True + return False + + +def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool: + for container in _panel_user_connection_containers(panel_user): + for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"): + if _coerce_panel_datetime(container.get(key)): + return True + for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"): + if str(container.get(key) or "").strip(): + return True + for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]: + for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS: + marker = container.get(key) + if isinstance(marker, dict) and any( + str(value or "").strip() for value in marker.values() + ): + return True + if marker and not isinstance(marker, dict): + return True + return False + + +def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]: + panel_user = _panel_user_payload(panel_user_data) + last_connected_at = _panel_user_last_connected_at(panel_user) + if not panel_user: + return {"status": "unknown", "last_connected_at": None} + if last_connected_at or _panel_user_positive_traffic_bytes(panel_user): + return {"status": "connected", "last_connected_at": last_connected_at} + if _panel_user_has_connected_marker_value(panel_user): + return {"status": "connected", "last_connected_at": last_connected_at} + if _panel_user_has_connection_marker(panel_user): + return {"status": "never", "last_connected_at": None} + return {"status": "unknown", "last_connected_at": None} + + def _serialize_user(user: User) -> Dict[str, Any]: return { "user_id": int(user.user_id), diff --git a/backend/bot/app/web/admin_api_impl/health.py b/backend/bot/app/web/admin_api_impl/health.py new file mode 100644 index 0000000..3bc2f98 --- /dev/null +++ b/backend/bot/app/web/admin_api_impl/health.py @@ -0,0 +1,19 @@ +# ruff: noqa: F401,F403,F405,I001 +from datetime import datetime, timezone + +from ._runtime import * # noqa: F403,F405 +from .auth import _require_admin_user_id +from .common import _ok +from bot.services.config_health_service import collect_config_alerts + + +async def admin_health_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"} + alerts = await collect_config_alerts(request, refresh=refresh) + return _ok( + { + "alerts": alerts, + "checked_at": datetime.now(timezone.utc).isoformat(), + } + ) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index d9dd43a..146a011 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -6,6 +6,7 @@ def setup_admin_routes(app: web.Application) -> None: router = app.router router.add_get("/api/admin/me", admin_me_route) router.add_get("/api/admin/stats", admin_stats_route) + router.add_get("/api/admin/health", admin_health_route) router.add_get("/api/admin/users", admin_users_list_route) router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route) diff --git a/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py index b68ed08..7fe6be2 100644 --- a/backend/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -25,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons" WEBAPP_FAVICON_PATH = "/webapp-favicon" -WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji" WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512) WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { ".gif": "image/gif", @@ -171,14 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None: except OSError: logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True) - # Emoji logos were removed; purge any leftover animated-emoji cache files. - for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"): - if path.is_file(): - try: - path.unlink() - except OSError: - logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True) - async def _persist_appearance_upload( request: web.Request, diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index c6a061d..8c35593 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -5,6 +5,7 @@ from .common import ( _build_admin_webapp_referral_link, _error, _ok, + _panel_user_connection_activity, _premium_traffic_list_payload, _read_json, _serialize_payment, @@ -836,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: # imports into their VPN client. May be missing if the user has never # been provisioned on the panel. subscription_url: Optional[str] = None - panel_uuid = getattr(user, "panel_user_uuid", None) + last_vpn_connected_at: Optional[str] = None + vpn_connection_status = "unknown" + panel_uuid = getattr(user, "panel_user_uuid", None) or getattr( + active_sub, + "panel_user_uuid", + None, + ) if panel_uuid: subscription_service = request.app.get("subscription_service") panel_service = getattr(subscription_service, "panel_service", None) @@ -845,9 +852,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: panel_data = await panel_service.get_user_by_uuid(panel_uuid) if panel_data: subscription_url = panel_data.get("subscriptionUrl") or None + vpn_activity = _panel_user_connection_activity(panel_data) + vpn_connection_status = str(vpn_activity.get("status") or "unknown") + last_vpn_connected_at = vpn_activity.get("last_connected_at") except Exception as exc_panel: # pragma: no cover logger.warning( - "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", + "Failed to fetch panel details for user %s (uuid=%s): %s", target_id, panel_uuid, exc_panel, @@ -869,6 +879,8 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), "subscription_url": subscription_url, + "last_vpn_connected_at": last_vpn_connected_at, + "vpn_connection_status": vpn_connection_status, "referral": { "code": referral_code, "bot_link": referral_bot_link, diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 60fa062..4d3c93e 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -348,6 +348,16 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Английская версия текста на этапе оплаты.", subsection="checkout", ), + SettingField( + "PAYMENT_REQUEST_TIMEOUT_SECONDS", + "float", + "payments", + "Таймаут запроса к провайдеру", + "Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.", + optional=False, + min=1, + subsection="checkout", + ), # ─── Payment providers (toggles) ─────────────────────────────── # Common SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"), @@ -811,10 +821,11 @@ SETTINGS_MANIFEST: List[SettingField] = [ "bool", "system", "Анонимная статистика установки", - "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число " - "пользователей в виде диапазона. Без персональных данных, токенов и " - "доменов. Помогает понять число активных установок и какие версии " - "используются. Можно отключить здесь без перезапуска.", + "Раз в сутки отправляет обезличенный сигнал: версия, маркер образа " + "official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных " + "данных, токенов и доменов. Помогает понять число активных установок, какие " + "версии используются и долю изменённых сборок. Можно отключить здесь без " + "перезапуска.", ), ] diff --git a/backend/bot/app/web/templates/subscription_webapp.html b/backend/bot/app/web/templates/subscription_webapp.html index e0c1b17..00f81ff 100644 --- a/backend/bot/app/web/templates/subscription_webapp.html +++ b/backend/bot/app/web/templates/subscription_webapp.html @@ -22,7 +22,7 @@ sizes="180x180" href="/apple-touch-icon-precomposed.png" /> - /minishop + Subscription