Merge origin/dev into feature/telegram-flood-hardening
This commit is contained in:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
|
||||
@@ -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 = "💳"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
| Папка | Документация |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 на всех публичных доменах.
|
||||
|
||||
@@ -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 форка.
|
||||
|
||||
## Как выключить
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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 поверх месячного лимита;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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`.
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user