Merge origin/dev into feature/telegram-flood-hardening

This commit is contained in:
3252a8
2026-06-10 16:49:53 +03:00
139 changed files with 6227 additions and 535 deletions
+12
View File
@@ -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__/
+7 -5
View File
@@ -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).
+27 -5
View File
@@ -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
+2 -1
View File
@@ -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-<short-sha>`.
# 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
+1
View File
@@ -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
+72
View File
@@ -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"
+4 -1
View File
@@ -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
+2
View File
@@ -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,
+177 -10
View File
@@ -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})
@@ -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),
@@ -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(),
}
)
@@ -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)
@@ -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,
+14 -2
View File
@@ -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,
+15 -4
View File
@@ -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, ОС, локаль и число пользователей в виде диапазона. Без персональных "
"данных, токенов и доменов. Помогает понять число активных установок, какие "
"версии используются и долю изменённых сборок. Можно отключить здесь без "
"перезапуска.",
),
]
@@ -22,7 +22,7 @@
sizes="180x180"
href="/apple-touch-icon-precomposed.png"
/>
<title>/minishop</title>
<title>Subscription</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
.app-boot-fallback {
+57 -2
View File
@@ -168,6 +168,7 @@
.theme-key-ascii .admin-toolbar-card,
.theme-key-ascii .admin-table-card,
.theme-key-ascii .admin-panel-dash-card,
.theme-key-ascii .admin-config-alerts,
.theme-key-ascii .admin-select-trigger,
.theme-key-ascii .admin-select-content,
.theme-key-ascii .admin-cn-card[data-slot="card"],
@@ -199,7 +200,8 @@
.theme-key-ascii .admin-tabs-trigger,
.theme-key-ascii .admin-revenue-period-btn,
.theme-key-ascii .admin-mobile-toggle,
.theme-key-ascii .admin-nav-item {
.theme-key-ascii .admin-nav-item,
.theme-key-ascii .admin-config-alert-link {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
@@ -216,7 +218,8 @@
.theme-key-ascii .admin-nav-item:hover,
.theme-key-ascii .admin-tabs-trigger:hover,
.theme-key-ascii .admin-revenue-period-btn:hover,
.theme-key-ascii .bottom-nav button:hover {
.theme-key-ascii .bottom-nav button:hover,
.theme-key-ascii .admin-config-alert-link:hover {
background: #ffffff;
color: #000000;
}
@@ -393,6 +396,58 @@
color: #000000;
}
/* ---------- Admin health config alerts ---------- */
.theme-key-ascii .admin-config-alerts {
position: relative;
padding-left: 18px;
color: #ffffff;
}
.theme-key-ascii .admin-config-alerts::before {
content: "!";
position: absolute;
top: 11px;
left: 7px;
color: #ffffff;
font-family: var(--font-mono);
font-weight: 700;
}
.theme-key-ascii .admin-config-alerts-error {
border-color: #ff5555;
color: #ffaaaa;
}
.theme-key-ascii .admin-config-alerts-error::before {
color: #ff5555;
}
.theme-key-ascii .admin-config-alert-dot {
width: auto;
height: auto;
border-radius: 0;
background: transparent;
color: currentColor;
transform: none;
}
.theme-key-ascii .admin-config-alert-dot::before {
content: ">";
font-family: var(--font-mono);
}
.theme-key-ascii .admin-config-alert-error .admin-config-alert-dot {
background: transparent;
color: #ff5555;
}
.theme-key-ascii .admin-config-alert-link {
padding: 1px 7px;
font-family: var(--font-mono);
opacity: 1;
}
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
.theme-key-ascii .trial-offer-card,
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 6,
"assets_version": 7,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
@@ -252,6 +252,27 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Admin health config alerts */
.theme-key-light .admin-config-alerts {
border-color: color-mix(in srgb, var(--warning) 38%, var(--admin-border));
background: color-mix(in srgb, var(--warning) 9%, #ffffff);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.theme-key-light .admin-config-alerts-error {
border-color: color-mix(in srgb, var(--danger) 38%, var(--admin-border));
background: color-mix(in srgb, var(--danger) 8%, #ffffff);
}
.theme-key-light .admin-config-alert-link {
background: rgba(255, 255, 255, 0.58);
}
.theme-key-light .admin-config-alert-link:hover {
background: #ffffff;
}
/* Telegram notifications banner: keep the warm warning tint but swap the
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
.theme-key-light .telegram-notifications-card {
+1 -1
View File
@@ -9,7 +9,7 @@
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 5,
"assets_version": 6,
"tokens": {
"color_scheme": "light"
}
@@ -1267,6 +1267,59 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
inset -1px -1px 0 #dfdfdf;
}
/* Admin health config alerts */
.theme-key-windows95 .admin-config-alerts {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
background: #ffffcc;
color: #000000;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .admin-config-alerts-error {
border-color: #ffffff #404040 #404040 #ffffff;
background: #f7d6d6;
color: #000000;
}
.theme-key-windows95 .admin-config-alert-dot {
border-radius: 0;
background: #808000;
box-shadow:
1px 1px 0 #ffffff,
-1px -1px 0 #404040;
}
.theme-key-windows95 .admin-config-alert-error .admin-config-alert-dot {
background: #800000;
}
.theme-key-windows95 .admin-config-alert-link {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: #c0c0c0;
color: #000000;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
opacity: 1;
}
.theme-key-windows95 .admin-config-alert-link:hover {
background: #dfdfdf;
}
.theme-key-windows95 .admin-config-alert-link:active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 input::placeholder,
.theme-key-windows95 textarea::placeholder,
.theme-key-windows95 .input::placeholder,
@@ -9,7 +9,7 @@
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 13,
"assets_version": 14,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
+46 -2
View File
@@ -1,13 +1,17 @@
import asyncio
import functools
import hmac
import logging
from typing import Awaitable, Callable, Optional
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
from aiohttp.web_log import AccessLogger, KeyMethod
from sqlalchemy.orm import sessionmaker
from bot.payment_providers import iter_provider_specs, iter_service_keys
from bot.utils.request_security import request_client_ip
from config.settings import Settings
@@ -18,6 +22,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
return hmac.compare_digest(telegram_secret_token, self.secret_token)
class TrustedProxyAccessLogger(AccessLogger):
"""Aiohttp access logger that respects trusted X-Forwarded-For headers."""
def compile_format(self, log_format):
methods = []
for atom in self.FORMAT_RE.findall(log_format):
if atom[1] == "":
format_key = self.LOG_FORMAT_MAP[atom[0]]
method = getattr(type(self), f"_format_{atom[0]}", None)
if method is None:
method = getattr(AccessLogger, f"_format_{atom[0]}")
methods.append(KeyMethod(format_key, method))
else:
format_key = (self.LOG_FORMAT_MAP[atom[2]], atom[1])
method = getattr(type(self), f"_format_{atom[2]}", None)
if method is None:
method = getattr(AccessLogger, f"_format_{atom[2]}")
methods.append(KeyMethod(format_key, functools.partial(method, atom[1])))
compiled = self.FORMAT_RE.sub(r"%s", log_format)
compiled = self.CLEANUP_RE.sub(r"%\1", compiled)
return compiled, methods
@staticmethod
def _format_a(request, response, time):
if request is None:
return "-"
settings = request.app.get("settings") if hasattr(request, "app") else None
trusted_proxies = getattr(settings, "trusted_proxies", None)
client_ip = request_client_ip(request, trusted_proxies=trusted_proxies)
return client_ip or "-"
def _inject_shared_instances(
app: web.Application,
dp: Dispatcher,
@@ -48,6 +85,8 @@ async def build_and_start_web_app(
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
*,
after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None,
):
app = web.Application()
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
@@ -110,7 +149,7 @@ async def build_and_start_web_app(
runners = []
webhooks_runner = web.AppRunner(app)
webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger)
await webhooks_runner.setup()
runners.append(webhooks_runner)
site = web.TCPSite(
@@ -123,6 +162,8 @@ async def build_and_start_web_app(
logging.info(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
if after_webhooks_started is not None:
await after_webhooks_started()
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
@@ -133,7 +174,10 @@ async def build_and_start_web_app(
settings,
async_session_factory,
)
subscription_runner = web.AppRunner(subscription_app)
subscription_runner = web.AppRunner(
subscription_app,
access_log_class=TrustedProxyAccessLogger,
)
await subscription_runner.setup()
runners.append(subscription_runner)
subscription_site = web.TCPSite(
+22 -1
View File
@@ -11,9 +11,20 @@ from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id
def _email_auth_enabled(settings: Settings) -> bool:
return bool(getattr(settings, "email_auth_configured", True))
def _email_auth_not_configured_response() -> web.Response:
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
payload = await _read_json(request)
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
@@ -40,6 +51,10 @@ async def account_email_request_route(request: web.Request) -> web.Response:
async def account_email_verify_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
@@ -55,7 +70,6 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
email = email_payload.email
code = str(email_payload.code or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
@@ -210,6 +224,9 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
async def account_password_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -232,6 +249,10 @@ async def account_password_request_route(request: web.Request) -> web.Response:
async def account_password_confirm_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings = request.app.get("settings")
if not _email_auth_enabled(settings):
return _email_auth_not_configured_response()
payload = await _read_json(request)
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
if validation_error:
+45 -16
View File
@@ -18,6 +18,8 @@ _GZIP_BODY_CACHE: Dict[str, bytes] = {}
_ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {}
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
WEBAPP_HTML_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
WEBAPP_LEGACY_ASSET_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
async def health_route(request: web.Request) -> web.Response:
@@ -48,7 +50,7 @@ async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Respo
allow_precompressed=bool(asset_hash),
)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
)
return response
@@ -382,11 +384,15 @@ async def webapp_current_favicon_route(request: web.Request) -> web.Response:
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
digest = _webapp_generated_favicon_digest(favicon_url)
if digest:
return _webapp_favicon_file_response(digest, target_filename)
response = _webapp_favicon_file_response(digest, target_filename)
response.headers["Cache-Control"] = "no-cache"
return response
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
if redirect_url:
raise web.HTTPFound(location=redirect_url)
redirect = web.HTTPFound(location=redirect_url)
redirect.headers["Cache-Control"] = "no-cache"
raise redirect
raise web.HTTPNotFound(text="webapp_favicon_not_found")
@@ -897,7 +903,7 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
strip_dev_mock=not asset_hash,
)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
)
return response
@@ -1136,9 +1142,11 @@ async def index_route(request: web.Request) -> web.Response:
bootstrap = _build_webapp_bootstrap_payload(request)
config = bootstrap["config"]
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
css_asset_name = _resolve_webapp_css_asset_name()
js_asset_name = _resolve_webapp_js_asset_name()
html = html.replace(
'href="/subscription_webapp.css"',
f'href="/{_resolve_webapp_css_asset_name()}"',
f'href="/{css_asset_name}"',
1,
)
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
@@ -1165,7 +1173,7 @@ async def index_route(request: web.Request) -> web.Response:
)
html = html.replace(
WEBAPP_JS_PLACEHOLDER,
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
f'<script src="/{js_asset_name}" defer></script>',
)
brand_asset_url = cached["logo_url"]
if brand_asset_url:
@@ -1178,7 +1186,9 @@ async def index_route(request: web.Request) -> web.Response:
1,
)
response = web.Response(text=html, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-cache"
response.headers["Cache-Control"] = WEBAPP_HTML_CACHE_CONTROL
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@@ -1444,10 +1454,15 @@ def _resolve_webapp_js_asset_name() -> str:
def _resolve_webapp_admin_js_asset_name() -> str:
# The admin bundle is lazy-loaded from the already running Mini App. In
# deployments where nginx serves static files in front of aiohttp, stale
# hashed admin filenames can 404 even though the runtime build asset exists.
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
# The admin bundle is lazy-loaded from the already running Mini App. It now
# ships content-hashed alongside the main bundle (same build, deterministic
# hashes, served immutable), so iOS WebViews fetch fresh admin assets on every
# deploy. The App.svelte loader falls back to the bare runtime build name if a
# hashed asset ever 404s.
return _resolve_hashed_js_asset_name(
kind="admin-js",
base_name="subscription_webapp_admin",
)
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
@@ -1466,7 +1481,7 @@ def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
if minified_assets:
minified_assets.sort(reverse=True)
return _set_cached_asset_name(kind, minified_assets[0][1])
return _set_cached_asset_name(kind, f"{base_name}.js")
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.js"))
def _resolve_webapp_css_asset_name() -> str:
@@ -1477,9 +1492,11 @@ def _resolve_webapp_css_asset_name() -> str:
def _resolve_webapp_admin_css_asset_name() -> str:
# Keep the lazy-loaded admin stylesheet on the stable build filename for
# the same reason as the JS bundle above.
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
# Content-hashed and immutable, same rationale as the admin JS bundle above.
return _resolve_hashed_css_asset_name(
kind="admin-css",
base_name="subscription_webapp_admin",
)
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
@@ -1498,7 +1515,19 @@ def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
if hashed_assets:
hashed_assets.sort(reverse=True)
return _set_cached_asset_name(kind, hashed_assets[0][1])
return _set_cached_asset_name(kind, f"{base_name}.css")
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.css"))
def _stable_asset_name_with_version(filename: str) -> str:
path = ASSET_DIR / filename
try:
stat = path.stat()
except OSError:
return filename
raw_version = f"{filename}:{int(stat.st_mtime_ns)}:{int(stat.st_size)}"
version = hashlib.sha256(raw_version.encode("utf-8")).hexdigest()[:8]
return f"{filename}?v={version}"
def _get_cached_asset_name(kind: str) -> Optional[str]:
+5
View File
@@ -1497,6 +1497,10 @@ async def _grant_referral_welcome_bonus_if_eligible(
return None
subscription_service: SubscriptionService = request.app["subscription_service"]
default_tariff_key = None
tariffs_config = getattr(settings, "tariffs_config", None)
if tariffs_config:
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
try:
if await subscription_service.has_active_subscription(session, int(user.user_id)):
return None
@@ -1508,6 +1512,7 @@ async def _grant_referral_welcome_bonus_if_eligible(
int(user.user_id),
referral_welcome_days,
reason="referral_welcome_bonus",
tariff_key=default_tariff_key,
)
+38 -22
View File
@@ -1068,28 +1068,44 @@ async def _create_subscription_payment(
"payment_amount_below_minimum",
"Payment amount is below the provider minimum",
)
return await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=price,
stars_price=stars_price,
currency=payment_currency,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
)
payment_context = WebAppPaymentContext(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=price,
stars_price=stars_price,
currency=payment_currency,
description=description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
)
if provider_spec.reuse_webapp_payment:
from bot.payment_providers.shared import reusable_webapp_payment_response
try:
reusable_response = await reusable_webapp_payment_response(
payment_context,
provider_spec,
)
except Exception:
logger.exception(
"Failed to verify reusable payment: user_id=%s provider=%s",
user_id,
provider_spec.provider_key,
)
reusable_response = None
if reusable_response is not None:
return reusable_response
return await provider_spec.create_webapp_payment(payment_context)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
+5
View File
@@ -658,12 +658,17 @@ async def start_command_handler(
)
if referred_by_user_id and referral_welcome_days > 0:
try:
default_tariff_key = None
tariffs_config = getattr(settings, "tariffs_config", None)
if tariffs_config:
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
referral_bonus_end_date = (
await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
tariff_key=default_tariff_key,
)
)
if referral_bonus_end_date:
@@ -22,7 +22,7 @@ from bot.keyboards.inline.user_keyboards import (
get_tariff_packages_keyboard,
get_tariff_periods_keyboard,
sale_mode_with_callback_context,
subscription_options_callback,
tariff_purchase_back_callback,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
@@ -303,7 +303,7 @@ async def select_tariff_callback(
current_lang,
i18n,
settings,
back_callback=subscription_options_callback(callback_context),
back_callback=tariff_purchase_back_callback(callback_context),
callback_context=callback_context,
)
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
@@ -71,6 +71,12 @@ def subscription_options_callback(context: Optional[str]) -> str:
return "main_action:bot_subscribe" if context == BOT_MENU_CONTEXT else "main_action:subscribe"
def tariff_purchase_back_callback(context: Optional[str]) -> str:
if context == BOT_MENU_CONTEXT:
return "main_action:bot_interface"
return subscription_options_callback(context)
def payment_methods_back_callback(
value: str, sale_mode: str = "subscription", price: Optional[float] = None
) -> str:
+19 -5
View File
@@ -91,12 +91,9 @@ async def register_all_routers(dp: Dispatcher, settings: Settings):
logging.info("All application routers registered.")
async def on_startup_configured(dispatcher: Dispatcher):
async def configure_telegram_webhook(dispatcher: Dispatcher) -> None:
bot: Bot = dispatcher["bot_instance"]
settings: Settings = dispatcher["settings"]
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
logging.info("STARTUP: on_startup_configured executing...")
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set:
@@ -152,6 +149,14 @@ async def on_startup_configured(dispatcher: Dispatcher):
)
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
async def on_startup_configured(dispatcher: Dispatcher):
bot: Bot = dispatcher["bot_instance"]
settings: Settings = dispatcher["settings"]
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
logging.info("STARTUP: on_startup_configured executing...")
if settings.SUBSCRIPTION_MINI_APP_URL:
async def _configure_mini_app_menu() -> None:
@@ -331,8 +336,17 @@ async def run_bot(settings_param: Settings):
_yk_path,
)
async def _after_webhooks_started() -> None:
await configure_telegram_webhook(dp)
async def web_server_task():
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory)
await build_and_start_web_app(
dp,
bot,
settings_param,
local_async_session_factory,
after_webhooks_started=_after_webhooks_started,
)
main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")]
+2
View File
@@ -127,6 +127,7 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
PaymentAmountResolver = Callable[[Any, Any, Any], bool]
PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]]
@@ -180,6 +181,7 @@ class PaymentProviderSpec:
webhook_route: Optional[WebhookRoute] = None
webhook_requires_base_url: bool = False
create_webapp_payment: Optional[WebAppPaymentFactory] = None
reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None
requires_configured_service: bool = True
price_source: str = "rub"
emoji: str = "💳"
+102 -1
View File
@@ -53,11 +53,14 @@ from .shared import (
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
)
_LOG = "freekassa"
@@ -153,7 +156,7 @@ class FreeKassaService(HttpClientMixin):
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
@@ -253,6 +256,62 @@ class FreeKassaService(HttpClientMixin):
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def get_orders(
self,
*,
payment_id: int,
order_status: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_id),
}
if order_status is not None:
payload["orderStatus"] = int(order_status)
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
return await post_json_request(
session,
f"{self.api_base_url}/orders",
body=payload,
log_prefix="FreeKassa get_orders",
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def try_reuse_pending_order(self, payment: Any) -> Optional[str]:
order_hash = str(getattr(payment, "provider_payment_id", None) or "").strip()
if not order_hash:
return None
success, response_data = await self.get_orders(
payment_id=payment.payment_id,
order_status=0,
)
if not success:
return None
for order in response_data.get("orders") or []:
if not isinstance(order, dict):
continue
try:
is_new = int(order.get("status", -1)) == 0
except (TypeError, ValueError):
continue
if not is_new:
continue
if str(order.get("merchant_order_id") or "") != str(payment.payment_id):
continue
fk_order_id = str(order.get("fk_order_id") or "").strip()
if fk_order_id:
payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/")
return f"{payment_url}/form/{fk_order_id}/{order_hash}"
return None
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
@@ -513,6 +572,39 @@ async def pay_fk_callback_handler(
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="freekassa",
pending_status="pending_freekassa",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await freekassa_service.try_reuse_pending_order(reusable_payment)
if reusable_url:
await safe_callback_answer(callback)
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -625,6 +717,14 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: FreeKassaService = ctx.request.app.get("freekassa_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_order(payment)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
@@ -772,6 +872,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/freekassa",
webhook_route=freekassa_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
+108 -1
View File
@@ -3,6 +3,7 @@ import hashlib
import hmac
import json
import logging
import time
from collections import OrderedDict
from typing import Any, Dict, List, Optional, Tuple
@@ -54,10 +55,12 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
)
router = Router(name="user_subscription_payments_heleket_router")
@@ -243,7 +246,7 @@ class HeleketService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"HeleketService initialized but not fully configured. Payments disabled."
@@ -372,6 +375,70 @@ class HeleketService(HttpClientMixin):
logging.exception("Heleket create_payment_link: request failed.")
return False, {"message": str(exc)}
async def get_payment_info(self, payment_uuid: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
payment_uuid = str(payment_uuid or "").strip()
if not payment_uuid:
return False, {"message": "missing_payment_uuid"}
body = {"uuid": payment_uuid}
headers = {
"merchant": self.merchant_id,
"sign": _compute_signature(body, self.api_key),
"Content-Type": "application/json",
}
session = await self._get_session()
try:
async with session.post(
f"{self.base_url}/v1/payment/info",
data=_serialize_for_signature(body).encode("utf-8"),
headers=headers,
) as response:
response_data = await response.json(content_type=None)
state = response_data.get("state") if isinstance(response_data, dict) else None
if response.status != 200 or state != 0:
logging.warning(
"Heleket get_payment_info failed: uuid=%s status=%s body=%s",
payment_uuid,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
result = response_data.get("result") or {}
return isinstance(result, dict), result
except Exception as exc:
logging.exception("Heleket get_payment_info request failed: uuid=%s", payment_uuid)
return False, {"message": str(exc)}
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
payment_uuid = str(getattr(payment, "provider_payment_id", None) or "").strip()
if not payment_uuid:
return None
success, data = await self.get_payment_info(payment_uuid)
if not success or not isinstance(data, dict):
return None
status = str(data.get("payment_status") or data.get("status") or "").lower()
if status != "check" or bool(data.get("is_final")):
return None
if str(data.get("uuid") or "") != payment_uuid:
return None
if str(data.get("order_id") or "") != str(payment.payment_id):
return None
try:
expired_at = int(data.get("expired_at") or 0)
except (TypeError, ValueError):
return None
if expired_at and expired_at <= int(time.time()):
return None
return (
str(data.get("url") or "").strip()
or str(getattr(payment, "provider_payment_url", None) or "").strip()
or None
)
def _verify_signature(self, payload: Dict[str, Any]) -> bool:
received = payload.get("sign")
if not isinstance(received, str) or not received:
@@ -612,6 +679,38 @@ async def pay_heleket_callback_handler(
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="heleket",
pending_status="pending_heleket",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await heleket_service.try_reuse_pending_payment(reusable_payment)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -684,6 +783,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: HeleketService = ctx.request.app.get("heleket_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_payment(payment)
async def heleket_webhook_route(request: web.Request) -> web.Response:
service: HeleketService = request.app["heleket_service"]
return await service.webhook_route(request)
@@ -887,6 +993,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/heleket",
webhook_route=heleket_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
emoji="🪙",
config_class=HeleketConfig,
presentation_class=HeleketPresentation,
+74 -3
View File
@@ -546,7 +546,7 @@ class PaykillaService(HttpClientMixin):
self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {}
self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, [])
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"PaykillaService initialized but not fully configured. Payments disabled."
@@ -586,6 +586,12 @@ class PaykillaService(HttpClientMixin):
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}"
def _signed_invoice_details_url(self, invoice_id: str) -> str:
timestamp_ms = int(time.time() * 1000)
recv_window_ms = int(self.config.RECV_WINDOW_MS)
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/invoice/{invoice_id}?{query}&signature={signature}"
def _signed_currency_url(self) -> str:
timestamp_ms = int(time.time() * 1000)
recv_window_ms = int(self.config.RECV_WINDOW_MS)
@@ -661,8 +667,7 @@ class PaykillaService(HttpClientMixin):
return cached_data
if response.status != 200 or not isinstance(response_data, list):
logging.warning(
"Paykilla currency metadata request failed "
"(status=%s, body=%s)",
"Paykilla currency metadata request failed (status=%s, body=%s)",
response.status,
response_data,
)
@@ -887,6 +892,64 @@ class PaykillaService(HttpClientMixin):
logging.exception("Paykilla create_payment_link: request failed.")
return False, {"message": str(exc)}
async def get_invoice_details(self, invoice_id: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
invoice_id = str(invoice_id or "").strip()
if not invoice_id:
return False, {"message": "missing_invoice_id"}
headers = {
"X-API-KEY": self.api_key,
"Content-Type": "application/json",
}
session = await self._get_session()
try:
async with session.get(
self._signed_invoice_details_url(invoice_id),
headers=headers,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("Paykilla get_invoice_details: invalid JSON: %s", response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
invoice = _response_invoice_data(response_data)
if response.status != 200 or not first_value(invoice, "id"):
logging.warning(
"Paykilla get_invoice_details failed: id=%s status=%s body=%s",
invoice_id,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, invoice
except Exception as exc:
logging.exception("Paykilla get_invoice_details request failed: id=%s", invoice_id)
return False, {"message": str(exc)}
async def try_reuse_pending_invoice(self, payment: Any) -> Optional[str]:
invoice_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
if not invoice_id:
return None
success, data = await self.get_invoice_details(invoice_id)
if not success:
return None
if str(first_value(data, "id") or "") != invoice_id:
return None
if str(data.get("clientOrderId") or "") != str(payment.payment_id):
return None
if str(data.get("status") or "").strip().upper() != "PROCESSING":
return None
return f"{self.widget_url}/{invoice_id}"
def _webhook_url_for_request(self, request: web.Request) -> Optional[str]:
configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None))
if configured:
@@ -1260,6 +1323,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: PaykillaService = ctx.request.app.get("paykilla_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_invoice(payment)
async def paykilla_webhook_route(request: web.Request) -> web.Response:
service: PaykillaService = request.app["paykilla_service"]
return await service.webhook_route(request)
@@ -1571,6 +1641,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/paykilla",
webhook_route=paykilla_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
emoji="",
config_class=PaykillaConfig,
presentation_class=PaykillaPresentation,
+117 -4
View File
@@ -55,6 +55,7 @@ from .shared import (
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
)
@@ -157,7 +158,7 @@ class PlategaService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
@@ -282,6 +283,69 @@ class PlategaService(HttpClientMixin):
log_prefix="Platega create_transaction",
)
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
transaction_id = str(transaction_id or "").strip()
if not transaction_id:
return False, {"message": "missing_transaction_id"}
session = await self._get_session()
try:
async with session.get(
f"{self.base_url}/transaction/{transaction_id}",
headers=self._auth_headers,
) as response:
data = await response.json(content_type=None)
if response.status != 200 or not isinstance(data, dict):
logging.warning(
"Platega get_transaction failed: id=%s status=%s body=%s",
transaction_id,
response.status,
data,
)
return False, {"status": response.status, "message": data}
return True, data
except Exception as exc:
logging.exception("Platega get_transaction request failed: id=%s", transaction_id)
return False, {"message": str(exc)}
async def try_reuse_pending_transaction(
self,
payment: Any,
*,
user_id: int,
sale_mode: str,
variant: str,
) -> Optional[str]:
transaction_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
if not transaction_id or not payment_url:
return None
success, data = await self.get_transaction(transaction_id)
if not success or str(data.get("status") or "").upper() != "PENDING":
return None
if str(data.get("id") or "") != transaction_id:
return None
try:
payload = json.loads(str(data.get("payload") or ""))
except (TypeError, ValueError, json.JSONDecodeError):
return None
expected = {
"payment_db_id": str(payment.payment_id),
"user_id": str(user_id),
"sale_mode": str(sale_mode),
"platega_variant": str(variant),
}
if not isinstance(payload, dict) or any(
str(payload.get(key) or "") != value for key, value in expected.items()
):
return None
return payment_url
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
@@ -519,6 +583,43 @@ async def pay_platega_callback_handler(
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="platega",
pending_status="pending_platega",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await platega_service.try_reuse_pending_transaction(
reusable_payment,
user_id=callback.from_user.id,
sale_mode=parts.sale_mode,
variant=platega_variant,
)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -549,7 +650,6 @@ async def pay_platega_callback_handler(
)
transaction_id = first_value(response_data, "transactionId", "id")
redirect_url = first_value(response_data, "redirect", "url", "paymentUrl")
provider_status = str((response_data or {}).get("status") or payment_record.status)
# Platega requires *both* a transaction id and a redirect url to count as a
# usable payment — neither field is sufficient on its own. Skipping the
# persistence step when the redirect is missing matches the pre-refactor
@@ -566,7 +666,6 @@ async def pay_platega_callback_handler(
api_success=success,
payment_url=redirect_url,
provider_payment_id=persistable_id,
new_status=provider_status if persistable_id else None,
log_prefix=_LOG,
)
@@ -652,7 +751,6 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "transactionId", "id"),
new_status=str((response_data or {}).get("status") or payment.status),
log_prefix="Platega",
)
@@ -665,6 +763,19 @@ async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Respons
return await _create_webapp_payment(ctx, "platega_crypto")
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: PlategaService = ctx.request.app.get("platega_service")
if not service or not service.configured:
return None
variant = "crypto" if ctx.method == "platega_crypto" else "sbp"
return await service.try_reuse_pending_transaction(
payment,
user_id=ctx.user_id,
sale_mode=ctx.sale_mode,
variant=variant,
)
def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple:
return tuple(
ProviderManifestField(
@@ -819,6 +930,7 @@ SBP_SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/platega",
webhook_route=platega_webhook_route,
create_webapp_payment=create_sbp_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST
@@ -851,6 +963,7 @@ CRYPTO_SPEC = PaymentProviderSpec(
service_key="platega_service",
callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
+85 -1
View File
@@ -51,11 +51,13 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
post_json_request,
quote_hwid_callback_parts,
render_link_or_fail,
render_payment_link,
)
_LOG = "severpay"
@@ -136,7 +138,7 @@ class SeverPayService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=15)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
@@ -248,6 +250,48 @@ class SeverPayService(HttpClientMixin):
return True, response_data.get("data") or response_data
return False, response_data
async def get_payment(self, provider_payment_id: str) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
return False, {"message": "service_not_configured"}
provider_payment_id = str(provider_payment_id or "").strip()
if not provider_payment_id:
return False, {"message": "missing_payment_id"}
identifier: Dict[str, Any]
if provider_payment_id.isdigit():
identifier = {"id": int(provider_payment_id)}
else:
identifier = {"uid": provider_payment_id}
session = await self._get_session()
success, response_data = await post_json_request(
session,
f"{self.base_url}/payin/get",
body=self._build_signed_body(identifier),
log_prefix="SeverPay get_payment",
is_success=lambda status, data: status == 200 and bool((data or {}).get("status")),
)
if success:
return True, response_data.get("data") or response_data
return False, response_data
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
if not provider_payment_id or not payment_url:
return None
success, data = await self.get_payment(provider_payment_id)
if not success or str(data.get("status") or "").lower() not in {"new", "process"}:
return None
returned_ids = {str(data.get("id") or ""), str(data.get("uid") or "")}
if provider_payment_id not in returned_ids:
return None
if str(data.get("order_id") or "") != str(payment.payment_id):
return None
return payment_url
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
@@ -465,6 +509,38 @@ async def pay_severpay_callback_handler(
hwid_quote=hwid_quote,
)
reuse_amounts = payment_record_amounts(
months=parts.months,
sale_mode=parts.sale_mode,
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="severpay",
pending_status="pending_severpay",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
)
if reusable_payment is not None:
reusable_url = await severpay_service.try_reuse_pending_payment(reusable_payment)
if reusable_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=reusable_url,
log_prefix=_LOG,
)
return
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
@@ -552,6 +628,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: SeverPayService = ctx.request.app.get("severpay_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_payment(payment)
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
@@ -677,6 +760,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/severpay",
webhook_route=severpay_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=SeverPayConfig,
presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
@@ -42,6 +42,7 @@ from .common import (
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
reusable_webapp_payment_response,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
@@ -117,6 +118,7 @@ __all__ = [
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"reusable_webapp_payment_response",
"payment_units_for_activation",
"payment_unavailable",
"post_json_request",
@@ -285,6 +285,7 @@ async def safe_store_provider_payment_id(
payment: Payment,
*,
provider_payment_id: str,
provider_payment_url: Optional[str] = None,
new_status: Optional[str] = None,
log_prefix: str,
) -> bool:
@@ -300,6 +301,7 @@ async def safe_store_provider_payment_id(
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
provider_payment_url=provider_payment_url,
)
await session.commit()
return True
@@ -354,11 +356,12 @@ async def render_link_or_fail(
payment as ``failed_creation``. Every link-style provider used to inline
this same sequence.
"""
if api_success and provider_payment_id:
if api_success and provider_payment_id and payment_url:
await safe_store_provider_payment_id(
session,
payment,
provider_payment_id=provider_payment_id,
provider_payment_url=payment_url,
new_status=new_status,
log_prefix=log_prefix,
)
@@ -315,6 +315,45 @@ async def create_webapp_payment_record(
)
async def reusable_webapp_payment_response(
ctx: WebAppPaymentContext,
provider_spec: Any,
*,
since_minutes: Optional[int] = None,
) -> Optional[web.Response]:
resolver = getattr(provider_spec, "reuse_webapp_payment", None)
if resolver is None:
return None
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
payment = await payment_dal.find_recent_pending_provider_payment(
ctx.session,
user_id=ctx.user_id,
provider=provider_spec.provider_key,
pending_status=provider_spec.pending_status,
amount=ctx.price,
currency=ctx.currency,
sale_mode=ctx.sale_mode,
months=amounts.months,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
tariff_key=amounts.tariff_key,
since_minutes=since_minutes,
)
if payment is None:
return None
payment_url = await resolver(ctx, payment)
if not payment_url:
return None
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None:
await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
await session.commit()
@@ -1,12 +1,16 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union
from aiohttp import ClientSession, ClientTimeout
from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig
SuccessCheck = Callable[[int, Any], bool]
TimeoutSource = Union[float, Callable[[], float]]
_TRANSPORT_ATTEMPTS = 2
_DEFAULT_TIMEOUT_SECONDS = 20.0
def http_ok(status: int, _body: Any) -> bool:
@@ -14,6 +18,29 @@ def http_ok(status: int, _body: Any) -> bool:
return status == 200
def _trace_request_ctx(trace_config_ctx: Any) -> Optional[dict]:
ctx = getattr(trace_config_ctx, "trace_request_ctx", None)
return ctx if isinstance(ctx, dict) else None
async def _mark_request_headers_sent(session, trace_config_ctx, params) -> None:
ctx = _trace_request_ctx(trace_config_ctx)
if ctx is not None:
ctx["headers_sent"] = True
def _payment_trace_config() -> TraceConfig:
trace_config = TraceConfig()
trace_config.on_request_headers_sent.append(_mark_request_headers_sent)
return trace_config
def _should_retry_transport_error(exc: Exception, trace_ctx: Mapping[str, Any]) -> bool:
if trace_ctx.get("headers_sent"):
return False
return isinstance(exc, (asyncio.TimeoutError, ClientError, OSError))
async def post_json_request(
session: ClientSession,
url: str,
@@ -29,34 +56,47 @@ async def post_json_request(
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
can decide what to do (typically: mark the payment as ``failed_creation``).
"""
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not is_success(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
for attempt in range(1, _TRANSPORT_ATTEMPTS + 1):
trace_ctx: dict[str, Any] = {"headers_sent": False}
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
trace_request_ctx=trace_ctx,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not is_success(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
log_prefix,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
if attempt < _TRANSPORT_ATTEMPTS and _should_retry_transport_error(exc, trace_ctx):
logging.warning(
"%s: transport failed before request headers were sent; retrying (%s/%s): %s", # noqa: E501
log_prefix,
response.status,
response_data,
attempt + 1,
_TRANSPORT_ATTEMPTS,
exc,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
continue
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
return False, {"message": "request_failed"}
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
@@ -76,20 +116,69 @@ class HttpClientMixin:
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
``__init__`` and inherits ``_get_session`` / ``close``. The session is
created on first use and recreated transparently if it was closed.
``total_timeout`` may be a callable so the timeout follows runtime
settings changes (admin overrides apply in-process without a restart).
When the value changes, the next request gets a fresh session; the old
session stays open until its own in-flight requests cannot outlive it.
Provider API calls are traced so callers can retry transport failures only
when aiohttp has not sent request headers yet.
"""
_timeout: ClientTimeout
_timeout_source: TimeoutSource
_session: Optional[ClientSession]
_stale_sessions: List[ClientSession]
_session_cleanup_tasks: Set["asyncio.Task[None]"]
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
self._timeout = ClientTimeout(total=total_timeout)
def _init_http_client(self, *, total_timeout: TimeoutSource = _DEFAULT_TIMEOUT_SECONDS) -> None:
self._timeout_source = total_timeout
self._session = None
self._stale_sessions = []
self._session_cleanup_tasks = set()
def _current_timeout_seconds(self) -> float:
source = self._timeout_source
try:
seconds = float(source() if callable(source) else source)
except Exception:
return _DEFAULT_TIMEOUT_SECONDS
return seconds if seconds > 0 else _DEFAULT_TIMEOUT_SECONDS
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
timeout_seconds = self._current_timeout_seconds()
session = self._session
if session is not None and not session.closed and session.timeout.total != timeout_seconds:
self._session = None
self._stale_sessions.append(session)
task = asyncio.create_task(self._close_stale_session(session))
self._session_cleanup_tasks.add(task)
task.add_done_callback(self._session_cleanup_tasks.discard)
session = None
if session is None or session.closed:
session = ClientSession(
timeout=ClientTimeout(total=timeout_seconds),
trace_configs=[_payment_trace_config()],
)
self._session = session
return session
async def _close_stale_session(self, session: ClientSession) -> None:
# Any request started on this session is bound by its total timeout,
# so after that long it is safe to close without cutting one off.
await asyncio.sleep((session.timeout.total or _DEFAULT_TIMEOUT_SECONDS) + 1.0)
if session in self._stale_sessions:
self._stale_sessions.remove(session)
if not session.closed:
await session.close()
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
for task in list(self._session_cleanup_tasks):
task.cancel()
self._session_cleanup_tasks.clear()
sessions = [self._session, *self._stale_sessions]
self._session = None
self._stale_sessions = []
for session in sessions:
if session and not session.closed:
await session.close()
@@ -39,13 +39,16 @@ async def finalize_webapp_link_payment(
log_prefix="Wata",
)
"""
if api_success and provider_payment_id:
# Reuse logic needs both a provider id and a redirect URL; persisting only
# the id creates orphan records that match find_recent but fail verification.
if api_success and provider_payment_id and payment_url:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
provider_payment_url=payment_url,
)
await session.commit()
except Exception:
+24 -46
View File
@@ -54,7 +54,6 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
payment_units_for_activation,
@@ -63,7 +62,6 @@ from .shared import (
render_link_or_fail,
render_payment_link,
safe_callback_answer,
sale_mode_base,
)
router = Router(name="user_subscription_payments_wata_router")
@@ -206,7 +204,7 @@ class WataService(HttpClientMixin):
self._default_return_url = default_return_url
self._cached_public_key_pem = None # populated by webhook on first verify
self._init_http_client(total_timeout=10)
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")
@@ -361,6 +359,19 @@ class WataService(HttpClientMixin):
if not success or not isinstance(data, dict):
return None
returned_ids = {
str(data.get("id") or "").strip(),
str(data.get("paymentLinkId") or "").strip(),
str(data.get("payment_link_id") or "").strip(),
}
returned_ids.discard("")
if returned_ids and provider_payment_id not in returned_ids:
return None
order_id = first_value(data, "orderId", "order_id")
if order_id is not None and str(order_id) != str(payment.payment_id):
return None
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
if status and status not in _WATA_LINK_OPENED_STATUSES:
return None
@@ -889,17 +900,15 @@ async def pay_wata_callback_handler(
payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
months_for_lookup = (
reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None
)
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session,
user_id=callback.from_user.id,
provider="wata",
pending_status="pending_wata",
amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode,
months=months_for_lookup,
months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
@@ -974,45 +983,6 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
reuse_amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
hwid_device_count=ctx.hwid_device_count,
)
months_for_lookup = (
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
)
try:
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
ctx.session,
user_id=ctx.user_id,
provider="wata",
pending_status="pending_wata",
amount=ctx.price,
sale_mode=ctx.sale_mode,
months=months_for_lookup,
purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key,
since_minutes=service.payment_link_ttl_minutes,
)
except Exception:
logging.exception("Wata WebApp: lookup of reusable payment failed")
reusable_payment = None
if reusable_payment is not None:
try:
reusable_url = await service.try_reuse_pending_link(reusable_payment)
except Exception:
logging.exception("Wata WebApp: failed to verify reusable link")
reusable_url = None
if reusable_url:
return payment_link_response(
payment_url=reusable_url,
payment_id=reusable_payment.payment_id,
)
try:
payment = await create_webapp_payment_record(
ctx,
@@ -1044,6 +1014,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
)
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: WataService = ctx.request.app.get("wata_service")
if not service or not service.configured:
return None
return await service.try_reuse_pending_link(payment)
async def wata_webhook_route(request: web.Request) -> web.Response:
service: WataService = request.app["wata_service"]
return await service.webhook_route(request)
@@ -1204,6 +1181,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/wata",
webhook_route=wata_webhook_route,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=WataConfig,
presentation_class=WataPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
+39 -4
View File
@@ -382,6 +382,10 @@ class YooKassaService:
"title": pm_title,
"card_last4": last4_val,
}
confirmation = getattr(payment_info_yk, "confirmation", None)
confirmation_url = (
getattr(confirmation, "confirmation_url", None) if confirmation else None
)
return {
"id": payment_info_yk.id,
"status": payment_info_yk.status,
@@ -399,6 +403,7 @@ class YooKassaService:
and hasattr(payment_info_yk.captured_at, "isoformat")
else None,
"payment_method": pm_payload,
"confirmation_url": confirmation_url,
"test_mode": getattr(payment_info_yk, "test", None),
}
else:
@@ -1603,7 +1608,7 @@ async def _initiate_yk_payment(
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
new_status="pending_yookassa",
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
@@ -1671,12 +1676,11 @@ async def _initiate_yk_payment(
return True
if payment_response_yk and payment_method_id:
status_to_store = payment_response_yk.get("status", "pending")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=status_to_store,
new_status="pending_yookassa",
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
@@ -2928,7 +2932,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
await payment_dal.update_payment_status_by_db_id(
ctx.session,
payment.payment_id,
response.get("status", "pending"),
"pending_yookassa",
yk_payment_id=response.get("id"),
)
await ctx.session.commit()
@@ -2939,6 +2943,36 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return payment_failed()
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
service: YooKassaService = ctx.request.app.get("yookassa_service")
if not service or not service.configured:
return None
provider_payment_id = str(
getattr(payment, "yookassa_payment_id", None)
or getattr(payment, "provider_payment_id", None)
or ""
).strip()
if not provider_payment_id:
return None
info = await service.get_payment_info(provider_payment_id)
if not info or str(info.get("status") or "").strip().lower() != "pending":
return None
if bool(info.get("paid")):
return None
metadata = info.get("metadata") or {}
expected_metadata = {
"user_id": str(ctx.user_id),
"payment_db_id": str(payment.payment_id),
"sale_mode": str(ctx.sale_mode),
}
if any(str(metadata.get(key) or "") != value for key, value in expected_metadata.items()):
return None
return str(info.get("confirmation_url") or "").strip() or None
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
@@ -3073,6 +3107,7 @@ SPEC = PaymentProviderSpec(
webhook_route=yookassa_webhook_route,
webhook_requires_base_url=True,
create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=YooKassaConfig,
presentation_class=YooKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
@@ -0,0 +1,511 @@
"""Detect common deployment misconfigurations for the admin panel.
Each check returns :class:`ConfigAlert` items the admin UI renders as
banners on the dashboard and inside the affected sections. Local checks
(filesystem, settings flags) run on every request; network checks
(Telegram webhook, Remnawave panel) are cached for a couple of minutes so
the dashboard stays fast and external APIs are not hammered.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from bot.utils.request_security import ip_in_allowlist
logger = logging.getLogger(__name__)
APP_ROOT = Path(__file__).resolve().parents[3]
NETWORK_CHECKS_TTL_SECONDS = 120.0
NETWORK_CHECK_TIMEOUT_SECONDS = 8.0
_WEBHOOK_ERROR_RECENT_SECONDS = 3600
_WEBHOOK_PENDING_THRESHOLD = 50
SEVERITY_ERROR = "error"
SEVERITY_WARNING = "warning"
# Admin section ids the frontend routes alerts to.
SECTION_SETTINGS = "settings"
SECTION_PAYMENTS = "payments"
SECTION_BACKUPS = "backups"
SECTION_TARIFFS = "tariffs"
SECTION_APPEARANCE = "appearance"
SECTION_TRANSLATIONS = "translations"
SECTION_USERS = "users"
_DATA_DIR_SECTIONS = (
SECTION_BACKUPS,
SECTION_TARIFFS,
SECTION_APPEARANCE,
SECTION_TRANSLATIONS,
SECTION_SETTINGS,
)
# Every message key an alert can carry. Tests assert each has
# ``admin_health_<key>`` entries in both locale files.
ALL_MESSAGE_KEYS = (
"data_dir_missing",
"data_dir_not_writable",
"backups_dir_not_writable",
"tariffs_config_invalid",
"locale_overrides_invalid",
"subscription_page_config_invalid",
"provider_not_configured",
"provider_webhook_needs_base_url",
"no_payment_methods",
"mini_app_url_missing",
"mini_app_url_not_https",
"redis_not_configured",
"smtp_incomplete",
"proxy_not_trusted",
"bot_token_invalid",
"telegram_api_error",
"telegram_webhook_missing",
"telegram_webhook_mismatch",
"telegram_webhook_error",
"telegram_webhook_pending",
"panel_api_not_configured",
"panel_api_unreachable",
)
@dataclass(frozen=True)
class ConfigAlert:
id: str
severity: str
sections: Tuple[str, ...]
params: Dict[str, Any] = field(default_factory=dict)
# Locale key suffix; defaults to ``id``. Per-provider alerts carry ids
# like ``provider_not_configured:wata`` but share one message key.
message_key: Optional[str] = None
def as_payload(self) -> Dict[str, Any]:
return {
"id": self.id,
"severity": self.severity,
"sections": list(self.sections),
"message_key": self.message_key or self.id,
"params": dict(self.params),
}
# ─── Filesystem checks ─────────────────────────────────────────────
def _dir_is_writable(path: Path) -> bool:
probe = path / f".health-probe-{uuid.uuid4().hex}.tmp"
try:
probe.write_text("ok", encoding="utf-8")
probe.unlink()
return True
except OSError:
try:
probe.unlink()
except OSError:
pass
return False
def _resolve_data_path(value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else APP_ROOT / path
def data_dir_alerts(settings: Any, app_root: Path = APP_ROOT) -> List[ConfigAlert]:
alerts: List[ConfigAlert] = []
data_dir = app_root / "data"
if not data_dir.is_dir():
return [
ConfigAlert(
id="data_dir_missing",
severity=SEVERITY_ERROR,
sections=_DATA_DIR_SECTIONS,
params={"path": str(data_dir)},
)
]
if not _dir_is_writable(data_dir):
alerts.append(
ConfigAlert(
id="data_dir_not_writable",
severity=SEVERITY_ERROR,
sections=_DATA_DIR_SECTIONS,
params={"path": str(data_dir)},
)
)
backup_dir = _resolve_data_path(str(getattr(settings, "BACKUP_DIR", "") or "data/backups"))
if backup_dir.is_dir() and not _dir_is_writable(backup_dir):
alerts.append(
ConfigAlert(
id="backups_dir_not_writable",
severity=SEVERITY_WARNING,
sections=(SECTION_BACKUPS,),
params={"path": str(backup_dir)},
)
)
return alerts
def config_file_alerts(settings: Any) -> List[ConfigAlert]:
alerts: List[ConfigAlert] = []
tariffs_path = _resolve_data_path(
str(getattr(settings, "TARIFFS_CONFIG_PATH", "") or "data/tariffs.json")
)
if tariffs_path.is_file():
try:
from config.tariffs_config import load_tariffs_config
load_tariffs_config(tariffs_path)
except Exception as exc:
alerts.append(
ConfigAlert(
id="tariffs_config_invalid",
severity=SEVERITY_ERROR,
sections=(SECTION_TARIFFS,),
params={"path": str(tariffs_path), "error": str(exc)[:300]},
)
)
locale_overrides_path = APP_ROOT / "data" / "locales-overrides.json"
if locale_overrides_path.is_file():
try:
json.loads(locale_overrides_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
alerts.append(
ConfigAlert(
id="locale_overrides_invalid",
severity=SEVERITY_WARNING,
sections=(SECTION_TRANSLATIONS,),
params={"path": str(locale_overrides_path), "error": str(exc)[:300]},
)
)
try:
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_admin_config_json,
)
try:
subscription_guides_admin_config_json(settings)
except SubscriptionGuidesConfigError as exc:
alerts.append(
ConfigAlert(
id="subscription_page_config_invalid",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
params={"error": str(exc)[:300]},
)
)
except Exception: # pragma: no cover - defensive import guard
logger.exception("Subscription guides config check failed unexpectedly")
return alerts
# ─── Settings checks ───────────────────────────────────────────────
def payment_provider_alerts(settings: Any, app: Any) -> List[ConfigAlert]:
from bot.payment_providers import iter_provider_specs
alerts: List[ConfigAlert] = []
any_enabled = False
seen_services: set = set()
for spec in iter_provider_specs():
try:
enabled = spec.is_effectively_enabled(settings)
except Exception: # pragma: no cover - provider config errors
logger.exception("Provider %s enabled check failed", spec.id)
continue
if not enabled:
continue
any_enabled = True
if spec.service_key in seen_services:
continue
if spec.service_key:
seen_services.add(spec.service_key)
if not spec.is_service_configured(app):
alerts.append(
ConfigAlert(
id=f"provider_not_configured:{spec.id}",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS,),
params={"provider": spec.label},
message_key="provider_not_configured",
)
)
if spec.webhook_requires_base_url and not getattr(settings, "WEBHOOK_BASE_URL", None):
alerts.append(
ConfigAlert(
id=f"provider_webhook_needs_base_url:{spec.id}",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS,),
params={"provider": spec.label},
message_key="provider_webhook_needs_base_url",
)
)
if not any_enabled:
alerts.append(
ConfigAlert(
id="no_payment_methods",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS, SECTION_PAYMENTS),
)
)
return alerts
def settings_alerts(settings: Any) -> List[ConfigAlert]:
alerts: List[ConfigAlert] = []
mini_app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if not mini_app_url:
alerts.append(
ConfigAlert(
id="mini_app_url_missing",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
)
)
elif not mini_app_url.lower().startswith("https://"):
alerts.append(
ConfigAlert(
id="mini_app_url_not_https",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS,),
params={"url": mini_app_url},
)
)
if not getattr(settings, "REDIS_URL", None):
alerts.append(
ConfigAlert(
id="redis_not_configured",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
)
)
smtp_partial = any(
getattr(settings, key, None)
for key in ("SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_FROM_EMAIL")
)
if smtp_partial and not getattr(settings, "email_auth_configured", False):
alerts.append(
ConfigAlert(
id="smtp_incomplete",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
)
)
return alerts
def proxy_alerts(request: Any, settings: Any) -> List[ConfigAlert]:
"""Warn when the admin request itself came through an untrusted proxy.
In that case provider webhooks with IP allowlists will see the proxy
address instead of the real sender and may reject valid callbacks.
"""
headers = getattr(request, "headers", None) or {}
forwarded = headers.get("X-Forwarded-For")
remote = getattr(request, "remote", None)
if not forwarded or not remote:
return []
if ip_in_allowlist(remote, getattr(settings, "trusted_proxies", None)):
return []
return [
ConfigAlert(
id="proxy_not_trusted",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
params={"remote": str(remote)},
)
]
# ─── Network checks (cached) ───────────────────────────────────────
async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]:
if bot is None:
return []
try:
info = await asyncio.wait_for(bot.get_webhook_info(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS)
except Exception as exc:
if exc.__class__.__name__ in {"TelegramUnauthorizedError", "TelegramNotFound"}:
return [
ConfigAlert(
id="bot_token_invalid",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS,),
)
]
return [
ConfigAlert(
id="telegram_api_error",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
params={"error": str(exc)[:300]},
)
]
alerts: List[ConfigAlert] = []
actual_url = str(getattr(info, "url", "") or "")
base_url = str(getattr(settings, "WEBHOOK_BASE_URL", "") or "").rstrip("/")
expected_url = (
f"{base_url}{getattr(settings, 'telegram_webhook_path', '/tg/webhook')}" if base_url else ""
)
if not actual_url:
alerts.append(
ConfigAlert(
id="telegram_webhook_missing",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS,),
)
)
elif expected_url and actual_url != expected_url:
alerts.append(
ConfigAlert(
id="telegram_webhook_mismatch",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
params={"actual": actual_url, "expected": expected_url},
)
)
pending = int(getattr(info, "pending_update_count", 0) or 0)
last_error_date = getattr(info, "last_error_date", None)
last_error_ts: Optional[float] = None
if last_error_date is not None:
last_error_ts = (
last_error_date.timestamp()
if hasattr(last_error_date, "timestamp")
else float(last_error_date)
)
if (
pending > 0
and last_error_ts
and (time.time() - last_error_ts) < _WEBHOOK_ERROR_RECENT_SECONDS
):
alerts.append(
ConfigAlert(
id="telegram_webhook_error",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
params={"error": str(getattr(info, "last_error_message", "") or "")[:300]},
)
)
if pending > _WEBHOOK_PENDING_THRESHOLD:
alerts.append(
ConfigAlert(
id="telegram_webhook_pending",
severity=SEVERITY_WARNING,
sections=(SECTION_SETTINGS,),
params={"count": pending},
)
)
return alerts
async def panel_alerts(panel_service: Any, settings: Any) -> List[ConfigAlert]:
if not getattr(settings, "PANEL_API_URL", None) or not getattr(settings, "PANEL_API_KEY", None):
return [
ConfigAlert(
id="panel_api_not_configured",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS, SECTION_USERS, SECTION_TARIFFS),
)
]
if panel_service is None:
return []
try:
stats = await asyncio.wait_for(
panel_service.get_system_stats(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS
)
except Exception as exc:
logger.debug("Panel health check failed: %s", exc)
stats = None
if stats is None:
return [
ConfigAlert(
id="panel_api_unreachable",
severity=SEVERITY_ERROR,
sections=(SECTION_SETTINGS, SECTION_USERS),
params={"url": str(getattr(settings, "PANEL_API_URL", "") or "")},
)
]
return []
# ─── Aggregation ───────────────────────────────────────────────────
_network_cache: Dict[int, Tuple[float, List[ConfigAlert]]] = {}
_network_cache_lock = asyncio.Lock()
def local_alerts(request: Any, settings: Any, app: Any) -> List[ConfigAlert]:
alerts: List[ConfigAlert] = []
for collect in (
lambda: data_dir_alerts(settings),
lambda: config_file_alerts(settings),
lambda: payment_provider_alerts(settings, app),
lambda: settings_alerts(settings),
lambda: proxy_alerts(request, settings),
):
try:
alerts.extend(collect())
except Exception: # pragma: no cover - one broken check must not hide others
logger.exception("Config health check failed")
return alerts
async def network_alerts(app: Any, settings: Any, *, refresh: bool = False) -> List[ConfigAlert]:
cache_key = id(settings)
now = time.monotonic()
if not refresh:
cached = _network_cache.get(cache_key)
if cached and (now - cached[0]) < NETWORK_CHECKS_TTL_SECONDS:
return cached[1]
async with _network_cache_lock:
if not refresh:
cached = _network_cache.get(cache_key)
if cached and (time.monotonic() - cached[0]) < NETWORK_CHECKS_TTL_SECONDS:
return cached[1]
results = await asyncio.gather(
telegram_alerts(app.get("bot"), settings),
panel_alerts(app.get("panel_service"), settings),
return_exceptions=True,
)
alerts: List[ConfigAlert] = []
for result in results:
if isinstance(result, BaseException):
logger.exception("Network config health check failed", exc_info=result)
continue
alerts.extend(result)
_network_cache[cache_key] = (time.monotonic(), alerts)
return alerts
async def collect_config_alerts(request: Any, *, refresh: bool = False) -> List[Dict[str, Any]]:
app = request.app
settings = app["settings"]
alerts = local_alerts(request, settings, app)
alerts.extend(await network_alerts(app, settings, refresh=refresh))
order = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1}
alerts.sort(key=lambda alert: (order.get(alert.severity, 2), alert.id))
return [alert.as_payload() for alert in alerts]
+41 -3
View File
@@ -10,14 +10,13 @@ copy goes through the shared `JsonI18n` instance so translations live in
from __future__ import annotations
import html
import io
import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit
from config.webapp_themes_config import effective_webapp_theme_accent
if TYPE_CHECKING:
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
@@ -44,6 +43,7 @@ _LOGO_CONTENT_TYPES = {
".svg": "image/svg+xml",
".webp": "image/webp",
}
_EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS = {".ico", ".webp"}
@dataclass(frozen=True)
@@ -82,6 +82,8 @@ def _theme_accent(settings: Settings) -> str:
catalog = getattr(settings, "webapp_themes_catalog", None)
if catalog is None:
return primary
from config.webapp_themes_config import effective_webapp_theme_accent
return _safe_color(effective_webapp_theme_accent(catalog, primary))
except Exception:
return primary
@@ -129,6 +131,8 @@ def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES:
return None
content_type, body = _email_logo_payload(filename, content_type, body)
return EmailInlineImage(
content_id=_EMAIL_LOGO_CONTENT_ID,
content_type=content_type,
@@ -136,6 +140,40 @@ def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
)
def _email_logo_payload(filename: str, content_type: str, body: bytes) -> Tuple[str, bytes]:
suffix = Path(filename).suffix.lower()
if suffix not in _EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS:
return content_type, body
png_body = _static_raster_logo_to_png(body)
if png_body and len(png_body) <= _WEBAPP_LOGO_MAX_BYTES:
return "image/png", png_body
return content_type, body
def _static_raster_logo_to_png(body: bytes) -> Optional[bytes]:
try:
from PIL import Image, ImageOps, UnidentifiedImageError
except ImportError:
return None
try:
with Image.open(io.BytesIO(body)) as image:
image.seek(0)
if getattr(image, "is_animated", False):
return None
source = ImageOps.exif_transpose(image).convert("RGBA")
except (OSError, UnidentifiedImageError, ValueError, EOFError):
return None
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
return None
output = io.BytesIO()
source.save(output, format="PNG", optimize=True)
return output.getvalue()
def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]:
inline_logo = _inline_uploaded_logo(settings)
if inline_logo:
@@ -198,7 +236,7 @@ def _layout(
logo_block = (
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
f'border-radius:16px;">'
f'border-radius:16px;background:transparent;background-color:transparent;">'
)
layout_html = f"""<!DOCTYPE html>
+45 -13
View File
@@ -237,11 +237,12 @@ class NotificationService:
text: str,
path: str,
fallback_url: str,
web_app_button: bool = True,
) -> InlineKeyboardButton:
webapp_url = self._support_webapp_url(path)
if webapp_url:
if webapp_url and web_app_button:
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url))
return InlineKeyboardButton(text=text, url=fallback_url)
return InlineKeyboardButton(text=text, url=webapp_url or fallback_url)
def _support_text(self, language: Optional[str], key: str, fallback: str) -> str:
if not self.i18n:
@@ -316,7 +317,14 @@ class NotificationService:
return enabled
return self._coerce_bool_setting(raw_value, enabled)
def _support_keyboard(self, ticket, user, *, admin: bool = True) -> InlineKeyboardMarkup:
def _support_keyboard(
self,
ticket,
user,
*,
admin: bool = True,
web_app_buttons: bool = True,
) -> InlineKeyboardMarkup:
ticket_path = (
f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
)
@@ -326,6 +334,7 @@ class NotificationService:
text="Открыть тикет",
path=ticket_path,
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
web_app_button=web_app_buttons,
)
]
]
@@ -342,12 +351,35 @@ class NotificationService:
text="Карточка пользователя",
path=user_card_path,
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
web_app_button=web_app_buttons,
)
)
if profile_row:
rows.append(profile_row)
return InlineKeyboardMarkup(inline_keyboard=rows)
def _support_log_thread_id(self) -> Optional[int]:
return getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None)
def _support_thread_is_configured(self) -> bool:
return bool(getattr(self.settings, "LOG_CHAT_ID", None) and self._support_log_thread_id())
async def _send_admin_support_telegram(
self,
message: str,
*,
admin_markup: InlineKeyboardMarkup,
log_markup: InlineKeyboardMarkup,
) -> None:
thread_id = self._support_log_thread_id()
if not self._support_thread_is_configured():
await self._send_to_admins(message, reply_markup=admin_markup)
await self._send_to_log_channel(
message,
thread_id=thread_id,
reply_markup=log_markup,
)
def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup:
button_text = self._support_text(
getattr(user, "language_code", None),
@@ -415,12 +447,12 @@ class NotificationService:
f"статус: {hd.quote(str(snapshot.get('panel_status') or ''))}\n\n"
f"<b>Текст обращения</b>\n{hd.quote(preview)}"
)
keyboard = self._support_keyboard(ticket, user, admin=True)
await self._send_to_admins(message, reply_markup=keyboard)
await self._send_to_log_channel(
admin_keyboard = self._support_keyboard(ticket, user, admin=True)
log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
await self._send_admin_support_telegram(
message,
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
reply_markup=keyboard,
admin_markup=admin_keyboard,
log_markup=log_keyboard,
)
await self._send_admin_support_email(
render_support_new_ticket_admin,
@@ -456,13 +488,13 @@ class NotificationService:
f"💬 <b>Ответ пользователя в тикете #{ticket.ticket_id}</b>\n"
f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}"
)
keyboard = self._support_keyboard(ticket, user, admin=True)
if send_telegram and getattr(self.settings, "LOG_SUPPORT", True):
await self._send_to_admins(text, reply_markup=keyboard)
await self._send_to_log_channel(
admin_keyboard = self._support_keyboard(ticket, user, admin=True)
log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
await self._send_admin_support_telegram(
text,
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
reply_markup=keyboard,
admin_markup=admin_keyboard,
log_markup=log_keyboard,
)
if send_email:
await self._send_admin_support_email(
@@ -87,12 +87,17 @@ class PromoCodeService:
return False, _("promo_code_already_used_by_user", code=code_display)
bonus_days = promo_data.bonus_days
default_tariff_key = None
tariffs_config = getattr(self.settings, "tariffs_config", None)
if tariffs_config:
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
new_end_date = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=user_id,
bonus_days=bonus_days,
reason=f"promo code {applied_code}",
tariff_key=default_tariff_key,
)
if new_end_date:
@@ -783,6 +783,7 @@ class SubscriptionLifecycleMixin:
bonus_days: int,
reason: str = "bonus",
extend_hwid_devices: bool = True,
tariff_key: Optional[str] = None,
) -> Optional[datetime]:
reason_lower = (reason or "").lower()
apply_main_traffic_limit = any(
@@ -809,6 +810,17 @@ class SubscriptionLifecycleMixin:
preserve_tariff_limits = bool(
active_sub and active_sub.tariff_key and self._tariffs_config()
)
bonus_tariff = None
if not active_sub and tariff_key and self._tariffs_config():
try:
bonus_tariff = self._resolve_tariff(tariff_key)
except Exception:
logging.warning(
"Unable to resolve bonus tariff %s for user %s.",
tariff_key,
user_id,
exc_info=True,
)
if not active_sub or not active_sub.end_date:
logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
@@ -818,10 +830,16 @@ class SubscriptionLifecycleMixin:
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501
traffic_limit = (
self.settings.user_traffic_limit_bytes
self._traffic_limit_for_period_tariff(bonus_tariff)
if bonus_tariff
else self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit
else self.settings.trial_traffic_limit_bytes
)
premium_baseline_bytes = bonus_tariff.premium_monthly_bytes if bonus_tariff else 0
base_hwid_limit = (
self._base_hwid_limit_for_tariff(bonus_tariff) if bonus_tariff else None
)
bonus_sub_payload = {
"user_id": user_id,
@@ -834,6 +852,21 @@ class SubscriptionLifecycleMixin:
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": traffic_limit,
"auto_renew_enabled": False,
"tariff_key": bonus_tariff.key if bonus_tariff else None,
"tier_baseline_bytes": bonus_tariff.monthly_bytes if bonus_tariff else None,
"topup_balance_bytes": 0,
"regular_bonus_bytes": 0,
"regular_unlimited_override": False,
"premium_baseline_bytes": premium_baseline_bytes,
"premium_topup_balance_bytes": 0,
"premium_topup_used_bytes": 0,
"premium_used_bytes": 0,
"premium_is_limited": False,
"premium_period_start_at": None,
"period_start_at": None,
"is_throttled": False,
"hwid_device_limit": base_hwid_limit,
"extra_hwid_devices": 0,
# Registration/referral bonus grants are short-lived, like a
# trial: only warn a few hours before they end, not days ahead.
"suppress_early_expiry_notifications": True,
@@ -886,13 +919,35 @@ class SubscriptionLifecycleMixin:
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes
updated_sub_model.traffic_limit_bytes
if bonus_tariff
else self.settings.user_traffic_limit_bytes
if apply_main_traffic_limit and not preserve_tariff_limits
else None
),
traffic_limit_strategy=(
"MONTH"
if bonus_tariff and bonus_tariff.billing_model == "period"
else self.settings.USER_TRAFFIC_STRATEGY
if bonus_tariff
else None
),
hwid_device_limit=(
self._effective_hwid_limit(updated_sub_model.hwid_device_limit, 0)
if bonus_tariff
else None
),
include_uuid=False,
include_default_squads=False,
)
if bonus_tariff:
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
bonus_tariff
)
if self.settings.parsed_user_external_squad_uuid:
panel_update_payload["externalSquadUuid"] = (
self.settings.parsed_user_external_squad_uuid
)
panel_update_success = await self.panel_service.update_user_details_on_panel(
panel_uuid,
+10 -1
View File
@@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.infra.redis import redis_lock
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
from bot.utils.app_version import (
resolve_app_version,
resolve_app_version_tag,
resolve_build_provenance,
resolve_image_modified,
)
from config.settings import Settings
from db.dal import app_settings_dal, user_dal
@@ -169,11 +174,15 @@ class TelemetryWorker:
version = resolve_app_version()
version_tag = resolve_app_version_tag()
build_provenance = resolve_build_provenance()
image_modified = resolve_image_modified()
# Person properties (``$set``) snapshot the latest state per install, so
# "version breakdown" in PostHog is a person-property breakdown.
person_props = {
"app_version": version,
"app_version_tag": version_tag,
"build_provenance": build_provenance,
"image_modified": image_modified,
"os": platform.system().lower() or "unknown",
"arch": platform.machine().lower() or "unknown",
"python_version": platform.python_version(),
+61
View File
@@ -6,6 +6,9 @@ the runtime container agree on the value:
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
> ``dev+unknown``
Build provenance is intentionally separate from the version: official release
automation stamps official images, while local/fork builds default to custom.
The same value powers the admin sidebar (web process) and the anonymous
telemetry beacon (worker process), so "active installs" and version
breakdowns line up across both.
@@ -24,6 +27,16 @@ from typing import Optional
APP_ROOT = Path(__file__).resolve().parents[3]
_APP_VERSION_CACHE: Optional[str] = None
_APP_BUILD_PROVENANCE_CACHE: Optional[str] = None
BUILD_PROVENANCE_OFFICIAL = "official"
BUILD_PROVENANCE_CUSTOM = "custom"
BUILD_PROVENANCE_UNKNOWN = "unknown"
_BUILD_PROVENANCE_VALUES = {
BUILD_PROVENANCE_OFFICIAL,
BUILD_PROVENANCE_CUSTOM,
BUILD_PROVENANCE_UNKNOWN,
}
def _run_git_command(*args: str) -> str:
@@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str:
return ""
def _normalize_build_provenance(raw: str) -> str:
value = str(raw or "").strip().lower()
if not value:
return ""
aliases = {
"true": BUILD_PROVENANCE_OFFICIAL,
"1": BUILD_PROVENANCE_OFFICIAL,
"yes": BUILD_PROVENANCE_OFFICIAL,
"upstream": BUILD_PROVENANCE_OFFICIAL,
"release": BUILD_PROVENANCE_OFFICIAL,
"false": BUILD_PROVENANCE_CUSTOM,
"0": BUILD_PROVENANCE_CUSTOM,
"no": BUILD_PROVENANCE_CUSTOM,
"fork": BUILD_PROVENANCE_CUSTOM,
"modified": BUILD_PROVENANCE_CUSTOM,
"local": BUILD_PROVENANCE_CUSTOM,
}
value = aliases.get(value, value)
if value in _BUILD_PROVENANCE_VALUES:
return value
return BUILD_PROVENANCE_CUSTOM
def resolve_app_version() -> str:
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
global _APP_VERSION_CACHE
@@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str:
if tag:
return tag
return resolve_app_version()
def resolve_build_provenance() -> str:
"""Low-cardinality image provenance: ``official``, ``custom`` or ``unknown``."""
global _APP_BUILD_PROVENANCE_CACHE
if _APP_BUILD_PROVENANCE_CACHE:
return _APP_BUILD_PROVENANCE_CACHE
env_value = _normalize_build_provenance(os.getenv("REMNAWAVE_MINISHOP_BUILD_PROVENANCE", ""))
if env_value:
_APP_BUILD_PROVENANCE_CACHE = env_value
return env_value
build_value = _normalize_build_provenance(_read_build_file(".build-provenance"))
if build_value:
_APP_BUILD_PROVENANCE_CACHE = build_value
return build_value
_APP_BUILD_PROVENANCE_CACHE = BUILD_PROVENANCE_CUSTOM
return _APP_BUILD_PROVENANCE_CACHE
def resolve_image_modified() -> bool:
"""True for non-official builds, including forks and local rebuilds."""
return resolve_build_provenance() != BUILD_PROVENANCE_OFFICIAL
+25 -10
View File
@@ -34,12 +34,26 @@ def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
return None
def _last_forwarded_ip(header_value: str) -> Optional[str]:
def _forwarded_ips(header_value: str) -> list[ipaddress._BaseAddress]:
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
if not candidates:
return None
candidate = candidates[-1]
return candidate if _parse_ip(candidate) is not None else None
parsed: list[ipaddress._BaseAddress] = []
for candidate in candidates:
parsed_ip = _parse_ip(candidate)
if parsed_ip is not None:
parsed.append(parsed_ip)
return parsed
def _forwarded_client_ip(
forwarded_ips: Sequence[ipaddress._BaseAddress],
trusted_networks: Sequence[ipaddress._BaseNetwork],
) -> Optional[str]:
for forwarded_ip in reversed(forwarded_ips):
if not any(forwarded_ip in network for network in trusted_networks):
return str(forwarded_ip)
if forwarded_ips:
return str(forwarded_ips[0])
return None
def request_client_ip(
@@ -48,20 +62,21 @@ def request_client_ip(
trusted_proxies: Optional[Sequence[str] | str] = None,
) -> Optional[str]:
remote_ip = _parse_ip(request.remote or "")
forwarded_for = request.headers.get("X-Forwarded-For", "")
forwarded_ips = _forwarded_ips(request.headers.get("X-Forwarded-For", ""))
if remote_ip and forwarded_for:
if remote_ip and forwarded_ips:
trusted_networks = parse_ip_entries(trusted_proxies)
if any(remote_ip in network for network in trusted_networks):
forwarded_ip = _last_forwarded_ip(forwarded_for)
forwarded_ip = _forwarded_client_ip(forwarded_ips, trusted_networks)
if forwarded_ip:
return forwarded_ip
if remote_ip:
return str(remote_ip)
forwarded_ip = _last_forwarded_ip(forwarded_for)
return forwarded_ip
if forwarded_ips:
return str(forwarded_ips[-1])
return None
def ip_in_allowlist(
+22 -4
View File
@@ -138,6 +138,17 @@ DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
]
)
DEFAULT_TRUSTED_PROXIES = ",".join(
[
"127.0.0.1",
"::1",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
]
)
class DBSettings(BaseModel):
user: str
@@ -215,6 +226,7 @@ class Settings(BaseSettings):
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS: int = Field(default=30)
PROFILE_SYNC_CACHE_TTL_SECONDS: int = Field(default=900)
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS: int = Field(default=3600)
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600)
@@ -309,7 +321,7 @@ class Settings(BaseSettings):
WEBHOOK_BASE_URL: Optional[str] = None
TRUSTED_PROXIES: Optional[str] = Field(
default="127.0.0.1,::1",
default=DEFAULT_TRUSTED_PROXIES,
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501
)
@@ -331,6 +343,11 @@ class Settings(BaseSettings):
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN,
description="English subscription description shown before purchase/renewal options.",
)
PAYMENT_REQUEST_TIMEOUT_SECONDS: float = Field(
default=20,
ge=1,
description="Maximum total time for one payment provider API request, in seconds.",
)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
@@ -1251,9 +1268,10 @@ class Settings(BaseSettings):
TELEMETRY_ENABLED: bool = Field(
default=True,
description=(
"Send an anonymous daily install heartbeat (version, OS, locale, "
"user-count range). No personal data. Opt out here, via the web "
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
"Send an anonymous daily install heartbeat (version, official/custom "
"image provenance, OS, locale, user-count range). No personal data. "
"Opt out here, via the web admin, or by clearing "
"TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
),
)
TELEMETRY_ENDPOINT: str = Field(
+3
View File
@@ -372,6 +372,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or ".theme-key-light.app-shell" not in style
or "Install guide theme surfaces" not in style
or "Admin controls: range sliders and sortable rows" not in style
or "Admin health config alerts" not in style
)
if key == "ascii":
return (
@@ -383,6 +384,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
or "Admin controls: range sliders and sortable rows" not in style
or "Admin health config alerts" not in style
)
if key != "windows95":
return False
@@ -407,6 +409,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
or "New webapp surfaces: support, purchase info, password login" not in style
or "Install guide theme surfaces" not in style
or "Admin controls: range sliders and sortable rows" not in style
or "Admin health config alerts" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
)
+26 -10
View File
@@ -1,7 +1,7 @@
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import Date, and_, case, cast, func
from sqlalchemy import Date, and_, case, cast, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload, selectinload
@@ -116,31 +116,41 @@ async def find_recent_pending_provider_payment(
provider: str,
pending_status: str,
amount: float,
currency: Optional[str],
sale_mode: Optional[str],
months: Optional[int],
purchased_gb: Optional[float],
purchased_hwid_devices: Optional[int],
tariff_key: Optional[str] = None,
since_minutes: int = 60,
since_minutes: Optional[int] = None,
) -> Optional[Payment]:
"""Return the most recent pending payment matching the given tariff parameters.
Used to reuse an existing provider payment link instead of creating a new one
on repeated user clicks. Only payments with a populated ``provider_payment_id``
are returned without it, there's no link to reuse.
on repeated user clicks. A generic or provider-specific payment id must be
populated so the caller can verify the remote payment link.
Status matching is case-insensitive and also accepts the generic ``pending``
alias so legacy rows (e.g. Platega ``PENDING`` or YooKassa ``pending``) stay
reusable after provider APIs overwrite the internal pending status.
"""
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
conditions = [
Payment.user_id == user_id,
Payment.provider == provider,
Payment.status == pending_status,
Payment.provider_payment_id.isnot(None),
Payment.created_at >= cutoff,
func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})),
or_(
Payment.provider_payment_id.isnot(None),
Payment.yookassa_payment_id.isnot(None),
),
func.abs(Payment.amount - float(amount)) < 0.01,
]
if since_minutes is not None:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
conditions.append(Payment.created_at >= cutoff)
if currency is not None:
conditions.append(func.upper(Payment.currency) == str(currency).strip().upper())
if sale_mode is not None:
conditions.append(Payment.sale_mode == sale_mode)
if tariff_key is not None:
@@ -238,12 +248,18 @@ async def count_user_succeeded_payments(
async def update_provider_payment_and_status(
session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str
session: AsyncSession,
payment_db_id: int,
provider_payment_id: str,
new_status: str,
provider_payment_url: Optional[str] = None,
) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.provider_payment_id = provider_payment_id
if provider_payment_url:
payment.provider_payment_url = provider_payment_url
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
+73
View File
@@ -715,6 +715,12 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i
return result.scalars().all()
async def count_all_active_users_for_broadcast(session: AsyncSession) -> int:
stmt = select(func.count(User.user_id)).where(User.is_banned == False)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
stmt = select(User).where(User.panel_user_uuid.is_not(None))
result = await session.execute(stmt)
@@ -890,6 +896,27 @@ async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[i
return result.scalars().all()
async def count_users_with_active_subscription_for_broadcast(session: AsyncSession) -> int:
"""Count non-banned users who have any active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = (
select(func.count(func.distinct(Subscription.user_id)))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
User.is_banned == False,
Subscription.is_active == True,
Subscription.end_date > now,
)
)
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who do NOT have any active subscription."""
from datetime import datetime, timezone
@@ -919,6 +946,20 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
return result.scalars().all()
async def count_users_without_active_subscription_for_broadcast(session: AsyncSession) -> int:
"""Count non-banned users who do NOT have any active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = select(func.count(User.user_id)).where(
User.is_banned == False,
~_active_subscription_exists_for_user(now),
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who never had any subscription or trial.
@@ -942,6 +983,24 @@ async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[i
return result.scalars().all()
async def count_users_without_any_subscription_for_broadcast(session: AsyncSession) -> int:
"""Count non-banned users who never had any subscription or trial."""
any_sub = aliased(Subscription)
stmt = (
select(func.count(User.user_id))
.outerjoin(any_sub, any_sub.user_id == User.user_id)
.where(
and_(
User.is_banned == False,
any_sub.user_id.is_(None),
)
)
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
def _expired_subscription_exists_for_user(now: datetime):
expired_subs = aliased(Subscription)
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
@@ -988,6 +1047,20 @@ async def count_users_with_expired_subscription(session: AsyncSession) -> int:
return int(result.scalar_one() or 0)
async def count_users_with_expired_subscription_for_broadcast(session: AsyncSession) -> int:
"""Count non-banned users with an expired subscription and no active one."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = select(func.count(User.user_id)).where(
User.is_banned == False,
_expired_subscription_exists_for_user(now),
~_active_subscription_exists_for_user(now),
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs with an expired subscription and no active one."""
from datetime import datetime, timezone
+12
View File
@@ -1155,6 +1155,13 @@ def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -
)
def _migration_0036_add_provider_payment_url(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
if "provider_payment_url" not in columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN provider_payment_url VARCHAR"))
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -1342,6 +1349,11 @@ MIGRATIONS: List[Migration] = [
description="Suppress multi-day expiry reminders for trial and bonus subscriptions",
upgrade=_migration_0035_add_subscription_promo_expiry_flag,
),
Migration(
id="0036_add_provider_payment_url",
description="Persist provider payment links for reusable pending payments",
upgrade=_migration_0036_add_provider_payment_url,
),
]
+1
View File
@@ -203,6 +203,7 @@ class Payment(Base):
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
yookassa_payment_id = Column(String, unique=True, index=True, nullable=True)
provider_payment_id = Column(String, unique=True, nullable=True)
provider_payment_url = Column(String, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False)
+46 -69
View File
@@ -1,11 +1,12 @@
# Resolve the application version from .git at build time and emit a tiny
# .build-version file. The .git tree is consumed in this throwaway stage and
# never copied into the runtime image; only the tag + commit version string
# ships. Non-main builds include the branch name so they are visibly distinct
# from release builds. This matches the runtime fallback chain in _resolve_app_version
# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
# "dev+unknown") so the admin sidebar always shows a tag / sha even though the
# runtime images have no git tooling and no .git tree.
# never copied into the runtime image; only the tag + commit version string and
# a low-cardinality build provenance marker ship. Non-main builds include the
# branch name so they are visibly distinct from release builds. This matches the
# runtime fallback chain in _resolve_app_version (REMNAWAVE_MINISHOP_VERSION env
# > .build-version file > live git > "dev+unknown") so the admin sidebar always
# shows a tag / sha even though the runtime images have no git tooling and no
# .git tree.
FROM alpine:3.20 AS version-builder
RUN apk add --no-cache git
WORKDIR /repo
@@ -14,6 +15,7 @@ ARG GIT_BRANCH=""
ARG BRANCH_NAME=""
ARG GITHUB_REF_NAME=""
ARG CI_COMMIT_REF_NAME=""
ARG REMNAWAVE_MINISHOP_BUILD_PROVENANCE="custom"
COPY .git ./.git
RUN set -eu; \
git config --global --add safe.directory /repo; \
@@ -40,9 +42,17 @@ RUN set -eu; \
else \
version="dev${branch_suffix}+unknown"; \
fi; \
provenance=$(printf '%s' "$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" | tr '[:upper:]' '[:lower:]'); \
case "$provenance" in \
official|custom|unknown) ;; \
true|1|yes|upstream|release) provenance="official" ;; \
false|0|no|fork|modified|local|"") provenance="custom" ;; \
*) provenance="custom" ;; \
esac; \
printf '%s' "$version" > /build-version; \
printf '%s' "${tag:-unknown}" > /build-tag; \
printf '%s' "${sha:-unknown}" > /build-commit
printf '%s' "${sha:-unknown}" > /build-commit; \
printf '%s' "$provenance" > /build-provenance
FROM python:3.12-slim AS python-base
@@ -82,12 +92,40 @@ RUN mkdir -p /app/logs /app/data \
COPY --from=version-builder /build-version /app/.build-version
COPY --from=version-builder /build-tag /app/.build-tag
COPY --from=version-builder /build-commit /app/.build-commit
COPY --from=version-builder /build-provenance /app/.build-provenance
USER appuser
FROM node:22-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./
RUN mkdir -p ../backend/bot/app/web/templates
RUN npm run build:webapp
COPY backend/bot/app/web/templates/subscription_webapp.html ../backend/bot/app/web/templates/subscription_webapp.html
RUN node ./scripts/prepare_nginx_assets.mjs --out ../frontend-nginx-dist
FROM python-base AS backend
# The Mini App shell is rendered by the backend, which rewrites the stylesheet
# and script tags to the content-hashed asset names (subscription_webapp.<hash>.css).
# Those hashed files are build artifacts and gitignored, so a clean checkout has
# none of them: without this copy the asset resolver falls back to the bare
# /subscription_webapp.css URL. That URL never changes between deploys and is
# served no-store, which iOS WebViews (WKWebView) cache aggressively and refuse to
# revalidate -> stale CSS and a broken-looking Mini App on iOS only. Pull the
# freshly built assets (the same hashes the nginx image serves) into the image so
# the shell emits immutable, cache-busting URLs.
COPY --from=frontend-builder /app/backend/bot/app/web/templates/ ./backend/bot/app/web/templates/
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
org.opencontainers.image.title="remnawave-minishop-backend" \
org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \
@@ -107,20 +145,6 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
CMD ["python", "backend/main_worker.py"]
FROM node:22-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./
RUN mkdir -p ../backend/bot/app/web/templates
RUN npm run build:webapp
FROM nginx:1.27-alpine AS frontend
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
@@ -131,57 +155,10 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css.gz /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.css /usr/share/nginx/html/subscription_webapp_admin.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js.gz /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.js /usr/share/nginx/html/subscription_webapp_admin.js
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz /usr/share/nginx/html/
RUN set -eu; \
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
JS_NAME="${HASHED:-subscription_webapp.js}"; \
CSS_NAME="subscription_webapp.css"; \
ADMIN_HASHED=$(ls /usr/share/nginx/html/subscription_webapp_admin.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
ADMIN_CSS_NAME="subscription_webapp_admin.css"; \
for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
name="$(basename "$candidate")"; \
case "$name" in \
subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \
esac; \
done; \
for candidate in /usr/share/nginx/html/subscription_webapp_admin.*.css; do \
name="$(basename "$candidate")"; \
case "$name" in \
subscription_webapp_admin.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) ADMIN_CSS_NAME="$name" ;; \
esac; \
done; \
if [ -n "$ADMIN_HASHED" ]; then \
cp "/usr/share/nginx/html/${ADMIN_HASHED}" /usr/share/nginx/html/subscription_webapp_admin.js; \
if [ -f "/usr/share/nginx/html/${ADMIN_HASHED}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_HASHED}.gz" /usr/share/nginx/html/subscription_webapp_admin.js.gz; fi; \
fi; \
if [ "$ADMIN_CSS_NAME" != "subscription_webapp_admin.css" ]; then \
cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}" /usr/share/nginx/html/subscription_webapp_admin.css; \
if [ -f "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" /usr/share/nginx/html/subscription_webapp_admin.css.gz; fi; \
fi; \
sed -i \
-e '/WEBAPP_I18N_SCRIPT/d' \
-e '/WEBAPP_CONFIG_SCRIPT/d' \
-e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \
-e "/WEBAPP_JS_SCRIPT/c\\ <script src=\"/${JS_NAME}\" type=\"module\"></script>" \
-e '/WEBAPP_DEV_MOCK_START/d' \
-e '/WEBAPP_DEV_MOCK_END/d' \
-e '/subscription_webapp.js" defer/d' \
/usr/share/nginx/html/index.html; \
chmod +x /docker-entrypoint.d/00-startup-banner.sh
COPY --from=frontend-builder /app/frontend-nginx-dist/ /usr/share/nginx/html/
COPY --from=version-builder /build-tag /build-tag
COPY --from=version-builder /build-commit /build-commit
+3
View File
@@ -2,11 +2,14 @@
app.example.com {
encode zstd gzip
# Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default.
# The backend uses X-Forwarded-For for payment provider IP allowlists.
reverse_proxy backend:8080
}
web.example.com {
encode zstd gzip
# Keep forwarded headers for Web App URL generation and request logging.
reverse_proxy frontend:80
}
+15 -2
View File
@@ -93,6 +93,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ "^/(?:$|login/password$|home$|install$|trial$|s/[a-f0-9]{32}$|invite$|devices$|settings$|support(?:/[0-9]+)?$|admin(?:/.*)?$)" {
proxy_pass http://backend:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
expires off;
add_header Cache-Control "public, max-age=31536000, immutable";
@@ -102,7 +111,9 @@ server {
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
expires off;
add_header Cache-Control "no-cache";
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri =404;
}
@@ -115,7 +126,9 @@ server {
}
location / {
add_header Cache-Control "no-cache";
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
add_header X-Robots-Tag "noindex, nofollow, noarchive";
try_files $uri /index.html;
}
@@ -72,6 +72,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Required for payment provider IP allowlists in webhook handlers.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
+1 -1
View File
@@ -4,7 +4,7 @@
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml` в `migrate`, `backend` и `worker`. Для кастомных тем создайте `data/themes`; для ручного каталога тарифов используйте `data/tariffs.json`.
| Папка | Документация |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+2 -2
View File
@@ -36,5 +36,5 @@ PANEL_API_URL=https://panel.example.com/api
PANEL_API_KEY=change_me
PANEL_WEBHOOK_SECRET=change_me
# Caddy and Docker network ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
# Caddy and private Docker/LAN ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
+3
View File
@@ -8,12 +8,15 @@
{$WEBHOOK_HOST} {
encode zstd gzip
# Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default.
# The backend uses X-Forwarded-For for payment provider IP allowlists.
reverse_proxy backend:8080
}
{$MINIAPP_HOST} {
encode zstd gzip
# Keep forwarded headers for Web App URL generation and request logging.
reverse_proxy frontend:80
}
+1 -1
View File
@@ -64,7 +64,7 @@ services:
environment:
<<: *app_environment
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
TRUSTED_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}
volumes:
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
+2 -2
View File
@@ -1,6 +1,6 @@
services:
mailserver:
image: ghcr.io/docker-mailserver/docker-mailserver:latest
image: mailserver/docker-mailserver:latest
container_name: mailserver
hostname: mail.example.com # Замените на ваш реальный домен (оставить поддомен mail)
domainname: example.com # Замените на ваш реальный домен
@@ -23,4 +23,4 @@ services:
- ENABLE_AMAVIS=0
- ENABLE_CLAMAV=0
- ENABLE_SPAMASSASSIN=0
restart: always
restart: always
+2 -2
View File
@@ -37,5 +37,5 @@ PANEL_API_URL=https://panel.example.com/api
PANEL_API_KEY=change_me
PANEL_WEBHOOK_SECRET=change_me
# Pangolin/Newt and Docker network ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
# Pangolin/Newt and private Docker/LAN ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
+1 -1
View File
@@ -64,7 +64,7 @@ services:
environment:
<<: *app_environment
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
TRUSTED_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}
volumes:
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
+2 -2
View File
@@ -36,5 +36,5 @@ PANEL_API_URL=https://panel.example.com/api
PANEL_API_KEY=change_me
PANEL_WEBHOOK_SECRET=change_me
# Nginx and Docker network ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12
# Nginx and private Docker/LAN ranges that may set X-Forwarded-For.
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
+1 -1
View File
@@ -64,7 +64,7 @@ services:
environment:
<<: *app_environment
WEBAPP_ENABLED: ${WEBAPP_ENABLED:-true}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,::1,172.16.0.0/12}
TRUSTED_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}
volumes:
- ./data:/app/data
- ${COMPOSE_BACKUP_SOURCE:-.}:/app/compose-source:${COMPOSE_RESTORE_MODE:-rw}
@@ -31,6 +31,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Required for payment provider IP allowlists in webhook handlers.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
{ label: 'Демо', href: '/demo/home' },
{ label: 'Установка', href: '/getting-started/setup/' },
{ label: 'GitHub', href: 'https://github.com/3252a8/remnawave-minishop' },
{ label: 'GitLab', href: 'https://gitlab.com/3252a8/remnawave-minishop' },
{ label: 'Telegram', href: 'https://t.me/remnawave_minishop' }
],
}),
+3
View File
@@ -46,6 +46,9 @@ function navIcon(href: string) {
if (href.includes('github.com/3252a8/remnawave-minishop')) {
return 'github'
}
if (href.includes('gitlab.com/3252a8/remnawave-minishop')) {
return 'gitlab'
}
if (href.includes('t.me/remnawave_minishop')) {
return 'telegram'
}
+12 -1
View File
@@ -41,10 +41,19 @@
| `DB_POOL_RECYCLE_SECONDS` | `.env` | Период recycling DB-соединений. |
| `REDIS_URL` | Compose | Redis для FSM, кеша, rate-limit, очередей и locks. В Compose задается автоматически. |
| `REDIS_KEY_PREFIX` | `.env` | Префикс Redis-ключей. |
| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. |
| `TRUSTED_PROXIES` | `.env` | IP/CIDR обратных прокси, которым доверяется `X-Forwarded-For`. По умолчанию включает loopback и private ranges для Docker/LAN/Kubernetes proxy. |
| `HTTP_BIND` / `HTTPS_BIND` | Caddy Compose | Адреса публикации Caddy-варианта. |
| `NEWT_ID` / `NEWT_SECRET` | Dev Compose | Доступы Newt в dev-compose. |
`TRUSTED_PROXIES` нужен не только для логов: платежные webhook-обработчики с IP-фильтром
сравнивают allowlist провайдера с client IP после обработки `X-Forwarded-For`. Если внешний
proxy не передает этот заголовок или его IP не входит в `TRUSTED_PROXIES`, backend увидит IP
proxy/Docker gateway и может отклонить валидный webhook. Для Caddy/Nginx/Newt из
`deploy/examples` дефолта достаточно; в кастомной инфраструктуре добавьте CIDR своего proxy
или сузьте значение до конкретных proxy IP. Trust-all вариант записывается как
`0.0.0.0/0,::/0`, но он безопасен только если backend не доступен напрямую, а внешний proxy
очищает входящий `X-Forwarded-For`.
## Кеши, rate limits и worker
Обычно эти значения не требуют правки.
@@ -66,6 +75,7 @@
| `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. |
| `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. |
| `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. |
| `ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS` | TTL счетчиков целевых групп рассылки в админке. |
| `PROFILE_SYNC_CACHE_TTL_SECONDS` | Минимальная пауза между sync Telegram-профиля пользователя. |
| `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS` | Минимальная пауза записи lifetime-трафика. |
| `PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES` | Дельта lifetime-трафика для более ранней записи. |
@@ -212,6 +222,7 @@
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. |
| `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. |
| `PAYMENT_<METHOD>_WEBAPP_ICON` | Lucide-иконка кнопки в Web App. |
| `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU` / `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` | Текст кнопки в Telegram. |
+8
View File
@@ -55,6 +55,14 @@ openssl rand -hex 32
- `SUBSCRIPTION_MINI_APP_URL` должен вести на frontend/Mini App-домен.
- Не добавляйте `/api`, `/auth` или webhook-пути в `SUBSCRIPTION_MINI_APP_URL`.
## IP allowlist вебхуков
- Reverse proxy для `WEBHOOK_BASE_URL` должен передавать `X-Forwarded-For` с реальным IP отправителя.
- `TRUSTED_PROXIES` должен включать IP/CIDR последнего proxy-hop до backend. Иначе платежные webhook-обработчики будут проверять allowlist по IP proxy или Docker gateway.
- В Docker Compose профилях Caddy, Nginx и Pangolin/Newt дефолт покрывает loopback и private ranges: `127.0.0.1`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`.
- Если backend находится в общей Docker-сети с недоверенными контейнерами, сузьте `TRUSTED_PROXIES` до конкретных IP ваших reverse proxy.
- Если вы сознательно хотите доверять любому proxy-hop, используйте `0.0.0.0/0,::/0`, но только когда backend не опубликован напрямую, а внешний proxy очищает входящий `X-Forwarded-For`.
## Дополнительно
- Используйте HTTPS на всех публичных доменах.
+3 -1
View File
@@ -11,6 +11,8 @@
| `installation_id` | `f47ac10b-...` (UUIDv4) | Случайный идентификатор установки. Генерируется один раз, хранится в БД. Не выводится из токена, домена или ID администраторов. |
| `app_version` | `v3.4.6+gabc1234` | Полная версия сборки. |
| `app_version_tag` | `v3.4.6` | Релизный тег для разбивки по версиям. |
| `build_provenance` | `official` / `custom` | Маркер происхождения образа. `official` ставится только официальной сборкой проекта; локальные сборки и форки считаются `custom`. |
| `image_modified` | `false` / `true` | Удобный булевый флаг для разбивки: `false` только у официальных образов, `true` у локальных/форк-сборок. |
| `os` / `arch` | `linux` / `x86_64` | Платформа. |
| `python_version` | `3.12.7` | Версия рантайма. |
| `locale` | `ru` | Язык по умолчанию. |
@@ -22,7 +24,7 @@
## Чего там нет
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент.
Никогда не отправляются: токен бота, домены, URL вебхуков, ключи платёжных систем и Remnawave, ID или данные пользователей, точное число пользователей, какой-либо контент, registry образа или URL форка.
## Как выключить
+6
View File
@@ -28,6 +28,12 @@
Все платежные webhook URL строятся от `WEBHOOK_BASE_URL` - публичного HTTPS-адреса backend/webhook-домена. Это должен быть домен, который проксируется на backend-сервер вебхуков (`backend:8080`), а не `SUBSCRIPTION_MINI_APP_URL` frontend/Mini App. Если `WEBHOOK_BASE_URL=https://bot.example.com`, то полный адрес получается как `https://bot.example.com` + путь из таблицы.
Если у провайдера включена IP-фильтрация (`FREEKASSA_TRUSTED_IPS`, `WATA_TRUSTED_IPS`,
`HELEKET_TRUSTED_IPS`, `PAYKILLA_TRUSTED_IPS` или встроенный allowlist YooKassa),
reverse proxy должен прокидывать `X-Forwarded-For`, а его IP/CIDR должен входить в
`TRUSTED_PROXIES`. Иначе backend увидит IP proxy/Docker gateway и может отклонить
валидный webhook с ошибкой `403`.
| Провайдер | Что указать в кабинете провайдера | Комментарий |
| --- | --- | --- |
| YooKassa | `WEBHOOK_BASE_URL` + `/webhook/yookassa` | Например `https://bot.example.com/webhook/yookassa`. |
+2
View File
@@ -7,6 +7,8 @@
JSON-каталог может содержать несколько тарифов разных моделей: подписки на срок, пакеты трафика без срока действия, разные наборы Internal Squads, лимиты устройств и пакеты докупки. Пример формата: [data/tariffs.example.json](https://github.com/3252a8/remnawave-minishop/blob/main/data/tariffs.example.json).
Важно для Docker: `TARIFFS_CONFIG_PATH` обычно указывает на файл внутри `/app/data`, и этот каталог должен быть доступен `migrate`, `backend` и `worker`. Мигратор тоже читает каталог тарифов, когда привязывает существующие активные подписки без `tariff_key` к тарифу по умолчанию и заполняет тарифные лимиты. В штатных compose-файлах это уже настроено: корневой compose использует `shop-data:/app/data`, а production-примеры - `./data:/app/data` для всех трех сервисов.
Коротко по моделям:
- `period` - подписка на срок с месячным лимитом трафика и опциональной докупкой GB поверх месячного лимита;
+3
View File
@@ -84,6 +84,8 @@ openssl rand -hex 32
В compose-примерах данные монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml`. Внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
Папка `./data` монтируется в `/app/data` для `migrate`, `backend` и `worker`. Если вы меняете compose-файл вручную, не убирайте этот mount у `migrate`: иначе `docker compose run --rm migrate` не увидит `data/tariffs.json` из `TARIFFS_CONFIG_PATH`.
Перед первым запуском создайте каталоги и отдайте их пользователю контейнера:
```bash
@@ -91,6 +93,7 @@ mkdir -p data/themes data/webapp-logo data/tariffs
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
docker compose run --rm migrate
docker compose up -d --force-recreate backend worker
```
+22 -2
View File
@@ -22,6 +22,13 @@ curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scri
sh install.sh
```
Та же ссылка на install-скрипт в GitLab:
```bash
curl -fsSL https://gitlab.com/3252a8/remnawave-minishop/-/raw/main/scripts/install.sh -o install.sh
sh install.sh
```
Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет:
- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`);
@@ -196,7 +203,7 @@ curl http://127.0.0.1:8082/health
docker compose logs -f backend worker frontend
```
Корневой `docker-compose.yml` оставлен для локальной сборки из исходников. Примеры в `deploy/examples` используют готовые GHCR-образы и не требуют указывать `-f`.
Корневой `docker-compose.yml` оставлен для локальной сборки из исходников. Примеры в `deploy/examples` используют готовые Docker Hub-образы и не требуют указывать `-f`.
## Миграции
@@ -310,7 +317,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\docker-build-push-images.ps1
Если нужен только один registry или другой namespace, переопределите переменные:
```bash
IMAGE_REGISTRIES=ghcr.io IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
IMAGE_REGISTRIES=docker.io IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
IMAGE_REGISTRIES="ghcr.io docker.io" IMAGE_NAMESPACE=other IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
```
@@ -374,6 +381,8 @@ distributed lock; код подготовлен к нескольким репл
Файлы приложения монтируются из локальной папки `./data` рядом с выбранным `docker-compose.yml` в
`/app/data`; внутри нее лежат тарифы, темы, логотипы и прочие файловые данные приложения.
Тот же `/app/data` должен быть смонтирован в `migrate`, `backend` и `worker`. Это важно для `data/tariffs.json`: `docker compose run --rm migrate` читает тот же каталог тарифов, что и приложение. В текущих compose-файлах этот mount уже есть у всех трех сервисов.
Перед первым запуском на сервере заранее дайте права пользователю контейнера `10001`:
```bash
@@ -381,6 +390,7 @@ mkdir -p data/themes data/webapp-logo data/tariffs
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
docker compose run --rm migrate
docker compose up -d --force-recreate backend worker
```
@@ -423,6 +433,16 @@ docker compose up -d backend worker
- Mini App/frontend-домен целиком идет в `frontend:80`;
- API/auth/theme routes Mini App дальше проксируются frontend nginx в `backend:8081`.
Для платежных провайдеров с IP allowlist важно, чтобы reverse proxy передавал реальный IP
отправителя в `X-Forwarded-For`, а backend доверял IP последнего proxy-hop через
`TRUSTED_PROXIES`. Готовые профили `caddy`, `nginx` и `newt` уже доверяют loopback и
private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), чтобы
Docker/LAN/Kubernetes proxy не ломал проверки `YOOKASSA`, `FREEKASSA_TRUSTED_IPS`,
`WATA_TRUSTED_IPS`, `HELEKET_TRUSTED_IPS` и `PAYKILLA_TRUSTED_IPS`. Если в вашей
Docker-сети есть недоверенные контейнеры, сузьте `TRUSTED_PROXIES` до конкретного IP
Caddy/Nginx/Newt. Trust-all режим возможен через `0.0.0.0/0,::/0`, но используйте его
только когда backend недоступен напрямую, а внешний proxy очищает входящий `X-Forwarded-For`.
Минимальная логика Caddy:
```caddyfile
+7
View File
@@ -8,6 +8,13 @@ curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scri
sh install.sh
```
Та же ссылка на install-скрипт в GitLab:
```bash
curl -fsSL https://gitlab.com/3252a8/remnawave-minishop/-/raw/main/scripts/install.sh -o install.sh
sh install.sh
```
В меню выберите `Install new stack and run migration` для нового сервера
или `Run migration only`, если compose-папка и `.env` уже готовы.
+7
View File
@@ -7,6 +7,13 @@ curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scri
sh install.sh
```
Та же ссылка на install-скрипт в GitLab:
```bash
curl -fsSL https://gitlab.com/3252a8/remnawave-minishop/-/raw/main/scripts/install.sh -o install.sh
sh install.sh
```
В меню выберите `Install new stack and run migration` для нового
сервера или `Run migration only`, если compose-папка уже готова. Затем
выберите источник `Old remnawave-tg-shop`.
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
import {
copyFile,
lstat,
mkdir,
readdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates");
const DEFAULT_OUT_DIR = path.join(repoRoot, "frontend-nginx-dist");
const outDir = resolveOutDir();
function resolveOutDir() {
const argIndex = process.argv.indexOf("--out");
if (argIndex === -1) {
return DEFAULT_OUT_DIR;
}
const rawValue = process.argv[argIndex + 1];
if (!rawValue) {
throw new Error("--out requires a directory path");
}
return path.resolve(process.cwd(), rawValue);
}
async function pathExists(filePath) {
try {
await lstat(filePath);
return true;
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
}
async function copyIfExists(sourceName, targetName = sourceName) {
const sourcePath = path.join(templatesDir, sourceName);
if (!(await pathExists(sourcePath))) return false;
await copyFile(sourcePath, path.join(outDir, targetName));
return true;
}
async function linkOrCopy(targetName, aliasName) {
const aliasPath = path.join(outDir, aliasName);
try {
await symlink(targetName, aliasPath);
} catch (error) {
if (!["EPERM", "EINVAL", "ENOSYS"].includes(error?.code)) {
throw error;
}
await copyFile(path.join(outDir, targetName), aliasPath);
}
}
function latestMatching(entries, pattern, fallbackName) {
const matches = entries.filter((name) => pattern.test(name)).sort();
return matches.at(-1) || fallbackName;
}
async function copyRuntimeAsset({ hashedName, stableName }) {
const copied = await copyIfExists(hashedName);
if (copied && hashedName !== stableName) {
await linkOrCopy(hashedName, stableName);
} else if (!copied) {
await copyIfExists(stableName);
}
const gzipName = `${hashedName}.gz`;
const gzipCopied = await copyIfExists(gzipName);
if (gzipCopied && hashedName !== stableName) {
await linkOrCopy(gzipName, `${stableName}.gz`);
}
}
function prepareIndexHtml(rawHtml, { cssName, jsName }) {
const html = rawHtml
.replace(/\r\n/g, "\n")
.replace('href="/subscription_webapp.css"', `href="/${cssName}"`);
const lines = html.split("\n");
const output = lines
.map((line) =>
line.includes("WEBAPP_JS_SCRIPT")
? ` <script src="/${jsName}" type="module"></script>`
: line
)
.filter(
(line) =>
!line.includes("WEBAPP_I18N_SCRIPT") &&
!line.includes("WEBAPP_CONFIG_SCRIPT") &&
!line.includes("WEBAPP_DEV_MOCK_START") &&
!line.includes("WEBAPP_DEV_MOCK_END") &&
!line.includes('subscription_webapp.js" defer')
)
.join("\n");
return output.endsWith("\n") ? output : `${output}\n`;
}
async function main() {
const entries = await readdir(templatesDir);
const mainJsName = latestMatching(
entries,
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
"subscription_webapp.js"
);
const mainCssName = latestMatching(
entries,
/^subscription_webapp\.[0-9a-f]{8}\.css$/,
"subscription_webapp.css"
);
const adminJsName = latestMatching(
entries,
/^subscription_webapp_admin\.min\.[0-9a-f]{8}\.js$/,
"subscription_webapp_admin.js"
);
const adminCssName = latestMatching(
entries,
/^subscription_webapp_admin\.[0-9a-f]{8}\.css$/,
"subscription_webapp_admin.css"
);
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
await Promise.all([
copyRuntimeAsset({ hashedName: mainJsName, stableName: "subscription_webapp.js" }),
copyRuntimeAsset({ hashedName: mainCssName, stableName: "subscription_webapp.css" }),
copyRuntimeAsset({ hashedName: adminJsName, stableName: "subscription_webapp_admin.js" }),
copyRuntimeAsset({ hashedName: adminCssName, stableName: "subscription_webapp_admin.css" }),
]);
const indexTemplate = await readFile(path.join(templatesDir, "subscription_webapp.html"), "utf8");
await writeFile(
path.join(outDir, "index.html"),
prepareIndexHtml(indexTemplate, { cssName: mainCssName, jsName: mainJsName }),
"utf8"
);
console.log(
`Prepared nginx assets in ${path.relative(repoRoot, outDir)}: ${mainJsName}, ${mainCssName}, ${adminJsName}, ${adminCssName}`
);
}
await main();
+25 -8
View File
@@ -111,9 +111,10 @@
export let mockRuntime = null;
const FALLBACK_BRAND_TITLE = "Subscription";
const EMPTY_MOCK = {
config: {
title: "/minishop",
title: FALLBACK_BRAND_TITLE,
primaryColor: "#00fe7a",
apiBase: "/api",
language: "ru",
@@ -362,7 +363,7 @@
languageBusy,
} = $accountStore);
$: brandTitle = CFG.title || "/minishop";
$: brandTitle = CFG.title || FALLBACK_BRAND_TITLE;
$: brand = normalizeBrand({
title: brandTitle,
logoUrl: CFG.logoUrl,
@@ -374,6 +375,9 @@
$: plans = data?.plans?.length ? data.plans : MOCK_SOURCE.data.plans;
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
$: appSettings = data?.settings || MOCK_SOURCE.data.settings;
$: rawEmailAuthEnabled =
data?.settings?.email_auth_enabled ?? appSettings?.email_auth_enabled ?? CFG.emailAuthEnabled;
$: emailAuthEnabled = rawEmailAuthEnabled !== false && rawEmailAuthEnabled !== "false";
$: subscriptionPurchaseDescription = String(
appSettings?.subscription_purchase_description || ""
).trim();
@@ -488,7 +492,7 @@
);
$: telegramNotificationsStartLink = String(user?.telegram_notifications_start_link || "");
$: hasUnlinkedIdentity =
!user?.telegram_linked || !user?.email || telegramNotificationsNeedPrompt;
!user?.telegram_linked || (emailAuthEnabled && !user?.email) || telegramNotificationsNeedPrompt;
$: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
$: referralWelcomeBonusDays = Math.max(0, Number(referral?.welcome_bonus_days || 0));
$: referralOneBonusPerReferee = Boolean(referral?.one_bonus_per_referee);
@@ -532,9 +536,15 @@
changeConfirmOpen ||
topupModalOpen ||
deviceTopupModalOpen ||
linkEmailOpen ||
setPasswordOpen
(emailAuthEnabled && linkEmailOpen) ||
(emailAuthEnabled && setPasswordOpen)
);
$: if (!emailAuthEnabled && linkEmailOpen) {
accountStore.closeLinkEmailDialog();
}
$: if (!emailAuthEnabled && setPasswordOpen) {
accountStore.closeSetPasswordDialog();
}
$: if (!tariffMode && !$billingStore.selectedPlan && plans.length) {
billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] }));
}
@@ -1162,10 +1172,16 @@
}
function openSettingsLinkEmailDialog() {
if (!emailAuthEnabled) return;
const authDemo = MOCK_SOURCE.data?.auth_demo || {};
accountStore.openLinkEmailDialog(demoAuthLogin ? authDemo.email || "3252a8@proton.me" : "");
}
function openSettingsSetPasswordDialog() {
if (!emailAuthEnabled) return;
accountStore.openSetPasswordDialog();
}
async function linkTelegramFromSettings() {
if (!demoAuthLogin) {
await accountStore.linkTelegramAccount(() => telegramMiniAppInitData);
@@ -2596,6 +2612,7 @@
<SettingsScreen
{currentLang}
{currentLanguageOption}
{emailAuthEnabled}
{emailLinkStatus}
{isAdmin}
{languageBusy}
@@ -2624,7 +2641,7 @@
{openAdminPanel}
{openExternalLink}
openLinkEmailDialog={openSettingsLinkEmailDialog}
openSetPasswordDialog={accountStore.openSetPasswordDialog}
openSetPasswordDialog={openSettingsSetPasswordDialog}
{setLanguageMenuOpen}
{t}
updateAccountLanguage={accountStore.updateAccountLanguage}
@@ -2653,13 +2670,13 @@
{disconnectDevice}
{linkEmailBusy}
{linkEmailIsError}
{linkEmailOpen}
linkEmailOpen={emailAuthEnabled && linkEmailOpen}
{linkEmailPending}
{linkEmailResendCooldown}
{linkEmailStatus}
{setPasswordBusy}
{setPasswordIsError}
{setPasswordOpen}
setPasswordOpen={emailAuthEnabled && setPasswordOpen}
{setPasswordPending}
{setPasswordResendCooldown}
{setPasswordStatus}
+1 -1
View File
@@ -30,7 +30,7 @@
export let config = {};
export let mockData = {};
const title = config.title || "/minishop";
const title = config.title || "Subscription";
const logoUrl = config.logoUrl || "/webapp-default-logo.webp";
const plans = mockData.plans || [];
const sub = mockData.subscription || {};
+44 -9
View File
@@ -45,9 +45,11 @@
import AppearanceSection from "./sections/AppearanceSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import ConfigAlertsBanner from "./ConfigAlertsBanner.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
import { createBackupsStore } from "../lib/admin/stores/backupsStore.js";
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
import { createHealthStore } from "../lib/admin/stores/healthStore.js";
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
@@ -93,7 +95,7 @@
export let onTranslationsSaved = () => {};
export let routePrefix = "";
export let brand = {};
export let brandTitle = "/minishop";
export let brandTitle = "Subscription";
export let appFaviconUrl = "";
export let appFaviconUseCustom = false;
export let appVersion = "dev+local";
@@ -223,6 +225,8 @@
}
let sidebarOpen = false;
let isCompact = false;
let dismissedUserRouteKey = "";
let lastUserRouteKey = "";
let adminLanguageMenuOpen = false;
let adminLanguageClickGuard = false;
let adminLanguageClickGuardArmed = false;
@@ -245,6 +249,7 @@
const adsStore = createAdsStore({ api, onToast: flash, at });
const backupsStore = createBackupsStore({ api, onToast: flash, at });
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
const healthStore = createHealthStore({ api });
const logsStore = createLogsStore({ api, at });
const paymentsStore = createPaymentsStore({ api, onToast: flash, at, routePrefix });
const promosStore = createPromosStore({ api, onToast: flash, at });
@@ -258,6 +263,7 @@
setContext("promosStore", promosStore);
setContext("adsStore", adsStore);
setContext("healthStore", healthStore);
setContext("backupsStore", backupsStore);
setContext("broadcastStore", broadcastStore);
setContext("logsStore", logsStore);
@@ -379,6 +385,7 @@
const uid = Number(userId);
// Synthetic email-only users use negative user_id; still a valid admin target.
if (!Number.isFinite(uid) || uid === 0) return;
dismissedUserRouteKey = "";
const next = normalizeSection("payments");
sidebarOpen = false;
if (active !== next) {
@@ -395,6 +402,7 @@
function openLogsUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
dismissedUserRouteKey = "";
const next = normalizeSection("logs");
sidebarOpen = false;
if (active !== next) {
@@ -410,16 +418,24 @@
function openUserCard(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid === 0) return;
const next = normalizeSection("users");
dismissedUserRouteKey = "";
sidebarOpen = false;
if (active !== next) {
active = next;
paymentsStore.closePayment({ skipPush: true });
supportStore.closeTicketView({ skipPush: true });
onSectionChange(next, uid);
usersStore.setActive(active);
usersStore.openUser(uid, { skipPush: true, pathContext: active });
}
function userRouteKey(section = active) {
if (section === "users" && initialUserId) return `users:${initialUserId}`;
if (section === "payments" && initialPaymentUserId) return `payments:${initialPaymentUserId}`;
return "";
}
function closeUserCard() {
dismissedUserRouteKey = userRouteKey();
usersStore.closeUser({ skipPush: true });
if (active === "users" || active === "payments") {
onSectionChange(active, 0);
}
usersStore.setActive(next);
usersStore.openUser(uid);
}
function resolvedAvatarUrl(user) {
@@ -518,6 +534,12 @@
if (typeof window !== "undefined") {
window.addEventListener("popstate", onPopState);
}
void broadcastStore.loadCounts();
void healthStore.loadHealth();
const healthTimer =
typeof window !== "undefined"
? window.setInterval(() => void healthStore.loadHealth(), 5 * 60 * 1000)
: null;
return () => {
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
if (compactMql) {
@@ -526,6 +548,7 @@
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
}
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
if (healthTimer !== null) window.clearInterval(healthTimer);
clearAdminLanguageClickGuard();
};
});
@@ -533,9 +556,18 @@
$: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 };
$: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 };
$: {
const currentUserRouteKey = userRouteKey();
if (currentUserRouteKey !== lastUserRouteKey) {
if (currentUserRouteKey !== dismissedUserRouteKey) dismissedUserRouteKey = "";
lastUserRouteKey = currentUserRouteKey;
}
}
$: if (
active === "users" &&
initialUserId &&
dismissedUserRouteKey !== `users:${initialUserId}` &&
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)
) {
usersStore.openUser(initialUserId, { skipPush: true });
@@ -552,6 +584,7 @@
$: if (
active === "payments" &&
initialPaymentUserId &&
dismissedUserRouteKey !== `payments:${initialPaymentUserId}` &&
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialPaymentUserId)
) {
usersStore.openUser(initialPaymentUserId, { skipPush: true, pathContext: "payments" });
@@ -779,6 +812,7 @@
</header>
<main class="admin-main">
<ConfigAlertsBanner {at} section={active} onNavigate={setActive} />
{#key active}
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
{#if active === "stats"}
@@ -892,4 +926,5 @@
{trafficPercentValue}
{trafficLeftLabel}
{trafficOfLabel}
onClose={closeUserCard}
/>
@@ -0,0 +1,127 @@
<script>
import { getContext } from "svelte";
import { RefreshCw, TriangleAlert } from "$components/ui/icons.js";
import { AdminButton } from "$components/patterns/admin/index.js";
export let at = (key, _params = {}, fallback = "") => fallback || key;
export let section = "stats";
export let onNavigate = () => {};
const healthStore = getContext("healthStore");
const MESSAGE_FALLBACKS = {
data_dir_missing:
"Каталог data ({path}) не найден. Проверьте, что том data смонтирован в контейнер.",
data_dir_not_writable:
"Нет прав на запись в {path} — бэкапы, тарифы, логотипы и переводы не сохранятся.",
backups_dir_not_writable: "Каталог бэкапов {path} недоступен для записи.",
tariffs_config_invalid: "Файл тарифов {path} не читается: {error}",
locale_overrides_invalid: "Файл переводов {path} повреждён: {error}",
subscription_page_config_invalid: "Конфиг гайдов подписки не читается: {error}",
provider_not_configured:
"Провайдер {provider} включён, но не настроен — оплата через него не работает.",
provider_webhook_needs_base_url:
"Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
no_payment_methods: "Не включён ни один способ оплаты.",
mini_app_url_missing:
"SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
mini_app_url_not_https:
"SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
redis_not_configured:
"REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
smtp_incomplete: "SMTP настроен не полностью — вход по email работать не будет.",
proxy_not_trusted:
"Запросы приходят через прокси {remote}, которого нет в TRUSTED_PROXIES — вебхуки платёжных провайдеров могут отклоняться по IP.",
bot_token_invalid: "Telegram отверг BOT_TOKEN — бот не работает.",
telegram_api_error: "Не удалось обратиться к Telegram API: {error}",
telegram_webhook_missing: "Вебхук Telegram не установлен — бот не получает обновления.",
telegram_webhook_mismatch: "Вебхук Telegram указывает на {actual}, ожидается {expected}.",
telegram_webhook_error: "Telegram сообщает об ошибке доставки вебхука: {error}",
telegram_webhook_pending: "В очереди Telegram скопилось {count} необработанных обновлений.",
panel_api_not_configured:
"PANEL_API_URL и PANEL_API_KEY не заданы — синхронизация и выдача подписок не работают.",
panel_api_unreachable: "Панель Remnawave недоступна по адресу {url}.",
};
const SECTION_FALLBACK_LABELS = {
settings: "Настройки",
payments: "Платежи",
backups: "Бэкапы",
tariffs: "Тарифы",
appearance: "Внешний вид",
translations: "Переводы",
users: "Пользователи",
};
function interpolate(template, params = {}) {
return String(template || "").replace(/\{(\w+)\}/g, (match, key) =>
params[key] !== undefined && params[key] !== null ? String(params[key]) : match
);
}
function alertText(alert) {
const fallback = interpolate(
MESSAGE_FALLBACKS[alert.message_key] || alert.message_key,
alert.params
);
return at(`health_${alert.message_key}`, alert.params || {}, fallback);
}
function sectionLabel(id) {
return at(`nav_${id}`, {}, SECTION_FALLBACK_LABELS[id] || id);
}
$: alerts = $healthStore?.alerts || [];
$: healthLoading = $healthStore?.healthLoading;
$: isDashboard = section === "stats";
$: visibleAlerts = isDashboard
? alerts
: alerts.filter((alert) => (alert.sections || []).includes(section));
$: errorCount = visibleAlerts.filter((alert) => alert.severity === "error").length;
</script>
{#if visibleAlerts.length}
<div
class="admin-config-alerts"
class:admin-config-alerts-error={errorCount > 0}
role="alert"
aria-live="polite"
>
<div class="admin-config-alerts-head">
<span class="admin-config-alerts-title">
<TriangleAlert size={15} />
{at("health_title", {}, "Проблемы конфигурации")}
</span>
{#if isDashboard}
<AdminButton
onclick={() => healthStore.loadHealth({ refresh: true })}
disabled={healthLoading}
>
<RefreshCw size={13} />
{at("health_refresh", {}, "Проверить снова")}
</AdminButton>
{/if}
</div>
<ul class="admin-config-alerts-list">
{#each visibleAlerts as alert (alert.id)}
<li class="admin-config-alert admin-config-alert-{alert.severity}">
<span class="admin-config-alert-dot" aria-hidden="true"></span>
<span class="admin-config-alert-text">{alertText(alert)}</span>
{#if isDashboard && (alert.sections || []).length}
<span class="admin-config-alert-links">
{#each alert.sections as sectionId (sectionId)}
<button
type="button"
class="admin-config-alert-link"
on:click={() => onNavigate(sectionId)}
>
{sectionLabel(sectionId)}
</button>
{/each}
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
@@ -105,6 +105,10 @@
if (index >= 0) backupsPage = Math.floor(index / BACKUPS_PAGE_SIZE);
}
function setBackupsPage(page) {
backupsPage = Math.min(Math.max(0, Number(page) || 0), backupsPageCount - 1);
}
function warningsText(warnings) {
return (warnings || []).filter(Boolean).join("\n");
}
@@ -336,12 +340,16 @@
{#if totalArchives > BACKUPS_PAGE_SIZE}
<AdminPagination
meta={paginationMeta()}
page={backupsPage}
pageCount={backupsPageCount}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("pagination_prev", {}, "Назад")}
nextLabel={at("pagination_next", {}, "Далее")}
prevDisabled={backupsPage <= 0}
nextDisabled={backupsPage >= backupsPageCount - 1}
onPrev={() => (backupsPage = Math.max(0, backupsPage - 1))}
onNext={() => (backupsPage = Math.min(backupsPageCount - 1, backupsPage + 1))}
onPageChange={setBackupsPage}
/>
{/if}
{/if}
@@ -8,15 +8,23 @@
export let at;
const broadcastStore = getContext("broadcastStore");
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult, broadcastCounts } =
$broadcastStore);
$: ({
broadcastTarget,
broadcastText,
broadcastBusy,
broadcastResult,
broadcastCounts,
broadcastCountsLoading,
} = $broadcastStore);
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
// Append the resolved audience size to each option once counts are loaded.
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
const count = broadcastCounts?.[option.value];
return count == null ? option : { ...option, label: `${option.label} (${count})` };
if (count != null) return { ...option, label: `${option.label} (${count})` };
if (broadcastCountsLoading) return { ...option, label: `${option.label} (...)` };
return option;
});
onMount(() => {
+12 -10
View File
@@ -15,10 +15,11 @@
export let onOpenUserCard = () => {};
const logsStore = getContext("logsStore");
const LOGS_PAGE_SIZE = 50;
$: ({ logs, logsTotal, logsPage, logsUserFilter, logsLoading } = $logsStore);
$: logsHasMore = logs.length > 0 && logsTotal > (logsPage + 1) * 50; // 50 is LOGS_PAGE_SIZE
$: logsPageCount = Math.max(1, Math.ceil(Number(logsTotal || 0) / LOGS_PAGE_SIZE));
$: logHeaders = [
at("date", {}, "Дата"),
at("event", {}, "Событие"),
@@ -165,17 +166,18 @@
</div>
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${logsPage + 1}`}
page={logsPage}
pageCount={logsPageCount}
total={logsTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={logsPage === 0}
nextDisabled={!logsHasMore}
onPrev={() => {
logsStore.setPage(Math.max(0, logsPage - 1));
}}
onNext={() => {
logsStore.setPage(logsPage + 1);
}}
onPageChange={(page) => logsStore.setPage(page)}
/>
<style>
@@ -17,10 +17,11 @@
export let onOpenUserCard = () => {};
const paymentsStore = getContext("paymentsStore");
const PAYMENTS_PAGE_SIZE = 25;
$: ({ payments, paymentsTotal, paymentsPage, paymentsLoading } = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
$: paymentsPageCount = Math.max(1, Math.ceil(Number(paymentsTotal || 0) / PAYMENTS_PAGE_SIZE));
/** @param {number|null|undefined} v */
function formatTrafficGbCell(v) {
@@ -177,17 +178,18 @@
</div>
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${paymentsPage + 1} · ${at("total", {}, "Всего")} ${paymentsTotal}`}
page={paymentsPage}
pageCount={paymentsPageCount}
total={paymentsTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={paymentsPage === 0}
nextDisabled={!paymentsHasMore}
onPrev={() => {
paymentsStore.setPage(Math.max(0, paymentsPage - 1));
}}
onNext={() => {
paymentsStore.setPage(paymentsPage + 1);
}}
onPageChange={(page) => paymentsStore.setPage(page)}
/>
<style>
@@ -709,14 +709,6 @@
onValueChange={handleTrialSquadSelect}
/>
{/key}
<Input
class="input"
type="text"
placeholder={valueForKey("USER_SQUAD_UUIDS", settingsDirty, settingsFieldMap) ||
"uuid-a,uuid-b"}
value={valueForKey("TRIAL_SQUAD_UUIDS", settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting("TRIAL_SQUAD_UUIDS", event.currentTarget.value)}
/>
{#if isSettingDirty("TRIAL_SQUAD_UUIDS", settingsDirty)}
<AdminButton
size="sm"
@@ -42,6 +42,7 @@
export let userTelegramProfileLink = () => "";
export let userTelegramProfileLinkKind = () => "";
export let openTelegramProfileLink = () => false;
export let onClose = () => usersStore.closeUser();
let avatarPreviewOpen = false;
let avatarPreviewUrl = "";
@@ -90,6 +91,17 @@
return at("user_hwid_limit_count", { count: base }, `${base}`);
}
function vpnLastConnectionLabel(detail) {
const connectedAt = detail?.last_vpn_connected_at;
const status = detail?.vpn_connection_status;
if (connectedAt) return fmtDate(connectedAt);
if (status === "never") return at("user_vpn_never_connected", {}, "Никогда");
if (status === "connected") {
return at("user_vpn_connected_no_time", {}, "Подключался, время неизвестно");
}
return "—";
}
const usersStore = getContext("usersStore");
$: ({
@@ -118,11 +130,14 @@
userLogsPageSize,
} = $usersStore);
$: userLogsHasMore =
Number(userLogsTotal || 0) > (Number(userLogsPage || 0) + 1) * Number(userLogsPageSize || 20);
$: userReferralsHasMore =
Number(userReferralsTotal || 0) >
(Number(userReferralsPage || 0) + 1) * Number(userReferralsPageSize || 25);
$: userLogsPageCount = Math.max(
1,
Math.ceil(Number(userLogsTotal || 0) / Number(userLogsPageSize || 20))
);
$: userReferralsPageCount = Math.max(
1,
Math.ceil(Number(userReferralsTotal || 0) / Number(userReferralsPageSize || 25))
);
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
$: referralInviter = openedUserDetail?.referral?.inviter || null;
@@ -184,7 +199,7 @@
: ""}
description={openedUser?.username ? "@" + openedUser.username : ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUser}
onclose={onClose}
class="admin-dialog admin-user-dialog"
>
{#if openedUser}
@@ -274,6 +289,10 @@
>{fmtDate(openedUser.registration_date)}</strong
>
</li>
<li>
<span>{at("user_label_vpn_last_connected", {}, "Последнее VPN-подключение")}</span
><strong>{vpnLastConnectionLabel(openedUserDetail)}</strong>
</li>
<li>
<span>{at("user_label_ref_code", {}, "Реф. код")}</span><strong
>{openedUserDetail.referral?.code ||
@@ -723,13 +742,19 @@
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
<AdminPagination
meta={`${at("page_short", {}, "Стр.")} ${userLogsPage + 1}`}
page={userLogsPage}
pageCount={userLogsPageCount}
total={userLogsTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={userLogsPage === 0 || userLogsLoading}
nextDisabled={!userLogsHasMore || userLogsLoading}
onPrev={() => usersStore.setUserLogsPage(Math.max(0, userLogsPage - 1))}
onNext={() => usersStore.setUserLogsPage(userLogsPage + 1)}
disabled={userLogsLoading}
onPageChange={(page) => usersStore.setUserLogsPage(page)}
/>
{/if}
</Tabs.Content>
@@ -1229,20 +1254,19 @@
{#if userReferralsTotal > userReferralsPageSize}
<AdminPagination
meta={at(
"pagination_meta",
{
current: userReferralsPage + 1,
total: Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize)),
},
`${userReferralsPage + 1}/${Math.max(1, Math.ceil(userReferralsTotal / userReferralsPageSize))}`
)}
page={userReferralsPage}
pageCount={userReferralsPageCount}
total={userReferralsTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("prev_page", {}, "Назад")}
nextLabel={at("next_page", {}, "Вперёд")}
prevDisabled={userReferralsLoading || userReferralsPage <= 0}
nextDisabled={userReferralsLoading || !userReferralsHasMore}
onPrev={() => usersStore.setUserReferralsPage(userReferralsPage - 1)}
onNext={() => usersStore.setUserReferralsPage(userReferralsPage + 1)}
disabled={userReferralsLoading}
onPageChange={(page) => usersStore.setUserReferralsPage(page)}
/>
{/if}
</div>
+12 -10
View File
@@ -48,7 +48,7 @@
const USERS_PAGE_SIZE = 25;
let usersFilterSheetOpen = false;
$: usersHasMore = users.length === USERS_PAGE_SIZE;
$: usersPageCount = Math.max(1, Math.ceil(Number(usersTotal || 0) / USERS_PAGE_SIZE));
const USERS_FILTER_OPTIONS = [
{ value: "all", label: at("filter_all", {}, "Все") },
@@ -570,17 +570,19 @@
</div>
<AdminPagination
meta={`${at("page", {}, "Страница")} ${usersPage + 1}`}
page={usersPage}
pageCount={usersPageCount}
total={usersTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
prevDisabled={usersPage === 0}
nextDisabled={!usersHasMore}
onPrev={() => {
usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) });
usersStore.loadUsers();
}}
onNext={() => {
usersStore.updateState({ usersPage: usersPage + 1 });
onPageChange={(page) => {
usersStore.updateState({ usersPage: page });
usersStore.loadUsers();
}}
/>
@@ -1,12 +1,20 @@
import { writable } from "svelte/store";
export function createBroadcastStore({ api, onToast, at }) {
const COUNTS_CACHE_TTL_MS = 30_000;
const COUNTS_DISPLAY_CACHE_TTL_MS = 5 * 60_000;
const COUNTS_STORAGE_KEY = "remnawave-admin:broadcast-audience-counts";
let countsPromise = null;
const cachedCounts = readStoredCounts();
const state = writable({
broadcastTarget: "all",
broadcastText: "",
broadcastBusy: false,
broadcastResult: null,
broadcastCounts: null,
broadcastCounts: cachedCounts?.counts || null,
broadcastCountsLoading: false,
broadcastCountsLoadedAt: cachedCounts?.loadedAt || 0,
});
const BROADCAST_TARGET_OPTIONS = [
@@ -14,23 +22,84 @@ export function createBroadcastStore({ api, onToast, at }) {
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
{
value: "active_never_connected",
label: at(
"broadcast_target_active_never_connected",
{},
"С подпиской, но без VPN-подключений"
),
},
{
value: "never",
label: at("broadcast_target_never", {}, "Без подписки и без истории"),
},
];
async function loadCounts() {
function countsAreFresh(stateSnapshot) {
return (
stateSnapshot.broadcastCounts &&
Date.now() - Number(stateSnapshot.broadcastCountsLoadedAt || 0) < COUNTS_CACHE_TTL_MS
);
}
function readStoredCounts() {
try {
const res = await api("/admin/broadcast/audience-counts");
if (res?.ok && res.counts) {
state.update((s) => ({ ...s, broadcastCounts: res.counts }));
}
if (typeof window === "undefined" || !window.sessionStorage) return null;
const raw = window.sessionStorage.getItem(COUNTS_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
const loadedAt = Number(payload?.loadedAt || 0);
if (!payload?.counts || Date.now() - loadedAt > COUNTS_DISPLAY_CACHE_TTL_MS) return null;
return { counts: payload.counts, loadedAt };
} catch {
// Counts are advisory; ignore failures and keep plain labels.
return null;
}
}
function writeStoredCounts(counts, loadedAt) {
try {
if (typeof window === "undefined" || !window.sessionStorage) return;
window.sessionStorage.setItem(COUNTS_STORAGE_KEY, JSON.stringify({ counts, loadedAt }));
} catch {
// Ignore storage quota/privacy errors; in-memory counts still work.
}
}
async function loadCounts({ force = false } = {}) {
let shouldLoad = false;
state.update((s) => {
if (!force && countsAreFresh(s)) return s;
if (countsPromise || s.broadcastCountsLoading) return s;
shouldLoad = true;
return { ...s, broadcastCountsLoading: true };
});
if (!shouldLoad) return countsPromise || Promise.resolve();
countsPromise = (async () => {
try {
const res = await api("/admin/broadcast/audience-counts");
if (res?.ok && res.counts) {
const loadedAt = Date.now();
state.update((s) => ({
...s,
broadcastCounts: res.counts,
broadcastCountsLoadedAt: loadedAt,
}));
writeStoredCounts(res.counts, loadedAt);
}
} catch {
// Counts are advisory; ignore failures and keep existing/plain labels.
} finally {
state.update((s) => ({ ...s, broadcastCountsLoading: false }));
countsPromise = null;
}
})();
return countsPromise;
}
async function runBroadcast() {
let text = "";
let target = "";
@@ -0,0 +1,35 @@
import { writable } from "svelte/store";
export function createHealthStore({ api }) {
const state = writable({
alerts: [],
checkedAt: null,
healthLoading: false,
healthError: "",
});
async function loadHealth({ refresh = false } = {}) {
state.update((s) => ({ ...s, healthLoading: true, healthError: "" }));
try {
const data = await api(`/admin/health${refresh ? "?refresh=1" : ""}`);
if (!data?.ok) {
state.update((s) => ({ ...s, healthError: data?.error || "load_failed" }));
} else {
state.update((s) => ({
...s,
alerts: Array.isArray(data.alerts) ? data.alerts : [],
checkedAt: data.checked_at || null,
}));
}
} catch (e) {
state.update((s) => ({ ...s, healthError: e?.message || String(e) }));
} finally {
state.update((s) => ({ ...s, healthLoading: false }));
}
}
return {
subscribe: state.subscribe,
loadHealth,
};
}
+91 -57
View File
@@ -54,6 +54,58 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
let _activeRef = "stats"; // fallback if active isn't tracked
let _pathContext = null;
let _openUserRequestId = 0;
function _closedUserModalState() {
return {
openedUser: null,
openedUserDetail: null,
userDetailLoading: false,
userMessageDraft: "",
userExtendDays: 30,
userExtendHwidDevices: true,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userDetailTab: "profile",
premiumUnlimitedDraft: false,
premiumBonusGbDraft: "",
regularUnlimitedDraft: false,
regularBonusGbDraft: "",
hwidUnlimitedDraft: false,
hwidDeviceLimitDraft: "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
};
}
function _openingUserModalState(user, userId) {
return {
..._closedUserModalState(),
openedUser: user,
userDetailLoading: true,
userDetailTab: "subscription",
userLogsUserId: userId,
};
}
function _isCurrentUserRequest(s, requestId, userId) {
return (
requestId === _openUserRequestId && Boolean(s.openedUser) && s.openedUser.user_id === userId
);
}
function setActive(active) {
_activeRef = active;
@@ -124,31 +176,15 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
const userId =
typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
if (!userId) return;
const requestId = ++_openUserRequestId;
_setPathContext(opts.pathContext);
const openedUser =
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId };
state.update((s) => ({
...s,
openedUser:
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
openedUserDetail: null,
userMessageDraft: "",
userMessageConfirmOpen: false,
userExtendDays: 30,
userExtendHwidDevices: true,
userDetailLoading: true,
userDetailTab: "subscription",
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: userId,
..._openingUserModalState(openedUser, userId),
userActionBusy: s.userActionBusy,
}));
if (!opts.skipPush) _pushUserPath(userId);
@@ -161,54 +197,52 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
const hasHwidLimit =
sub?.hwid_device_limit !== null && sub?.hwid_device_limit !== undefined;
const hwidLimit = hasHwidLimit ? Number(sub?.hwid_device_limit) : null;
state.update((s) => ({
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft:
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0,
hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
}));
state.update((s) => {
if (!_isCurrentUserRequest(s, requestId, userId)) return s;
return {
...s,
openedUserDetail: res,
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft:
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
hwidUnlimitedDraft: hasHwidLimit && hwidLimit === 0,
hwidDeviceLimitDraft: hasHwidLimit && hwidLimit > 0 ? String(hwidLimit) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
};
});
} else {
onToast(res?.error || "load_failed");
state.update((s) => ({ ...s, openedUser: null }));
if (!opts.skipPush) _pushUserPath(null);
_pathContext = null;
let shouldClearPath = false;
let shouldShowError = false;
state.update((s) => {
if (!_isCurrentUserRequest(s, requestId, userId)) return s;
shouldShowError = true;
shouldClearPath = true;
_pathContext = null;
return { ...s, ..._closedUserModalState() };
});
if (shouldShowError) onToast(res?.error || "load_failed");
if (shouldClearPath && !opts.skipPush) _pushUserPath(null);
}
} finally {
state.update((s) => ({ ...s, userDetailLoading: false }));
state.update((s) => {
if (!_isCurrentUserRequest(s, requestId, userId)) return s;
return { ...s, userDetailLoading: false };
});
}
}
function closeUser(opts = {}) {
let wasOpen = false;
_openUserRequestId += 1;
state.update((s) => {
wasOpen = Boolean(s.openedUser);
return {
...s,
openedUser: null,
openedUserDetail: null,
userDeleteOpen: false,
userBanConfirmOpen: false,
userMessageConfirmOpen: false,
userReferralsOpen: false,
userReferralsLoading: false,
userReferrals: [],
userReferralsTotal: 0,
userReferralsPage: 0,
userReferralsInviter: null,
userLogs: [],
userLogsTotal: 0,
userLogsPage: 0,
userLogsLoading: false,
userLogsLoaded: false,
userLogsUserId: null,
..._closedUserModalState(),
};
});
if (wasOpen && !opts.skipPush) _pushUserPath(null);
@@ -1,26 +1,198 @@
<script>
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import { ArrowRight, ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import AdminButton from "./AdminButton.svelte";
export let meta = "";
export let prevLabel = "Back";
export let nextLabel = "Next";
export let page = null;
export let pageCount = null;
export let total = null;
export let pageLabel = "Page";
export let ofLabel = "of";
export let totalLabel = "Total";
export let jumpLabel = "Page";
export let jumpAriaLabel = "Go to page";
export let goLabel = "Go";
export let disabled = false;
export let prevDisabled = false;
export let nextDisabled = false;
export let onPrev = () => {};
export let onNext = () => {};
export let onPageChange = null;
let jumpValue = "";
$: normalizedPage = Number(page);
$: normalizedPageCount = Math.max(1, Math.ceil(Number(pageCount) || 1));
$: hasPageNavigation = Number.isFinite(normalizedPage) && typeof onPageChange === "function";
$: currentPage = hasPageNavigation
? Math.min(Math.max(0, Math.floor(normalizedPage)), normalizedPageCount - 1)
: 0;
$: pages = hasPageNavigation ? visiblePages(currentPage, normalizedPageCount) : [];
$: paginationDisabled = Boolean(disabled);
$: computedPrevDisabled =
paginationDisabled || prevDisabled || (hasPageNavigation ? currentPage <= 0 : false);
$: computedNextDisabled =
paginationDisabled ||
nextDisabled ||
(hasPageNavigation ? currentPage >= normalizedPageCount - 1 : false);
$: hasTotal = total !== null && total !== undefined && total !== "";
$: totalValue = Number(total);
$: showTotal = hasTotal && Number.isFinite(totalValue) && totalValue >= 0;
$: jumpTarget = Number(jumpValue);
$: canJump =
hasPageNavigation &&
!paginationDisabled &&
jumpValue !== "" &&
Number.isFinite(jumpTarget) &&
Number.isInteger(jumpTarget) &&
jumpTarget >= 1 &&
jumpTarget <= normalizedPageCount;
function visiblePages(activePage, count) {
const pageIndexes = new Set([0, count - 1, activePage - 1, activePage, activePage + 1]);
if (activePage <= 2) {
pageIndexes.add(1);
pageIndexes.add(2);
}
if (activePage >= count - 3) {
pageIndexes.add(count - 2);
pageIndexes.add(count - 3);
}
const sorted = [...pageIndexes]
.filter((value) => value >= 0 && value < count)
.sort((a, b) => a - b);
const result = [];
sorted.forEach((value, index) => {
const previous = sorted[index - 1];
if (index > 0 && value - previous > 1) {
result.push({ type: "ellipsis", key: `ellipsis-${previous}-${value}` });
}
result.push({ type: "page", key: `page-${value}`, index: value, label: value + 1 });
});
return result;
}
function goToPage(nextPage) {
if (!hasPageNavigation || paginationDisabled) return;
const clamped = Math.min(
Math.max(0, Math.floor(Number(nextPage) || 0)),
normalizedPageCount - 1
);
if (clamped === currentPage) return;
onPageChange(clamped);
}
function handlePrev() {
if (computedPrevDisabled) return;
if (hasPageNavigation) goToPage(currentPage - 1);
else onPrev();
}
function handleNext() {
if (computedNextDisabled) return;
if (hasPageNavigation) goToPage(currentPage + 1);
else onNext();
}
function submitJump() {
if (!canJump) return;
goToPage(jumpTarget - 1);
jumpValue = "";
}
</script>
<div class="admin-pagination">
<span class="admin-pagination-meta">{meta}</span>
<div class="admin-pagination-summary">
{#if meta}
<span class="admin-pagination-meta">{meta}</span>
{/if}
{#if hasPageNavigation}
<span class="admin-pagination-count">
{pageLabel}
{currentPage + 1}
{ofLabel}
{normalizedPageCount}
</span>
{/if}
{#if showTotal}
<span class="admin-pagination-count">{totalLabel} {totalValue}</span>
{/if}
</div>
<div class="admin-pagination-buttons">
<AdminButton size="sm" disabled={prevDisabled} onclick={onPrev}>
<AdminButton
class="admin-pagination-nav"
size="sm"
disabled={computedPrevDisabled}
onclick={handlePrev}
>
<ChevronLeft size={14} />
{prevLabel}
</AdminButton>
<AdminButton size="sm" disabled={nextDisabled} onclick={onNext}>
{#if hasPageNavigation}
<div class="admin-pagination-pages" aria-label={pageLabel}>
{#each pages as item (item.key)}
{#if item.type === "ellipsis"}
<span class="admin-pagination-ellipsis" aria-hidden="true">...</span>
{:else}
<AdminButton
class={item.index === currentPage
? "admin-pagination-page is-active"
: "admin-pagination-page"}
size="sm"
disabled={paginationDisabled}
aria-current={item.index === currentPage ? "page" : undefined}
aria-label={`${pageLabel} ${item.label}`}
onclick={() => goToPage(item.index)}
>
{item.label}
</AdminButton>
{/if}
{/each}
</div>
{/if}
<AdminButton
class="admin-pagination-nav"
size="sm"
disabled={computedNextDisabled}
onclick={handleNext}
>
{nextLabel}
<ChevronRight size={14} />
</AdminButton>
</div>
{#if hasPageNavigation}
<form class="admin-pagination-jump" on:submit|preventDefault={submitJump}>
<label class="admin-pagination-jump-label">
<span>{jumpLabel}</span>
<input
class="admin-pagination-jump-input"
type="number"
min="1"
max={normalizedPageCount}
step="1"
inputmode="numeric"
aria-label={jumpAriaLabel}
placeholder={String(currentPage + 1)}
value={jumpValue}
on:input={(event) => (jumpValue = event.currentTarget.value)}
disabled={paginationDisabled}
/>
</label>
<AdminButton
class="admin-pagination-jump-button"
size="sm"
type="submit"
disabled={!canJump}
aria-label={goLabel}
title={goLabel}
>
<ArrowRight size={13} />
</AdminButton>
</form>
{/if}
</div>
+2 -1
View File
@@ -27,11 +27,12 @@ export function escapeHtml(value) {
}
// Default project logo, served by the backend when no custom logo is set.
export const DEFAULT_BRAND_TITLE = "Subscription";
export const DEFAULT_LOGO_URL = "/webapp-default-logo.webp";
export function normalizeBrand(brand = {}) {
return {
title: String(brand.title || "/minishop").trim() || "/minishop",
title: String(brand.title || DEFAULT_BRAND_TITLE).trim() || DEFAULT_BRAND_TITLE,
logoUrl: String(brand.logoUrl || "").trim() || DEFAULT_LOGO_URL,
};
}

Some files were not shown because too many files have changed in this diff Show More