Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c459cabaae | ||
|
|
0864413e11 | ||
|
|
ff2ed5b9f3 | ||
|
|
02cb2f0d27 | ||
|
|
ddb372a4e5 | ||
|
|
73474f70c8 | ||
|
|
6bb2709244 | ||
|
|
fd931581f5 | ||
|
|
217bed3c5d | ||
|
|
ab21253d4f | ||
|
|
1b2290ea66 | ||
|
|
ce6273a652 | ||
|
|
3170b966b5 | ||
|
|
1fca62de75 | ||
|
|
5c96fcd519 | ||
|
|
ca88c995b8 | ||
|
|
234fc69505 | ||
|
|
1362edde42 | ||
|
|
194bf64ebb | ||
|
|
a2ce29da45 | ||
|
|
0db3a68c09 | ||
|
|
c2f0ae0b8b | ||
|
|
23ad893f69 | ||
|
|
d2149357c6 | ||
|
|
63192659e4 | ||
|
|
3e58e01d53 | ||
|
|
8cbe7e01ec | ||
|
|
a99aeec0d4 | ||
|
|
2697c30c0f | ||
|
|
2077c27252 | ||
|
|
a86f5d75e1 | ||
|
|
24faabc20a | ||
|
|
00e1f51abe | ||
|
|
4b8f939a25 | ||
|
|
d1c4a6de80 | ||
|
|
bddcd16a07 | ||
|
|
a8e229d530 | ||
|
|
5cee619dd0 | ||
|
|
d5b23d8306 | ||
|
|
9de000e78c | ||
|
|
2b6f25f0f8 | ||
|
|
3c82f43c84 | ||
|
|
724e936660 | ||
|
|
d263651b48 | ||
|
|
3d5190639f | ||
|
|
9adcbf103a | ||
|
|
fcb8e51ec0 | ||
|
|
ae3d6a9b99 | ||
|
|
e0b3940b98 | ||
|
|
2ec7376981 | ||
|
|
6d0084dd3a | ||
|
|
e3f35a461c | ||
|
|
a75d2d7ac0 | ||
|
|
e5b0daf639 | ||
|
|
f07031f32c | ||
|
|
cc74ddec10 | ||
|
|
766f2a5780 | ||
|
|
1158a2d835 | ||
|
|
1d8ad0f24d | ||
|
|
6913676420 | ||
|
|
6890b58ced | ||
|
|
4cbd4dedf5 | ||
|
|
7700b294b7 | ||
|
|
4b2faa87bb | ||
|
|
1200e8ff70 | ||
|
|
728599c882 | ||
|
|
fd1edf38b5 | ||
|
|
2e4599d068 | ||
|
|
516d699cf3 | ||
|
|
6dc43182ab | ||
|
|
5e1fe59396 | ||
|
|
af1487731a | ||
|
|
77b124d61e | ||
|
|
090c88603e | ||
|
|
7bb0a94918 | ||
|
|
a456718193 | ||
|
|
358ef6ded7 | ||
|
|
ae7bfb9621 | ||
|
|
976c81c11c | ||
|
|
02e7bb48d6 | ||
|
|
81c9f6bfd3 | ||
|
|
bdf0622be5 | ||
|
|
cf34d8971d | ||
|
|
0afe4f0bc5 | ||
|
|
69985e44dd | ||
|
|
8d6f91b292 | ||
|
|
cd4b40af6b | ||
|
|
e108cc0baa | ||
|
|
5b37306061 | ||
|
|
5527bf0247 | ||
|
|
c46aaad7e3 | ||
|
|
c733d830f6 | ||
|
|
bd7361e9dc | ||
|
|
1ccda9ebd1 | ||
|
|
661d3f7953 | ||
|
|
7313f90350 | ||
|
|
d988812ecf | ||
|
|
1ee0b0a7cd | ||
|
|
2b4a8d6953 | ||
|
|
2ad6b14513 | ||
|
|
33707b7257 | ||
|
|
5f22c2081e | ||
|
|
fbb89793cb | ||
|
|
a06884d816 | ||
|
|
7b8feee99a | ||
|
|
0a5575c1e6 | ||
|
|
1055e58d0e | ||
|
|
d8fe88eb01 | ||
|
|
602cbc7ee6 | ||
|
|
bcb7372500 | ||
|
|
e52c75538f | ||
|
|
5d1fd3304b | ||
|
|
2d43033f98 | ||
|
|
101119911a | ||
|
|
58de153370 | ||
|
|
0a294b8bf8 | ||
|
|
1605be0dfa | ||
|
|
6f2bd2c443 | ||
|
|
36e0e4f462 | ||
|
|
e15d6559aa | ||
|
|
dc66cf6a06 | ||
|
|
70e6fa382b | ||
|
|
20f1228218 | ||
|
|
e4f523a79d | ||
|
|
8e5124e4cf | ||
|
|
4d86015d25 | ||
|
|
c4d2d0a2fe | ||
|
|
2c3f2c32bc | ||
|
|
21f9ed803d | ||
|
|
966a18045d | ||
|
|
4263cb7c99 | ||
|
|
1ee8af338c | ||
|
|
ee840a7e6d | ||
|
|
8f009c8caf | ||
|
|
4d7577f4ec | ||
|
|
e9e028fc00 | ||
|
|
9508eece5f | ||
|
|
2ccead9b49 | ||
|
|
0473829f7a | ||
|
|
162e9e20b5 | ||
|
|
3b40369606 | ||
|
|
d22ed238b4 | ||
|
|
5e47a1496b | ||
|
|
56796d9f22 | ||
|
|
619a13128c | ||
|
|
023ab375ec |
@@ -16,7 +16,9 @@ frontend/node_modules/
|
|||||||
docs-site/node_modules/
|
docs-site/node_modules/
|
||||||
docs-site/.astro/
|
docs-site/.astro/
|
||||||
docs-site/dist/
|
docs-site/dist/
|
||||||
|
docs-site/public/demo/runtime/
|
||||||
docs-site/src/content/docs/
|
docs-site/src/content/docs/
|
||||||
|
frontend-nginx-dist/
|
||||||
deploy/compose/docker-compose-dev.yml
|
deploy/compose/docker-compose-dev.yml
|
||||||
data/*
|
data/*
|
||||||
!data/tariffs.example.json
|
!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.min.*.js.gz
|
||||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
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.css
|
||||||
backend/bot/app/web/templates/subscription_webapp.js
|
backend/bot/app/web/templates/subscription_webapp.js
|
||||||
backend/bot/app/web/templates/subscription_webapp.min.*.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.min.*.js.gz
|
||||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
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_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
|
# Byte-compiled / optimized / DLL files
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
|
|||||||
+7
-5
@@ -71,16 +71,18 @@ WEB_SERVER_PORT=8080
|
|||||||
FRONTEND_PORT=8082
|
FRONTEND_PORT=8082
|
||||||
|
|
||||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||||
# Keep loopback for local proxy; add your proxy network if needed.
|
# Keep loopback and private network ranges so payment provider IP allowlists
|
||||||
TRUSTED_PROXIES=127.0.0.1,::1
|
# 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) ──────────────────────────────
|
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
|
||||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
# 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
|
# 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:
|
# used. It contains an opaque random install id and coarse facts only:
|
||||||
# version, OS/arch, Python version, language, enabled payment providers and a
|
# version, official/custom image provenance, OS/arch, Python version, language,
|
||||||
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
|
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
|
||||||
# personal information is ever sent. Full details: docs/configuration/telemetry.md
|
# 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 ->
|
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||||
# "Anonymous install analytics" (applies without a restart).
|
# "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
|
# Reusable workflow that builds the three image targets defined in
|
||||||
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
# 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:
|
# Called by:
|
||||||
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
# - 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
|
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -20,6 +20,10 @@ on:
|
|||||||
description: "Tagging strategy: 'dev' or 'release'"
|
description: "Tagging strategy: 'dev' or 'release'"
|
||||||
type: string
|
type: string
|
||||||
required: true
|
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
|
# 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
|
# than its caller grants, so the token scope is set by each caller
|
||||||
@@ -78,19 +82,36 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: inputs.push
|
if: inputs.push && inputs.publish_dockerhub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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.push }}" = "true" ] && [ "${{ 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
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
3252a8/${{ matrix.image }}
|
name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }}
|
||||||
ghcr.io/3252a8/${{ matrix.image }}
|
name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||||
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
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.
|
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||||
build-args: |
|
build-args: |
|
||||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
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-from: type=gha,scope=${{ matrix.target }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||||
provenance: false
|
provenance: false
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: PR checks
|
name: PR checks
|
||||||
|
|
||||||
# Runs on pull requests into main (typically from dev) and into dev (typically
|
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||||
# from feature/* branches): lint + format checks and a no-push image build to
|
# from feature/* branches): lint + format checks, a demo settings-manifest
|
||||||
# prove the Docker images still build.
|
# drift guard, and a no-push image build to prove the Docker images still build.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -56,6 +56,30 @@ jobs:
|
|||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
demo-manifest:
|
||||||
|
name: Demo settings manifest in sync
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install backend deps + pytest
|
||||||
|
run: pip install -r backend/requirements.txt pytest
|
||||||
|
|
||||||
|
# Fails if admin_settings_manifest.py changed without regenerating the
|
||||||
|
# demo snapshot. Fix: `python scripts/export_settings_manifest.py` then
|
||||||
|
# `npx --prefix frontend prettier --write \
|
||||||
|
# src/lib/webapp/settingsManifest.generated.json`, and commit the result.
|
||||||
|
- name: Check demo settings manifest is in sync
|
||||||
|
run: python -m pytest tests/test_settings_manifest_demo_sync.py -q
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Docker build
|
name: Docker build
|
||||||
uses: ./.github/workflows/_docker-build-push.yml
|
uses: ./.github/workflows/_docker-build-push.yml
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Dev images
|
name: Dev images
|
||||||
|
|
||||||
# On every push to the dev branch, build all three images and push them to
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -22,4 +22,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tag_mode: dev
|
tag_mode: dev
|
||||||
|
publish_dockerhub: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ docs-site/.astro/
|
|||||||
docs-site/dist/
|
docs-site/dist/
|
||||||
docs-site/public/demo/runtime/
|
docs-site/public/demo/runtime/
|
||||||
docs-site/src/content/docs/
|
docs-site/src/content/docs/
|
||||||
|
frontend-nginx-dist/
|
||||||
|
|
||||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||||
bot/app/web/templates/subscription_webapp.css
|
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"
|
||||||
@@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- Web App / Mini App с входом через Telegram или email;
|
- Web App / Mini App с входом через Telegram или email;
|
||||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
||||||
- пробный период, промокоды и реферальная программа;
|
- пробный период, промокоды и реферальная программа;
|
||||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
|
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars;
|
||||||
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||||
|
|
||||||
@@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
|
||||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
|
||||||
|
|
||||||
## Совместимость
|
## Совместимость
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@ docker compose logs -f backend worker frontend
|
|||||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||||
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
- `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 админке.
|
- остальные настройки удобнее задать в Web App админке.
|
||||||
|
|
||||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
В 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).
|
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||||
|
|
||||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
В 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
|
```bash
|
||||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
mkdir -p data/themes data/webapp-logo data/tariffs
|
||||||
touch data/locales-overrides.json
|
touch data/locales-overrides.json
|
||||||
chown -R 10001:10001 data
|
chown -R 10001:10001 data
|
||||||
chmod -R u+rwX data
|
chmod -R u+rwX data
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
|||||||
from bot.middlewares.db_session import DBSessionMiddleware
|
from bot.middlewares.db_session import DBSessionMiddleware
|
||||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||||
|
from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ def build_dispatcher(
|
|||||||
dp["i18n_instance"] = i18n_instance
|
dp["i18n_instance"] = i18n_instance
|
||||||
dp["async_session_factory"] = async_session_factory
|
dp["async_session_factory"] = async_session_factory
|
||||||
|
|
||||||
|
dp.update.outer_middleware(UpdateAntiFloodMiddleware(settings=settings))
|
||||||
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
|
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
|
||||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from bot.app.web.admin_api_impl import (
|
|||||||
backups as _backups,
|
backups as _backups,
|
||||||
broadcast as _broadcast,
|
broadcast as _broadcast,
|
||||||
common as _common,
|
common as _common,
|
||||||
|
health as _health,
|
||||||
logs as _logs,
|
logs as _logs,
|
||||||
panel as _panel,
|
panel as _panel,
|
||||||
payments as _payments,
|
payments as _payments,
|
||||||
@@ -28,6 +29,7 @@ _MODULES = (
|
|||||||
_runtime,
|
_runtime,
|
||||||
_auth,
|
_auth,
|
||||||
_common,
|
_common,
|
||||||
|
_health,
|
||||||
_stats,
|
_stats,
|
||||||
_users,
|
_users,
|
||||||
_payments,
|
_payments,
|
||||||
|
|||||||
@@ -1,5 +1,165 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
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:
|
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()
|
target = str(payload.get("target") or "all").strip().lower()
|
||||||
if not text:
|
if not text:
|
||||||
return _error(400, "empty_text")
|
return _error(400, "empty_text")
|
||||||
if target not in {"all", "active", "inactive", "expired"}:
|
if target not in BROADCAST_TARGETS:
|
||||||
target = "all"
|
target = "all"
|
||||||
|
|
||||||
queue_manager = get_queue_manager()
|
queue_manager = get_queue_manager()
|
||||||
@@ -18,12 +178,22 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
async with async_session_factory() as session:
|
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)
|
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||||
elif target == "inactive":
|
elif target == "inactive":
|
||||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||||
elif target == "expired":
|
elif target == "expired":
|
||||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
||||||
|
elif target == "never":
|
||||||
|
user_ids = await user_dal.get_user_ids_without_any_subscription(session)
|
||||||
else:
|
else:
|
||||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||||
|
|
||||||
@@ -54,3 +224,19 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
||||||
|
"""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"]
|
||||||
|
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 {}
|
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]:
|
def _serialize_user(user: User) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"user_id": int(user.user_id),
|
"user_id": int(user.user_id),
|
||||||
@@ -94,6 +263,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
|||||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||||
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||||
|
provider = sub.provider
|
||||||
|
is_trial = str(provider or "").strip().lower() == "trial"
|
||||||
|
display_label = "Trial" if is_trial else sub.tariff_key
|
||||||
return {
|
return {
|
||||||
"subscription_id": int(sub.subscription_id),
|
"subscription_id": int(sub.subscription_id),
|
||||||
"panel_user_uuid": sub.panel_user_uuid,
|
"panel_user_uuid": sub.panel_user_uuid,
|
||||||
@@ -117,9 +289,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
|||||||
"regular_unlimited_override": regular_unlimited_override,
|
"regular_unlimited_override": regular_unlimited_override,
|
||||||
"premium_unlimited_override": premium_unlimited_override,
|
"premium_unlimited_override": premium_unlimited_override,
|
||||||
"premium_is_limited": bool(sub.premium_is_limited),
|
"premium_is_limited": bool(sub.premium_is_limited),
|
||||||
|
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
|
||||||
|
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
|
||||||
"tariff_key": sub.tariff_key,
|
"tariff_key": sub.tariff_key,
|
||||||
|
"display_label": display_label,
|
||||||
|
"is_trial": is_trial,
|
||||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||||
"provider": sub.provider,
|
"provider": provider,
|
||||||
"is_throttled": bool(sub.is_throttled),
|
"is_throttled": bool(sub.is_throttled),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = app.router
|
||||||
router.add_get("/api/admin/me", admin_me_route)
|
router.add_get("/api/admin/me", admin_me_route)
|
||||||
router.add_get("/api/admin/stats", admin_stats_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", admin_users_list_route)
|
||||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||||
@@ -30,6 +31,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||||
admin_user_regular_traffic_override_route,
|
admin_user_regular_traffic_override_route,
|
||||||
)
|
)
|
||||||
|
router.add_post(
|
||||||
|
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
|
||||||
|
admin_user_hwid_device_limit_route,
|
||||||
|
)
|
||||||
router.add_post(
|
router.add_post(
|
||||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||||
admin_user_traffic_grant_route,
|
admin_user_traffic_grant_route,
|
||||||
@@ -57,6 +62,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||||
|
|
||||||
|
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
||||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||||
router.add_post("/api/admin/sync", admin_sync_route)
|
router.add_post("/api/admin/sync", admin_sync_route)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap
|
|||||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
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_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
@@ -136,10 +135,6 @@ def _favicon_digest(url: str) -> Optional[str]:
|
|||||||
return match.group(1) if match else None
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
def _emoji_to_codepoints(value: str) -> str:
|
|
||||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
|
||||||
|
|
||||||
|
|
||||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||||
keep_logos = {
|
keep_logos = {
|
||||||
filename
|
filename
|
||||||
@@ -156,15 +151,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
|||||||
]
|
]
|
||||||
if digest
|
if digest
|
||||||
}
|
}
|
||||||
keep_emoji_prefixes = set()
|
|
||||||
if (
|
|
||||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
|
||||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
|
||||||
== "noto-color-animated"
|
|
||||||
):
|
|
||||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
|
||||||
if codepoints:
|
|
||||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
|
||||||
|
|
||||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||||
if path.is_file() and path.name not in keep_logos:
|
if path.is_file() and path.name not in keep_logos:
|
||||||
@@ -184,15 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
|||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||||
|
|
||||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
|
||||||
if path.is_file() and not any(
|
|
||||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
except OSError:
|
|
||||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _persist_appearance_upload(
|
async def _persist_appearance_upload(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
@@ -389,7 +366,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
|
|||||||
request,
|
request,
|
||||||
{
|
{
|
||||||
"WEBAPP_LOGO_URL": logo_url,
|
"WEBAPP_LOGO_URL": logo_url,
|
||||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
|
||||||
**(
|
**(
|
||||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||||
if favicon_payload.get("favicon_url")
|
if favicon_payload.get("favicon_url")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .common import (
|
|||||||
_build_admin_webapp_referral_link,
|
_build_admin_webapp_referral_link,
|
||||||
_error,
|
_error,
|
||||||
_ok,
|
_ok,
|
||||||
|
_panel_user_connection_activity,
|
||||||
_premium_traffic_list_payload,
|
_premium_traffic_list_payload,
|
||||||
_read_json,
|
_read_json,
|
||||||
_serialize_payment,
|
_serialize_payment,
|
||||||
@@ -745,6 +746,24 @@ def _user_search_condition(query: str):
|
|||||||
return or_(*conditions)
|
return or_(*conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
|
||||||
|
first_trial_sub = trial_subs[0] if trial_subs else None
|
||||||
|
latest_trial_sub = trial_subs[-1] if trial_subs else None
|
||||||
|
first_start = getattr(first_trial_sub, "start_date", None)
|
||||||
|
latest_start = getattr(latest_trial_sub, "start_date", None)
|
||||||
|
latest_end = getattr(latest_trial_sub, "end_date", None)
|
||||||
|
reset_at = getattr(user, "trial_eligibility_reset_at", None)
|
||||||
|
return {
|
||||||
|
"used": bool(trial_subs),
|
||||||
|
"count": len(trial_subs),
|
||||||
|
"first_activated_at": first_start.isoformat() if first_start else None,
|
||||||
|
"latest_activated_at": latest_start.isoformat() if latest_start else None,
|
||||||
|
"latest_end_date": latest_end.isoformat() if latest_end else None,
|
||||||
|
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
|
||||||
|
"last_reset_at": reset_at.isoformat() if reset_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
target_id = int(request.match_info["user_id"])
|
target_id = int(request.match_info["user_id"])
|
||||||
@@ -764,6 +783,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
.limit(20)
|
.limit(20)
|
||||||
)
|
)
|
||||||
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
||||||
|
trial_subs_stmt = (
|
||||||
|
select(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.user_id == target_id,
|
||||||
|
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
|
||||||
|
)
|
||||||
|
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
|
||||||
|
)
|
||||||
|
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
|
||||||
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
||||||
recent_payments_stmt = (
|
recent_payments_stmt = (
|
||||||
select(Payment)
|
select(Payment)
|
||||||
@@ -809,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
|
# imports into their VPN client. May be missing if the user has never
|
||||||
# been provisioned on the panel.
|
# been provisioned on the panel.
|
||||||
subscription_url: Optional[str] = None
|
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:
|
if panel_uuid:
|
||||||
subscription_service = request.app.get("subscription_service")
|
subscription_service = request.app.get("subscription_service")
|
||||||
panel_service = getattr(subscription_service, "panel_service", None)
|
panel_service = getattr(subscription_service, "panel_service", None)
|
||||||
@@ -818,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)
|
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||||
if panel_data:
|
if panel_data:
|
||||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
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
|
except Exception as exc_panel: # pragma: no cover
|
||||||
logger.warning(
|
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,
|
target_id,
|
||||||
panel_uuid,
|
panel_uuid,
|
||||||
exc_panel,
|
exc_panel,
|
||||||
@@ -830,16 +867,20 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
|||||||
serialized_inviter = (
|
serialized_inviter = (
|
||||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||||
)
|
)
|
||||||
|
trial_payload = _serialize_trial_summary(user, trial_subs)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
{
|
{
|
||||||
"user": serialized_user,
|
"user": serialized_user,
|
||||||
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
||||||
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
||||||
|
"trial": trial_payload,
|
||||||
"total_paid": float(total_paid),
|
"total_paid": float(total_paid),
|
||||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||||
"log_count": int(log_count or 0),
|
"log_count": int(log_count or 0),
|
||||||
"subscription_url": subscription_url,
|
"subscription_url": subscription_url,
|
||||||
|
"last_vpn_connected_at": last_vpn_connected_at,
|
||||||
|
"vpn_connection_status": vpn_connection_status,
|
||||||
"referral": {
|
"referral": {
|
||||||
"code": referral_code,
|
"code": referral_code,
|
||||||
"bot_link": referral_bot_link,
|
"bot_link": referral_bot_link,
|
||||||
@@ -1318,6 +1359,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
|||||||
return _ok({"subscription": _serialize_subscription(active)})
|
return _ok({"subscription": _serialize_subscription(active)})
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
|
||||||
|
"""Override the user's base HWID device limit.
|
||||||
|
|
||||||
|
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
|
||||||
|
default is used. Purchased extra devices remain tracked separately and are
|
||||||
|
added when syncing the effective panel limit.
|
||||||
|
"""
|
||||||
|
actor_id = _require_admin_user_id(request)
|
||||||
|
target_id = int(request.match_info["user_id"])
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
payload = await _read_json(request)
|
||||||
|
|
||||||
|
unlimited = bool(payload.get("unlimited"))
|
||||||
|
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
|
||||||
|
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
|
||||||
|
|
||||||
|
if unlimited:
|
||||||
|
hwid_device_limit: Optional[int] = 0
|
||||||
|
elif use_default or limit_raw is None or limit_raw == "":
|
||||||
|
hwid_device_limit = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
hwid_device_limit = int(limit_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _error(
|
||||||
|
400,
|
||||||
|
"invalid_hwid_device_limit",
|
||||||
|
"hwid_device_limit must be a non-negative integer",
|
||||||
|
)
|
||||||
|
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||||
|
return _error(
|
||||||
|
400,
|
||||||
|
"invalid_hwid_device_limit",
|
||||||
|
"hwid_device_limit must be an integer from 0 to 1000000",
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_service = request.app.get("subscription_service")
|
||||||
|
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||||
|
if not active:
|
||||||
|
return _error(404, "no_active_subscription")
|
||||||
|
|
||||||
|
active.hwid_device_limit = hwid_device_limit
|
||||||
|
|
||||||
|
effective_limit = None
|
||||||
|
if subscription_service is not None:
|
||||||
|
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||||
|
session, target_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await message_log_dal.create_message_log(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": actor_id,
|
||||||
|
"event_type": "admin_hwid_device_limit_webapp",
|
||||||
|
"content": (
|
||||||
|
f"hwid_device_limit={hwid_device_limit!r} "
|
||||||
|
f"effective_hwid_device_limit={effective_limit!r}"
|
||||||
|
),
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(active)
|
||||||
|
|
||||||
|
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||||
|
return _ok({"subscription": _serialize_subscription(active)})
|
||||||
|
|
||||||
|
|
||||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||||
"""Credit regular or premium traffic to a user without a payment.
|
"""Credit regular or premium traffic to a user without a payment.
|
||||||
|
|
||||||
@@ -1414,6 +1527,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
|||||||
return _error(400, "invalid_days")
|
return _error(400, "invalid_days")
|
||||||
if days <= 0:
|
if days <= 0:
|
||||||
return _error(400, "invalid_days")
|
return _error(400, "invalid_days")
|
||||||
|
extend_hwid_devices = payload.get("extend_hwid_devices")
|
||||||
|
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
|
||||||
|
|
||||||
subscription_service = request.app.get("subscription_service")
|
subscription_service = request.app.get("subscription_service")
|
||||||
if subscription_service is None:
|
if subscription_service is None:
|
||||||
@@ -1426,6 +1541,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
|||||||
target_id,
|
target_id,
|
||||||
days,
|
days,
|
||||||
"admin_extend_subscription_webapp",
|
"admin_extend_subscription_webapp",
|
||||||
|
extend_hwid_devices=extend_hwid_devices,
|
||||||
)
|
)
|
||||||
if not new_end:
|
if not new_end:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
@@ -1436,7 +1552,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
|||||||
{
|
{
|
||||||
"user_id": actor_id,
|
"user_id": actor_id,
|
||||||
"event_type": "admin_extend_subscription_webapp",
|
"event_type": "admin_extend_subscription_webapp",
|
||||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
"content": (
|
||||||
|
f"+{days}d -> {new_end.isoformat()} "
|
||||||
|
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
|
||||||
|
),
|
||||||
"is_admin_event": True,
|
"is_admin_event": True,
|
||||||
"target_user_id": target_id,
|
"target_user_id": target_id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
|||||||
{
|
{
|
||||||
"WEBAPP_TITLE",
|
"WEBAPP_TITLE",
|
||||||
"WEBAPP_LOGO_URL",
|
"WEBAPP_LOGO_URL",
|
||||||
"WEBAPP_LOGO_USE_EMOJI",
|
|
||||||
"WEBAPP_LOGO_EMOJI",
|
|
||||||
"WEBAPP_LOGO_EMOJI_FONT",
|
|
||||||
"WEBAPP_FAVICON_URL",
|
"WEBAPP_FAVICON_URL",
|
||||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||||
"WEBAPP_LOGO_FAVICON_URL",
|
"WEBAPP_LOGO_FAVICON_URL",
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
||||||
),
|
),
|
||||||
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
||||||
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
|
|
||||||
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
||||||
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
||||||
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
||||||
@@ -77,14 +76,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"int",
|
"int",
|
||||||
"general",
|
"general",
|
||||||
"ID обязательного канала",
|
"ID обязательного канала",
|
||||||
"Telegram ID канала, в котором нужно состоять.",
|
(
|
||||||
|
"Telegram ID канала для проверки подписки. Если бот видит канал, "
|
||||||
|
"ссылка кнопки будет получена автоматически."
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"REQUIRED_CHANNEL_LINK",
|
"REQUIRED_CHANNEL_LINK",
|
||||||
"string",
|
"string",
|
||||||
"general",
|
"general",
|
||||||
"Ссылка на канал",
|
"Ссылка на канал",
|
||||||
"Имя пользователя или invite-link.",
|
(
|
||||||
|
"Необязательно: публичный @username или invite-link, "
|
||||||
|
"если ссылку нельзя получить по ID канала."
|
||||||
|
),
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_API_URL",
|
"PANEL_API_URL",
|
||||||
@@ -101,6 +106,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"Секретный ключ API панели.",
|
"Секретный ключ API панели.",
|
||||||
secret=True,
|
secret=True,
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API total timeout",
|
||||||
|
"Maximum total time for one Remnawave API request, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API connect timeout",
|
||||||
|
"Maximum time to get or open a Remnawave API connection, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API socket connect timeout",
|
||||||
|
"Maximum TCP/TLS connection time for Remnawave API, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"remnawave",
|
||||||
|
"Panel API socket read timeout",
|
||||||
|
"Maximum time to wait for response data from Remnawave API, in seconds.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"PANEL_WEBHOOK_SECRET",
|
"PANEL_WEBHOOK_SECRET",
|
||||||
"string",
|
"string",
|
||||||
@@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField(
|
SettingField(
|
||||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||||
),
|
),
|
||||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
|
||||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
|
||||||
SettingField(
|
|
||||||
"WEBAPP_LOGO_EMOJI_FONT",
|
|
||||||
"string",
|
|
||||||
"appearance",
|
|
||||||
"Шрифт эмоджи-логотипа",
|
|
||||||
"Выберите шрифт для отображения эмодзи-логотипа",
|
|
||||||
choices=(
|
|
||||||
("system", "Системный (по умолчанию)"),
|
|
||||||
("noto-color", "Noto Color Emoji"),
|
|
||||||
("noto-color-animated", "Noto Color Emoji Animated"),
|
|
||||||
("noto-emoji", "Noto Emoji"),
|
|
||||||
("twemoji", "Twitter Emoji"),
|
|
||||||
("openmoji", "OpenMoji"),
|
|
||||||
("apple", "Apple Color Emoji (local)"),
|
|
||||||
("segoe", "Segoe UI Emoji (local)"),
|
|
||||||
("noto-local", "Noto Emoji (local)"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SettingField(
|
SettingField(
|
||||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||||
"bool",
|
"bool",
|
||||||
@@ -327,6 +348,16 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"Английская версия текста на этапе оплаты.",
|
"Английская версия текста на этапе оплаты.",
|
||||||
subsection="checkout",
|
subsection="checkout",
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"PAYMENT_REQUEST_TIMEOUT_SECONDS",
|
||||||
|
"float",
|
||||||
|
"payments",
|
||||||
|
"Таймаут запроса к провайдеру",
|
||||||
|
"Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.",
|
||||||
|
optional=False,
|
||||||
|
min=1,
|
||||||
|
subsection="checkout",
|
||||||
|
),
|
||||||
# ─── Payment providers (toggles) ───────────────────────────────
|
# ─── Payment providers (toggles) ───────────────────────────────
|
||||||
# Common
|
# Common
|
||||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||||
@@ -348,7 +379,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"string",
|
"string",
|
||||||
"payments",
|
"payments",
|
||||||
"Порядок методов оплаты",
|
"Порядок методов оплаты",
|
||||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
|
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
|
||||||
subsection="common",
|
subsection="common",
|
||||||
),
|
),
|
||||||
# ─── Trial ─────────────────────────────────────────────────────
|
# ─── Trial ─────────────────────────────────────────────────────
|
||||||
@@ -386,6 +417,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
optional=False,
|
optional=False,
|
||||||
subsection="trial",
|
subsection="trial",
|
||||||
),
|
),
|
||||||
|
SettingField(
|
||||||
|
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Триал без Telegram",
|
||||||
|
(
|
||||||
|
"Если выключено, email-only пользователю нужно привязать Telegram для "
|
||||||
|
"активации триала. Disposable email домены всегда требуют Telegram."
|
||||||
|
),
|
||||||
|
optional=False,
|
||||||
|
subsection="trial",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"TRIAL_SQUAD_UUIDS",
|
"TRIAL_SQUAD_UUIDS",
|
||||||
"string",
|
"string",
|
||||||
@@ -396,12 +439,82 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
),
|
),
|
||||||
# ─── Referral program ──────────────────────────────────────────
|
# ─── Referral program ──────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
"REFERRAL_ONE_BONUS_PER_REFEREE",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Один бонус на приглашённого",
|
||||||
|
subsection="referral",
|
||||||
),
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
"REFERRAL_WELCOME_BONUS_DAYS",
|
||||||
|
"int",
|
||||||
|
"pricing",
|
||||||
|
"Приветственный бонус (дней)",
|
||||||
|
min=0,
|
||||||
|
subsection="referral",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Приветственный бонус без Telegram",
|
||||||
|
(
|
||||||
|
"Если выключено, email-only пользователю нужно привязать Telegram для получения "
|
||||||
|
"реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
|
||||||
|
),
|
||||||
|
subsection="referral",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"LEGACY_REFS",
|
||||||
|
"bool",
|
||||||
|
"pricing",
|
||||||
|
"Поддержка старых ref-ссылок",
|
||||||
|
subsection="referral",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"DISPOSABLE_EMAIL_DOMAINS",
|
||||||
|
"text",
|
||||||
|
"pricing",
|
||||||
|
"Disposable email домены",
|
||||||
|
(
|
||||||
|
"Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
|
||||||
|
"email не смогут получить trial или реферальный приветственный бонус."
|
||||||
|
),
|
||||||
|
placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
|
||||||
|
subsection="referral",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"migrations",
|
||||||
|
"Старые ref-ссылки Remnashop",
|
||||||
|
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
|
||||||
|
subsection="Remnashop",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"migrations",
|
||||||
|
"Старые промокоды Remnashop",
|
||||||
|
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
|
||||||
|
subsection="Remnashop",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||||
|
"string",
|
||||||
|
"migrations",
|
||||||
|
"Последний импорт Remnashop",
|
||||||
|
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
|
||||||
|
subsection="Remnashop",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"MIGRATION_REMNASHOP_NOTES",
|
||||||
|
"text",
|
||||||
|
"migrations",
|
||||||
|
"Заметки по миграции Remnashop",
|
||||||
|
"Внутренние заметки оператора по перенесенному инстансу.",
|
||||||
|
subsection="Remnashop",
|
||||||
),
|
),
|
||||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
|
||||||
# ─── Notifications ─────────────────────────────────────────────
|
# ─── Notifications ─────────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||||
@@ -589,15 +702,130 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||||
# ─── System ────────────────────────────────────────────────────
|
# ─── System ────────────────────────────────────────────────────
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_DROP_NON_PRIVATE_UPDATES",
|
||||||
|
"bool",
|
||||||
|
"system",
|
||||||
|
"Drop non-private Telegram updates",
|
||||||
|
"Drops group/channel messages and callbacks before DB-backed middleware runs.",
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"system",
|
||||||
|
"Telegram anti-flood enabled",
|
||||||
|
"Enables soft per-user limits for extreme Telegram update floods.",
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_WINDOW_SECONDS",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Anti-flood window",
|
||||||
|
"Rolling window, in seconds, used by all Telegram anti-flood buckets.",
|
||||||
|
min=1,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"All updates limit",
|
||||||
|
"Maximum total Telegram updates from one actor during the window. 0 disables this bucket.",
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Messages limit",
|
||||||
|
"Maximum message updates from one actor during the window. 0 disables this bucket.",
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Button callbacks limit",
|
||||||
|
"Maximum callback-query updates from one actor during the window. 0 disables this bucket.",
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Inline queries limit",
|
||||||
|
"Maximum inline-query updates from one actor during the window. 0 disables this bucket.",
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"/start limit",
|
||||||
|
"Maximum /start messages from one actor during the window. 0 disables this bucket.",
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Expensive callbacks limit",
|
||||||
|
(
|
||||||
|
"Maximum payment, trial, promo and account-changing callbacks from one actor "
|
||||||
|
"during the window. 0 disables this bucket."
|
||||||
|
),
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_ACTION_COOLDOWN_ENABLED",
|
||||||
|
"bool",
|
||||||
|
"system",
|
||||||
|
"Action cooldowns enabled",
|
||||||
|
"Deduplicates repeated payment and trial button presses from the same user.",
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Payment callback cooldown",
|
||||||
|
(
|
||||||
|
"Seconds to suppress an exact repeated payment callback from the same user. "
|
||||||
|
"0 disables this cooldown."
|
||||||
|
),
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
|
SettingField(
|
||||||
|
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
|
||||||
|
"int",
|
||||||
|
"system",
|
||||||
|
"Trial callback cooldown",
|
||||||
|
(
|
||||||
|
"Seconds to suppress an exact repeated trial activation callback from the same user. "
|
||||||
|
"0 disables this cooldown."
|
||||||
|
),
|
||||||
|
min=0,
|
||||||
|
subsection="telegram_antiflood",
|
||||||
|
),
|
||||||
SettingField(
|
SettingField(
|
||||||
"TELEMETRY_ENABLED",
|
"TELEMETRY_ENABLED",
|
||||||
"bool",
|
"bool",
|
||||||
"system",
|
"system",
|
||||||
"Анонимная статистика установки",
|
"Анонимная статистика установки",
|
||||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
|
||||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
|
||||||
"доменов. Помогает понять число активных установок и какие версии "
|
"данных, токенов и доменов. Помогает понять число активных установок, какие "
|
||||||
"используются. Можно отключить здесь без перезапуска.",
|
"версии используются и долю изменённых сборок. Можно отключить здесь без "
|
||||||
|
"перезапуска.",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -734,6 +962,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
"devices": 10,
|
"devices": 10,
|
||||||
"subscription_guides": 10,
|
"subscription_guides": 10,
|
||||||
"system": 12,
|
"system": 12,
|
||||||
|
"migrations": 13,
|
||||||
}
|
}
|
||||||
exclusive_map = {
|
exclusive_map = {
|
||||||
key: opposite
|
key: opposite
|
||||||
|
|||||||
@@ -8,11 +8,18 @@
|
|||||||
<style nonce="__NONCE__">
|
<style nonce="__NONCE__">
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark light;
|
color-scheme: dark light;
|
||||||
|
--accent: #14b86f;
|
||||||
|
--accent-contrast: #03120b;
|
||||||
|
--bg: #0b1017;
|
||||||
|
--panel-3: #344052;
|
||||||
|
--border: #2d3847;
|
||||||
|
--text: #f7fafc;
|
||||||
|
--muted: #aeb8c5;
|
||||||
font-family:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
"Segoe UI", sans-serif;
|
"Segoe UI", sans-serif;
|
||||||
background: #0b1017;
|
background: var(--bg);
|
||||||
color: #f7fafc;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -39,7 +46,7 @@
|
|||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #aeb8c5;
|
color: var(--muted);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
@@ -57,8 +64,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #14b86f;
|
background: var(--accent);
|
||||||
color: #03120b;
|
color: var(--accent-contrast);
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -68,15 +75,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button.secondary {
|
.button.secondary {
|
||||||
border-color: #2d3847;
|
border-color: var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #f7fafc;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button[aria-disabled="true"] {
|
.button[aria-disabled="true"] {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: #344052;
|
background: var(--panel-3);
|
||||||
color: #aeb8c5;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
[hidden] {
|
[hidden] {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
sizes="180x180"
|
sizes="180x180"
|
||||||
href="/apple-touch-icon-precomposed.png"
|
href="/apple-touch-icon-precomposed.png"
|
||||||
/>
|
/>
|
||||||
<title>/minishop</title>
|
<title>Subscription</title>
|
||||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||||
<style>
|
<style>
|
||||||
.app-boot-fallback {
|
.app-boot-fallback {
|
||||||
|
|||||||
@@ -168,6 +168,7 @@
|
|||||||
.theme-key-ascii .admin-toolbar-card,
|
.theme-key-ascii .admin-toolbar-card,
|
||||||
.theme-key-ascii .admin-table-card,
|
.theme-key-ascii .admin-table-card,
|
||||||
.theme-key-ascii .admin-panel-dash-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-trigger,
|
||||||
.theme-key-ascii .admin-select-content,
|
.theme-key-ascii .admin-select-content,
|
||||||
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
||||||
@@ -199,7 +200,8 @@
|
|||||||
.theme-key-ascii .admin-tabs-trigger,
|
.theme-key-ascii .admin-tabs-trigger,
|
||||||
.theme-key-ascii .admin-revenue-period-btn,
|
.theme-key-ascii .admin-revenue-period-btn,
|
||||||
.theme-key-ascii .admin-mobile-toggle,
|
.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: 1px solid #ffffff;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: #000000;
|
background: #000000;
|
||||||
@@ -216,7 +218,8 @@
|
|||||||
.theme-key-ascii .admin-nav-item:hover,
|
.theme-key-ascii .admin-nav-item:hover,
|
||||||
.theme-key-ascii .admin-tabs-trigger:hover,
|
.theme-key-ascii .admin-tabs-trigger:hover,
|
||||||
.theme-key-ascii .admin-revenue-period-btn: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;
|
background: #ffffff;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
@@ -318,6 +321,133 @@
|
|||||||
box-shadow: inset 0 0 0 1px #ffffff;
|
box-shadow: inset 0 0 0 1px #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Admin controls: range sliders and sortable rows ---------- */
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-range-input {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-range-input::before {
|
||||||
|
height: 8px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-range-input__range {
|
||||||
|
height: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-range-input__thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #000000;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-range-input__thumb:hover,
|
||||||
|
.theme-key-ascii .ui-range-input__thumb:focus-visible {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
box-shadow: 0 0 0 1px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable {
|
||||||
|
--sortable-drop-line: #ffffff;
|
||||||
|
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable-item.is-dragging {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable-item.is-drop-target {
|
||||||
|
outline: 1px dashed #ffffff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
|
||||||
|
top: -5px;
|
||||||
|
height: 1px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable-handle {
|
||||||
|
align-self: center;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
background: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-ascii .ui-sortable-handle:hover,
|
||||||
|
.theme-key-ascii .ui-sortable-handle:focus-visible,
|
||||||
|
.theme-key-ascii .ui-sortable-handle:active {
|
||||||
|
background: #ffffff;
|
||||||
|
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 ---------- */
|
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||||
|
|
||||||
.theme-key-ascii .trial-offer-card,
|
.theme-key-ascii .trial-offer-card,
|
||||||
@@ -1104,6 +1234,7 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
|||||||
.admin-cn-card-skeleton--tall,
|
.admin-cn-card-skeleton--tall,
|
||||||
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
||||||
.admin-tabs-trigger, .admin-tabs-list,
|
.admin-tabs-trigger, .admin-tabs-list,
|
||||||
|
.ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle,
|
||||||
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
||||||
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
||||||
.admin-dialog,
|
.admin-dialog,
|
||||||
@@ -1243,3 +1374,51 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
|||||||
.theme-key-ascii table tbody tr:hover td {
|
.theme-key-ascii table tbody tr:hover td {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Newer webapp surfaces: telegram banner, traffic/referral
|
||||||
|
* dropdowns, login language picker. Flatten the accent pills,
|
||||||
|
* rounded badges and colored gradients these ship with so they
|
||||||
|
* read as plain console boxes.
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* Telegram notifications banner: the .card chrome is already
|
||||||
|
* flattened above; only the rounded, color-tinted icon badge needs
|
||||||
|
* squaring off (the Send glyph itself is whitened by the global rule). */
|
||||||
|
.theme-key-ascii .telegram-notifications-icon {
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium-server / referral-tariff dropdown help glyph: drop the
|
||||||
|
* pill background in every state (the accent maps to white here,
|
||||||
|
* which would otherwise paint a white blob behind the icon). */
|
||||||
|
.theme-key-ascii .premium-server-help-icon,
|
||||||
|
.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||||
|
.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon,
|
||||||
|
.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||||
|
.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The check on the selected language sits on a solid white row, so a
|
||||||
|
* white glyph would vanish — invert it to black to keep it readable. */
|
||||||
|
.theme-key-ascii .language-select-item[data-selected] .language-select-item-check {
|
||||||
|
color: #000000 !important;
|
||||||
|
stroke: #000000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login-screen language trigger: square the rounded chip. */
|
||||||
|
.theme-key-ascii .auth-language-trigger {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Render flag emoji as monochrome glyphs to stay in the console palette. */
|
||||||
|
.theme-key-ascii .emoji-flag {
|
||||||
|
filter: grayscale(1) contrast(1.05);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"use_primary_accent": false,
|
"use_primary_accent": false,
|
||||||
"use_in_admin": true,
|
"use_in_admin": true,
|
||||||
"css_file": "style.css",
|
"css_file": "style.css",
|
||||||
"assets_version": 4,
|
"assets_version": 7,
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"color_scheme": "dark",
|
"color_scheme": "dark",
|
||||||
"style_preset": "ascii"
|
"style_preset": "ascii"
|
||||||
|
|||||||
@@ -204,3 +204,77 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
|
|||||||
.theme-key-light .install-loading .ui-spinner {
|
.theme-key-light .install-loading .ui-spinner {
|
||||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin controls: range sliders and sortable rows */
|
||||||
|
|
||||||
|
.theme-key-light .ui-range-input::before {
|
||||||
|
background: rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-range-input__range {
|
||||||
|
background: color-mix(in srgb, var(--accent) 70%, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-range-input__thumb {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 68%, #0f172a);
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-range-input__thumb:focus-visible {
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable-handle {
|
||||||
|
border-radius: 6px;
|
||||||
|
color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable-handle:hover,
|
||||||
|
.theme-key-light .ui-sortable-handle:focus-visible {
|
||||||
|
background: rgba(15, 23, 42, 0.055);
|
||||||
|
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable {
|
||||||
|
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable-item.is-drop-target {
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
|
||||||
|
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-light .ui-sortable-item.is-drop-target::before {
|
||||||
|
background: var(--sortable-drop-line);
|
||||||
|
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 {
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"use_primary_accent": true,
|
"use_primary_accent": true,
|
||||||
"use_in_admin": true,
|
"use_in_admin": true,
|
||||||
"css_file": "style.css",
|
"css_file": "style.css",
|
||||||
"assets_version": 3,
|
"assets_version": 6,
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"color_scheme": "light"
|
"color_scheme": "light"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
.theme-key-windows95 svg.lucide-file-text,
|
.theme-key-windows95 svg.lucide-file-text,
|
||||||
.theme-key-windows95 svg.lucide-gift,
|
.theme-key-windows95 svg.lucide-gift,
|
||||||
.theme-key-windows95 svg.lucide-globe-2,
|
.theme-key-windows95 svg.lucide-globe-2,
|
||||||
|
.theme-key-windows95 svg.lucide-grip-vertical,
|
||||||
.theme-key-windows95 svg.lucide-home,
|
.theme-key-windows95 svg.lucide-home,
|
||||||
.theme-key-windows95 svg.lucide-house,
|
.theme-key-windows95 svg.lucide-house,
|
||||||
.theme-key-windows95 svg.lucide-info,
|
.theme-key-windows95 svg.lucide-info,
|
||||||
@@ -232,6 +233,10 @@
|
|||||||
--win95-button-icon: var(--win95-icon-globe);
|
--win95-button-icon: var(--win95-icon-globe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 svg.lucide-grip-vertical {
|
||||||
|
--win95-button-icon: var(--win95-icon-sliders);
|
||||||
|
}
|
||||||
|
|
||||||
.theme-key-windows95 svg.lucide-file-text {
|
.theme-key-windows95 svg.lucide-file-text {
|
||||||
--win95-button-icon: var(--win95-icon-file-text);
|
--win95-button-icon: var(--win95-icon-file-text);
|
||||||
}
|
}
|
||||||
@@ -375,6 +380,7 @@
|
|||||||
svg.lucide-file-text,
|
svg.lucide-file-text,
|
||||||
svg.lucide-gift,
|
svg.lucide-gift,
|
||||||
svg.lucide-globe-2,
|
svg.lucide-globe-2,
|
||||||
|
svg.lucide-grip-vertical,
|
||||||
svg.lucide-home,
|
svg.lucide-home,
|
||||||
svg.lucide-house,
|
svg.lucide-house,
|
||||||
svg.lucide-info,
|
svg.lucide-info,
|
||||||
@@ -1171,6 +1177,149 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
|||||||
opacity: 0.52;
|
opacity: 0.52;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin controls: range sliders and sortable rows */
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-range-input {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-range-input::before {
|
||||||
|
height: 8px;
|
||||||
|
border: 2px solid;
|
||||||
|
border-color: #404040 #ffffff #ffffff #404040;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #808080,
|
||||||
|
inset -1px -1px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-range-input__range {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-range-input__thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid;
|
||||||
|
border-color: #ffffff #404040 #404040 #ffffff;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: #c0c0c0;
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #dfdfdf,
|
||||||
|
inset -1px -1px 0 #808080;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-range-input__thumb:hover,
|
||||||
|
.theme-key-windows95 .ui-range-input__thumb:focus-visible {
|
||||||
|
background: #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-range-input__thumb[data-active] {
|
||||||
|
border-color: #404040 #ffffff #ffffff #404040;
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #808080,
|
||||||
|
inset -1px -1px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-sortable-item.is-drop-target {
|
||||||
|
outline: 1px dotted #000000;
|
||||||
|
outline-offset: 3px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
|
||||||
|
top: -7px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #000080;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 #ffffff,
|
||||||
|
0 -1px 0 #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-sortable-handle {
|
||||||
|
align-self: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid;
|
||||||
|
border-color: #ffffff #404040 #404040 #ffffff;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #dfdfdf,
|
||||||
|
inset -1px -1px 0 #808080;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-sortable-handle:hover,
|
||||||
|
.theme-key-windows95 .ui-sortable-handle:focus-visible {
|
||||||
|
background: #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .ui-sortable-handle:active {
|
||||||
|
border-color: #404040 #ffffff #ffffff #404040;
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #808080,
|
||||||
|
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 input::placeholder,
|
||||||
.theme-key-windows95 textarea::placeholder,
|
.theme-key-windows95 textarea::placeholder,
|
||||||
.theme-key-windows95 .input::placeholder,
|
.theme-key-windows95 .input::placeholder,
|
||||||
@@ -1352,3 +1501,64 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
|||||||
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
|
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
|
||||||
color: #800080;
|
color: #800080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Newer webapp surfaces: telegram banner, traffic /
|
||||||
|
* referral dropdowns, login language picker ---------- */
|
||||||
|
|
||||||
|
/* Telegram notifications banner: the Card chrome is already beveled by
|
||||||
|
* the shared .card rule; give the icon badge a raised chip look instead
|
||||||
|
* of the rounded, color-tinted default (the Send glyph maps to send.png). */
|
||||||
|
.theme-key-windows95 .telegram-notifications-icon {
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #ffffff #404040 #404040 #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #dfdfdf,
|
||||||
|
inset -1px -1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standalone referral-tariff dropdown and bonus rows: bevel them like the
|
||||||
|
* rest of the surfaces so they don't read as flat 1px boxes. */
|
||||||
|
.theme-key-windows95 .referral-tariff-dropdown,
|
||||||
|
.theme-key-windows95 .referral-bonus-row {
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #ffffff #404040 #404040 #ffffff;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px 0 #dfdfdf,
|
||||||
|
inset -1px -1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-key-windows95 .referral-bonus-row-nested {
|
||||||
|
background: #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium-server / referral help glyph: drop the rounded accent pill so it
|
||||||
|
* sits inline as a plain stroked question mark. */
|
||||||
|
.theme-key-windows95 .premium-server-help-icon,
|
||||||
|
.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||||
|
.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon,
|
||||||
|
.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||||
|
.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The selected language row turns navy; its check maps to a dark bitmap,
|
||||||
|
* so invert it to white to keep it visible. */
|
||||||
|
.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check,
|
||||||
|
.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login-screen language trigger: square the rounded chip. */
|
||||||
|
.theme-key-windows95 .auth-language-trigger {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"use_primary_accent": false,
|
"use_primary_accent": false,
|
||||||
"use_in_admin": true,
|
"use_in_admin": true,
|
||||||
"css_file": "style.css",
|
"css_file": "style.css",
|
||||||
"assets_version": 11,
|
"assets_version": 14,
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"color_scheme": "light",
|
"color_scheme": "light",
|
||||||
"style_preset": "win95"
|
"style_preset": "win95"
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.web_log import AccessLogger, KeyMethod
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from bot.payment_providers import iter_provider_specs, iter_service_keys
|
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
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +22,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
|
|||||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
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(
|
def _inject_shared_instances(
|
||||||
app: web.Application,
|
app: web.Application,
|
||||||
dp: Dispatcher,
|
dp: Dispatcher,
|
||||||
@@ -48,6 +85,8 @@ async def build_and_start_web_app(
|
|||||||
bot: Bot,
|
bot: Bot,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
async_session_factory: sessionmaker,
|
async_session_factory: sessionmaker,
|
||||||
|
*,
|
||||||
|
after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None,
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||||
@@ -110,7 +149,7 @@ async def build_and_start_web_app(
|
|||||||
|
|
||||||
runners = []
|
runners = []
|
||||||
|
|
||||||
webhooks_runner = web.AppRunner(app)
|
webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger)
|
||||||
await webhooks_runner.setup()
|
await webhooks_runner.setup()
|
||||||
runners.append(webhooks_runner)
|
runners.append(webhooks_runner)
|
||||||
site = web.TCPSite(
|
site = web.TCPSite(
|
||||||
@@ -123,6 +162,8 @@ async def build_and_start_web_app(
|
|||||||
logging.info(
|
logging.info(
|
||||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
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:
|
if settings.WEBAPP_ENABLED:
|
||||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||||
@@ -133,7 +174,10 @@ async def build_and_start_web_app(
|
|||||||
settings,
|
settings,
|
||||||
async_session_factory,
|
async_session_factory,
|
||||||
)
|
)
|
||||||
subscription_runner = web.AppRunner(subscription_app)
|
subscription_runner = web.AppRunner(
|
||||||
|
subscription_app,
|
||||||
|
access_log_class=TrustedProxyAccessLogger,
|
||||||
|
)
|
||||||
await subscription_runner.setup()
|
await subscription_runner.setup()
|
||||||
runners.append(subscription_runner)
|
runners.append(subscription_runner)
|
||||||
subscription_site = web.TCPSite(
|
subscription_site = web.TCPSite(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
|
|||||||
verify_webapp_session_token,
|
verify_webapp_session_token,
|
||||||
)
|
)
|
||||||
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
|
||||||
from bot.services.email_templates import render_account_merged
|
from bot.services.email_templates import render_account_merged
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
from bot.services.promo_code_service import PromoCodeService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
@@ -82,7 +82,6 @@ WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
|||||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
|
||||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||||
@@ -92,7 +91,6 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
|||||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
|
||||||
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
||||||
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
||||||
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
||||||
|
|||||||
@@ -11,9 +11,20 @@ from .common import _invalidate_webapp_user_caches
|
|||||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
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:
|
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
|
if not _email_auth_enabled(settings):
|
||||||
|
return _email_auth_not_configured_response()
|
||||||
|
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
|
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
|
||||||
if validation_error:
|
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:
|
async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
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(
|
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||||
request,
|
request,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -55,7 +70,6 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
email = email_payload.email
|
email = email_payload.email
|
||||||
code = str(email_payload.code or "")
|
code = str(email_payload.code or "")
|
||||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||||
settings: Settings = request.app["settings"]
|
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
merge_notice: Optional[Dict[str, Any]] = None
|
merge_notice: Optional[Dict[str, Any]] = None
|
||||||
source_panel_uuid: Optional[str] = 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:
|
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
user_id = _require_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
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_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
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:
|
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||||
user_id = _require_user_id(request)
|
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)
|
payload = await _read_json(request)
|
||||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||||
if validation_error:
|
if validation_error:
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ def create_subscription_webapp_application(
|
|||||||
async def _startup(app_obj: web.Application) -> None:
|
async def _startup(app_obj: web.Application) -> None:
|
||||||
await _ensure_shared_http_session()
|
await _ensure_shared_http_session()
|
||||||
await _warm_webapp_logo_cache(app_obj)
|
await _warm_webapp_logo_cache(app_obj)
|
||||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
|
||||||
await warm_subscription_guides_config(app_obj)
|
await warm_subscription_guides_config(app_obj)
|
||||||
|
|
||||||
async def _shutdown(app_obj: web.Application) -> None:
|
async def _shutdown(app_obj: web.Application) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import gzip
|
|||||||
from config.webapp_themes_config import (
|
from config.webapp_themes_config import (
|
||||||
default_webapp_theme_asset_file,
|
default_webapp_theme_asset_file,
|
||||||
default_webapp_theme_css_files,
|
default_webapp_theme_css_files,
|
||||||
|
effective_webapp_theme_accent,
|
||||||
ensure_default_webapp_theme_descriptor_files,
|
ensure_default_webapp_theme_descriptor_files,
|
||||||
public_theme_payload,
|
public_theme_payload,
|
||||||
public_themes_catalog_payload,
|
public_themes_catalog_payload,
|
||||||
@@ -17,6 +18,8 @@ _GZIP_BODY_CACHE: Dict[str, bytes] = {}
|
|||||||
_ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {}
|
_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]] = {}
|
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
|
||||||
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
|
_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:
|
async def health_route(request: web.Request) -> web.Response:
|
||||||
@@ -47,7 +50,7 @@ async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Respo
|
|||||||
allow_precompressed=bool(asset_hash),
|
allow_precompressed=bool(asset_hash),
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = (
|
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
|
return response
|
||||||
|
|
||||||
@@ -206,9 +209,6 @@ async def theme_asset_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_webapp_logo_url(settings: Settings) -> str:
|
def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||||
if not raw_logo_url:
|
if not raw_logo_url:
|
||||||
return WEBAPP_DEFAULT_LOGO_PATH
|
return WEBAPP_DEFAULT_LOGO_PATH
|
||||||
@@ -303,29 +303,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _emoji_to_codepoints(value: str) -> str:
|
|
||||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
|
|
||||||
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
|
|
||||||
|
|
||||||
|
|
||||||
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
|
|
||||||
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
|
|
||||||
|
|
||||||
|
|
||||||
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
|
|
||||||
codepoints = _emoji_to_codepoints(emoji)
|
|
||||||
if not codepoints or ext not in {"gif", "webp"}:
|
|
||||||
return ""
|
|
||||||
return f"/webapp-emoji/{codepoints}/512.{ext}"
|
|
||||||
|
|
||||||
|
|
||||||
async def webapp_logo_route(request: web.Request) -> web.Response:
|
async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
|
||||||
raise web.HTTPNotFound(text="webapp_logo_disabled")
|
|
||||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||||
if not raw_logo_url:
|
if not raw_logo_url:
|
||||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||||
@@ -405,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))
|
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
|
||||||
digest = _webapp_generated_favicon_digest(favicon_url)
|
digest = _webapp_generated_favicon_digest(favicon_url)
|
||||||
if digest:
|
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)
|
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
|
||||||
if redirect_url:
|
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")
|
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||||
|
|
||||||
@@ -521,37 +504,8 @@ def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Re
|
|||||||
return web.Response(body=body, content_type=content_type)
|
return web.Response(body=body, content_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
|
||||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
|
||||||
ext = str(request.match_info.get("ext") or "").strip().lower()
|
|
||||||
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
|
|
||||||
raise web.HTTPNotFound(text="webapp_emoji_not_found")
|
|
||||||
|
|
||||||
emoji_cache_key = f"{codepoints}:{ext}"
|
|
||||||
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
|
|
||||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
|
||||||
if emoji_cache is None:
|
|
||||||
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
|
||||||
async with cache_lock:
|
|
||||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
|
||||||
if emoji_cache is None:
|
|
||||||
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
|
||||||
if emoji_cache:
|
|
||||||
emoji_caches[emoji_cache_key] = emoji_cache
|
|
||||||
|
|
||||||
if not emoji_cache:
|
|
||||||
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
|
|
||||||
|
|
||||||
body, content_type = emoji_cache
|
|
||||||
response = web.Response(body=body, content_type=content_type)
|
|
||||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
||||||
settings: Settings = app["settings"]
|
settings: Settings = app["settings"]
|
||||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
|
||||||
return
|
|
||||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||||
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
|
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||||
return
|
return
|
||||||
@@ -573,111 +527,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
|
|
||||||
settings: Settings = app["settings"]
|
|
||||||
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
|
||||||
return
|
|
||||||
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
|
|
||||||
return
|
|
||||||
|
|
||||||
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
|
|
||||||
if not codepoints:
|
|
||||||
return
|
|
||||||
|
|
||||||
app.setdefault("webapp_emoji_cache", {})
|
|
||||||
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
|
||||||
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
|
|
||||||
|
|
||||||
for ext in ("gif", "webp"):
|
|
||||||
emoji_cache_key = f"{codepoints}:{ext}"
|
|
||||||
if emoji_cache_key in emoji_caches:
|
|
||||||
continue
|
|
||||||
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
|
||||||
if loaded_emoji:
|
|
||||||
emoji_caches[emoji_cache_key] = loaded_emoji
|
|
||||||
if ext == "gif":
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_or_fetch_webapp_animated_emoji(
|
|
||||||
codepoints: str, ext: str
|
|
||||||
) -> Optional[Tuple[bytes, str]]:
|
|
||||||
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
|
|
||||||
if disk_emoji:
|
|
||||||
return disk_emoji
|
|
||||||
|
|
||||||
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
|
|
||||||
if fetched_emoji:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
|
|
||||||
)
|
|
||||||
return fetched_emoji
|
|
||||||
|
|
||||||
|
|
||||||
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
|
||||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
|
||||||
try:
|
|
||||||
body = path.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
|
||||||
return None
|
|
||||||
return body, "image/gif" if ext == "gif" else "image/webp"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_webapp_animated_emoji_to_disk(
|
|
||||||
codepoints: str, ext: str, emoji: Tuple[bytes, str]
|
|
||||||
) -> None:
|
|
||||||
body, _content_type = emoji
|
|
||||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
|
||||||
return
|
|
||||||
|
|
||||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
|
||||||
try:
|
|
||||||
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_bytes(body)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
|
||||||
try:
|
|
||||||
session = await _get_shared_http_session()
|
|
||||||
timeout = ClientTimeout(total=4)
|
|
||||||
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
|
|
||||||
async with session.get(
|
|
||||||
source_url,
|
|
||||||
allow_redirects=False,
|
|
||||||
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
|
|
||||||
timeout=timeout,
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
return None
|
|
||||||
|
|
||||||
content_type = (
|
|
||||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
|
||||||
)
|
|
||||||
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
|
|
||||||
if content_type and content_type != expected_content_type:
|
|
||||||
return None
|
|
||||||
|
|
||||||
body = bytearray()
|
|
||||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
|
||||||
body.extend(chunk)
|
|
||||||
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
|
||||||
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return bytes(body), expected_content_type
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||||
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
|
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
|
||||||
if disk_logo:
|
if disk_logo:
|
||||||
@@ -923,7 +772,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
|||||||
"traffic_packages": settings.traffic_packages,
|
"traffic_packages": settings.traffic_packages,
|
||||||
"stars_traffic_packages": settings.stars_traffic_packages,
|
"stars_traffic_packages": settings.stars_traffic_packages,
|
||||||
"support_url": settings.SUPPORT_LINK or "",
|
"support_url": settings.SUPPORT_LINK or "",
|
||||||
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
|
|
||||||
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
|
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
|
||||||
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
|
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
|
||||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
@@ -937,9 +785,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
|||||||
def _resolve_app_version() -> str:
|
def _resolve_app_version() -> str:
|
||||||
# Single source of truth shared with the telemetry worker so the admin
|
# Single source of truth shared with the telemetry worker so the admin
|
||||||
# sidebar and the install beacon always report the same version.
|
# sidebar and the install beacon always report the same version.
|
||||||
from bot.utils.app_version import resolve_app_version
|
from bot.utils import app_version as app_version_module
|
||||||
|
|
||||||
return resolve_app_version()
|
global _APP_VERSION_CACHE
|
||||||
|
|
||||||
|
app_version_module.APP_ROOT = APP_ROOT
|
||||||
|
app_version_module._run_git_command = _run_git_command
|
||||||
|
app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE
|
||||||
|
version = app_version_module.resolve_app_version()
|
||||||
|
_APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git_command(*args: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=APP_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
async def _enforce_webapp_rate_limit(
|
async def _enforce_webapp_rate_limit(
|
||||||
@@ -1033,7 +903,7 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
|
|||||||
strip_dev_mock=not asset_hash,
|
strip_dev_mock=not asset_hash,
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = (
|
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
|
return response
|
||||||
|
|
||||||
@@ -1144,9 +1014,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
|||||||
"themesDir": settings.WEBAPP_THEMES_DIR,
|
"themesDir": settings.WEBAPP_THEMES_DIR,
|
||||||
"themePreviewKey": preview_key,
|
"themePreviewKey": preview_key,
|
||||||
"logoUrl": cached["logo_url"],
|
"logoUrl": cached["logo_url"],
|
||||||
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
|
|
||||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
|
||||||
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
|
||||||
"faviconUrl": cached["favicon_url"],
|
"faviconUrl": cached["favicon_url"],
|
||||||
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
||||||
"apiBase": "/api",
|
"apiBase": "/api",
|
||||||
@@ -1157,7 +1024,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
|||||||
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
|
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
|
||||||
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
|
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
|
||||||
"supportUrl": cached["support_url"],
|
"supportUrl": cached["support_url"],
|
||||||
"termsUrl": cached["terms_url"],
|
|
||||||
"privacyPolicyUrl": cached["privacy_policy_url"],
|
"privacyPolicyUrl": cached["privacy_policy_url"],
|
||||||
"userAgreementUrl": cached["user_agreement_url"],
|
"userAgreementUrl": cached["user_agreement_url"],
|
||||||
"currency": cached["currency"],
|
"currency": cached["currency"],
|
||||||
@@ -1276,9 +1142,11 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
bootstrap = _build_webapp_bootstrap_payload(request)
|
bootstrap = _build_webapp_bootstrap_payload(request)
|
||||||
config = bootstrap["config"]
|
config = bootstrap["config"]
|
||||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
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(
|
html = html.replace(
|
||||||
'href="/subscription_webapp.css"',
|
'href="/subscription_webapp.css"',
|
||||||
f'href="/{_resolve_webapp_css_asset_name()}"',
|
f'href="/{css_asset_name}"',
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||||
@@ -1305,15 +1173,9 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
WEBAPP_JS_PLACEHOLDER,
|
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"]
|
brand_asset_url = cached["logo_url"]
|
||||||
if (
|
|
||||||
not brand_asset_url
|
|
||||||
and settings.WEBAPP_LOGO_USE_EMOJI
|
|
||||||
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
|
|
||||||
):
|
|
||||||
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
|
|
||||||
if brand_asset_url:
|
if brand_asset_url:
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
"</head>",
|
"</head>",
|
||||||
@@ -1324,7 +1186,9 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
response = web.Response(text=html, content_type="text/html", charset="utf-8")
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -1335,6 +1199,11 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||||
query = getattr(request, "query", {}) or {}
|
query = getattr(request, "query", {}) or {}
|
||||||
|
themes_catalog = getattr(settings, "webapp_themes_catalog", None)
|
||||||
|
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) or "#00fe7a"
|
||||||
|
initial_theme = (
|
||||||
|
_initial_theme_for_request(request, themes_catalog) if themes_catalog is not None else None
|
||||||
|
)
|
||||||
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
||||||
messages = _app_deeplink_i18n_payload(request, lang)
|
messages = _app_deeplink_i18n_payload(request, lang)
|
||||||
page_title = _webapp_page_title(settings, messages["title"])
|
page_title = _webapp_page_title(settings, messages["title"])
|
||||||
@@ -1351,6 +1220,14 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
|
|||||||
.replace("__NONCE__", nonce)
|
.replace("__NONCE__", nonce)
|
||||||
.replace("__MESSAGES_JSON__", messages_json)
|
.replace("__MESSAGES_JSON__", messages_json)
|
||||||
)
|
)
|
||||||
|
initial_theme_markup = _app_deeplink_theme_head_markup(
|
||||||
|
request,
|
||||||
|
initial_theme,
|
||||||
|
themes_catalog,
|
||||||
|
primary_color,
|
||||||
|
)
|
||||||
|
if initial_theme_markup:
|
||||||
|
html_text = html_text.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
||||||
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
|
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
|
||||||
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
@@ -1577,10 +1454,15 @@ def _resolve_webapp_js_asset_name() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_webapp_admin_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
|
# The admin bundle is lazy-loaded from the already running Mini App. It now
|
||||||
# deployments where nginx serves static files in front of aiohttp, stale
|
# ships content-hashed alongside the main bundle (same build, deterministic
|
||||||
# hashed admin filenames can 404 even though the runtime build asset exists.
|
# hashes, served immutable), so iOS WebViews fetch fresh admin assets on every
|
||||||
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
|
# 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:
|
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||||
@@ -1599,7 +1481,7 @@ def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
|||||||
if minified_assets:
|
if minified_assets:
|
||||||
minified_assets.sort(reverse=True)
|
minified_assets.sort(reverse=True)
|
||||||
return _set_cached_asset_name(kind, minified_assets[0][1])
|
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:
|
def _resolve_webapp_css_asset_name() -> str:
|
||||||
@@ -1610,9 +1492,11 @@ def _resolve_webapp_css_asset_name() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_webapp_admin_css_asset_name() -> str:
|
def _resolve_webapp_admin_css_asset_name() -> str:
|
||||||
# Keep the lazy-loaded admin stylesheet on the stable build filename for
|
# Content-hashed and immutable, same rationale as the admin JS bundle above.
|
||||||
# the same reason as the JS bundle above.
|
return _resolve_hashed_css_asset_name(
|
||||||
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
|
kind="admin-css",
|
||||||
|
base_name="subscription_webapp_admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||||
@@ -1631,7 +1515,19 @@ def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
|||||||
if hashed_assets:
|
if hashed_assets:
|
||||||
hashed_assets.sort(reverse=True)
|
hashed_assets.sort(reverse=True)
|
||||||
return _set_cached_asset_name(kind, hashed_assets[0][1])
|
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]:
|
def _get_cached_asset_name(kind: str) -> Optional[str]:
|
||||||
@@ -1683,6 +1579,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
|
|||||||
"font_sans": "--font-sans",
|
"font_sans": "--font-sans",
|
||||||
"font_logo": "--font-logo",
|
"font_logo": "--font-logo",
|
||||||
"font_mono": "--font-mono",
|
"font_mono": "--font-mono",
|
||||||
|
"home_logo_scale": "--home-logo-scale",
|
||||||
|
"home_logo_scale_desktop": "--home-logo-scale-desktop",
|
||||||
|
"home_logo_scale_mobile": "--home-logo-scale-mobile",
|
||||||
"admin_bg": "--admin-bg",
|
"admin_bg": "--admin-bg",
|
||||||
"admin_surface": "--admin-surface",
|
"admin_surface": "--admin-surface",
|
||||||
"admin_surface_2": "--admin-surface-2",
|
"admin_surface_2": "--admin-surface-2",
|
||||||
@@ -1694,6 +1593,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
|
|||||||
"admin_dim": "--admin-dim",
|
"admin_dim": "--admin-dim",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_INITIAL_THEME_LOGO_SCALE_TOKENS = {
|
||||||
|
"home_logo_scale",
|
||||||
|
"home_logo_scale_desktop",
|
||||||
|
"home_logo_scale_mobile",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _theme_css_href_for_html(theme: Any) -> str:
|
def _theme_css_href_for_html(theme: Any) -> str:
|
||||||
css_file = str(getattr(theme, "css_file", "") or "").strip()
|
css_file = str(getattr(theme, "css_file", "") or "").strip()
|
||||||
@@ -1716,7 +1621,8 @@ def _theme_css_href_for_html(theme: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
||||||
preview_key = str(request.query.get("theme_preview") or "").strip()
|
query = getattr(request, "query", {}) or {}
|
||||||
|
preview_key = str(query.get("theme_preview") or "").strip()
|
||||||
if preview_key:
|
if preview_key:
|
||||||
preview_theme = catalog.theme_by_key(preview_key)
|
preview_theme = catalog.theme_by_key(preview_key)
|
||||||
if preview_theme is not None and preview_theme.enabled:
|
if preview_theme is not None and preview_theme.enabled:
|
||||||
@@ -1728,18 +1634,38 @@ def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
|||||||
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
|
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_theme_tokens(theme: Any, primary_color: str) -> Dict[str, Any]:
|
||||||
|
if theme is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload = public_theme_payload(theme, primary_color)
|
||||||
|
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
|
||||||
|
return tokens if isinstance(tokens, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_theme_declarations(tokens: Dict[str, Any]) -> List[str]:
|
||||||
|
declarations = []
|
||||||
|
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
|
||||||
|
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
|
||||||
|
try:
|
||||||
|
scale = float(tokens.get(token_key) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if scale > 0:
|
||||||
|
declarations.append(f"{css_name}:{scale / 100:g}")
|
||||||
|
continue
|
||||||
|
value = str(tokens.get(token_key) or "").strip()
|
||||||
|
if value:
|
||||||
|
declarations.append(f"{css_name}:{value}")
|
||||||
|
return declarations
|
||||||
|
|
||||||
|
|
||||||
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
|
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
|
||||||
if theme is None:
|
if theme is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
payload = public_theme_payload(theme, primary_color)
|
tokens = _initial_theme_tokens(theme, primary_color)
|
||||||
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
|
declarations = _initial_theme_declarations(tokens)
|
||||||
tokens = tokens if isinstance(tokens, dict) else {}
|
|
||||||
declarations = []
|
|
||||||
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
|
|
||||||
value = str(tokens.get(token_key) or "").strip()
|
|
||||||
if value:
|
|
||||||
declarations.append(f"{css_name}:{value}")
|
|
||||||
|
|
||||||
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
|
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
|
||||||
bg = str(tokens.get("bg") or "").strip()
|
bg = str(tokens.get("bg") or "").strip()
|
||||||
@@ -1763,6 +1689,37 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
|
|||||||
return stylesheet + "\n" + style_tag
|
return stylesheet + "\n" + style_tag
|
||||||
|
|
||||||
|
|
||||||
|
def _app_deeplink_theme_head_markup(
|
||||||
|
request: web.Request,
|
||||||
|
theme: Any,
|
||||||
|
catalog: Any,
|
||||||
|
primary_color: str,
|
||||||
|
) -> str:
|
||||||
|
tokens = _initial_theme_tokens(theme, primary_color)
|
||||||
|
declarations = _initial_theme_declarations(tokens)
|
||||||
|
try:
|
||||||
|
accent = effective_webapp_theme_accent(
|
||||||
|
catalog,
|
||||||
|
primary_color,
|
||||||
|
theme_key=str(getattr(theme, "key", "") or "") or None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
accent = str(primary_color or "#00fe7a").strip() or "#00fe7a"
|
||||||
|
if accent and not any(item.startswith("--accent:") for item in declarations):
|
||||||
|
declarations.insert(0, f"--accent:{accent}")
|
||||||
|
if not declarations:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
|
||||||
|
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||||
|
return (
|
||||||
|
f'<style id="webapp-initial-theme" nonce="{nonce}">'
|
||||||
|
f"html{{color-scheme:{scheme};}}"
|
||||||
|
f":root{{{';'.join(declarations)}}}"
|
||||||
|
"</style>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _favicon_head_markup(favicon_url: str) -> str:
|
def _favicon_head_markup(favicon_url: str) -> str:
|
||||||
href = str(favicon_url or "").strip()
|
href = str(favicon_url or "").strip()
|
||||||
if not href:
|
if not href:
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
|||||||
session,
|
session,
|
||||||
referral_param,
|
referral_param,
|
||||||
current_user_id=None,
|
current_user_id=None,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
db_user, _ = await user_dal.create_email_user(
|
db_user, _ = await user_dal.create_email_user(
|
||||||
session,
|
session,
|
||||||
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
|||||||
session,
|
session,
|
||||||
referral_param,
|
referral_param,
|
||||||
current_user_id=None,
|
current_user_id=None,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
db_user, _ = await user_dal.create_email_user(
|
db_user, _ = await user_dal.create_email_user(
|
||||||
session,
|
session,
|
||||||
@@ -1010,13 +1012,53 @@ async def _request_email_code(
|
|||||||
|
|
||||||
|
|
||||||
def _telegram_id_for_user(user: User) -> Optional[int]:
|
def _telegram_id_for_user(user: User) -> Optional[int]:
|
||||||
if user.telegram_id:
|
telegram_id = getattr(user, "telegram_id", None)
|
||||||
return int(user.telegram_id)
|
if telegram_id:
|
||||||
if user.user_id and int(user.user_id) > 0:
|
return int(telegram_id)
|
||||||
return int(user.user_id)
|
user_id = getattr(user, "user_id", None)
|
||||||
|
if user_id and int(user_id) > 0:
|
||||||
|
return int(user_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_has_linked_telegram(user: User) -> bool:
|
||||||
|
return bool(getattr(user, "telegram_id", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _email_only_telegram_required_reason(
|
||||||
|
settings: Settings,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
without_telegram_enabled_attr: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if _user_has_linked_telegram(user):
|
||||||
|
return None
|
||||||
|
if is_disposable_email(getattr(user, "email", None), settings):
|
||||||
|
return "disposable_email"
|
||||||
|
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
|
||||||
|
return "telegram_required"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
|
||||||
|
return _email_only_telegram_required_reason(
|
||||||
|
settings,
|
||||||
|
user,
|
||||||
|
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _referral_welcome_telegram_required_reason(
|
||||||
|
settings: Settings,
|
||||||
|
user: User,
|
||||||
|
) -> Optional[str]:
|
||||||
|
return _email_only_telegram_required_reason(
|
||||||
|
settings,
|
||||||
|
user,
|
||||||
|
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _panel_description_for_user(user: User) -> str:
|
def _panel_description_for_user(user: User) -> str:
|
||||||
return panel_description_from_profile(
|
return panel_description_from_profile(
|
||||||
user.username,
|
user.username,
|
||||||
@@ -1292,17 +1334,35 @@ async def _link_telegram_to_user(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
|
||||||
|
if settings is None:
|
||||||
|
return False
|
||||||
|
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_referral_param_prefix(
|
||||||
|
raw: Optional[str],
|
||||||
|
*,
|
||||||
|
preserve_current_u_prefix: bool,
|
||||||
|
) -> str:
|
||||||
value = (raw or "").strip()
|
value = (raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return ""
|
||||||
|
|
||||||
value_lower = value.lower()
|
value_lower = value.lower()
|
||||||
if value_lower.startswith("ref_u"):
|
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
|
||||||
value = value[5:]
|
value = value[5:]
|
||||||
elif value_lower.startswith("ref_"):
|
elif value_lower.startswith("ref_"):
|
||||||
value = value[4:]
|
value = value[4:]
|
||||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||||
|
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value and value[0].lower() == "u" and len(value) == 10:
|
||||||
value = value[1:]
|
value = value[1:]
|
||||||
|
|
||||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||||
@@ -1310,27 +1370,65 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
|||||||
return value.upper()
|
return value.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _referral_param_lookup_candidates(
|
||||||
|
raw: Optional[str],
|
||||||
|
*,
|
||||||
|
remnashop_compat: bool,
|
||||||
|
) -> List[str]:
|
||||||
|
if not remnashop_compat:
|
||||||
|
normalized = _normalize_referral_param(raw)
|
||||||
|
return [normalized] if normalized else []
|
||||||
|
|
||||||
|
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
|
||||||
|
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = [value]
|
||||||
|
if value and value[0].lower() == "u":
|
||||||
|
candidates.append(value[1:])
|
||||||
|
|
||||||
|
unique: List[str] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate and candidate not in unique:
|
||||||
|
unique.append(candidate)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_referrer_id(
|
async def _resolve_referrer_id(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
raw_referral_param: Optional[str],
|
raw_referral_param: Optional[str],
|
||||||
*,
|
*,
|
||||||
current_user_id: Optional[int],
|
current_user_id: Optional[int],
|
||||||
|
settings: Optional[Settings] = None,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
normalized = _normalize_referral_param(raw_referral_param)
|
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||||
if not normalized:
|
candidates = _referral_param_lookup_candidates(
|
||||||
|
raw_referral_param,
|
||||||
|
remnashop_compat=remnashop_compat,
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
for normalized in candidates:
|
||||||
ref_user = None
|
ref_user = None
|
||||||
if normalized.isdigit():
|
if normalized.isdigit() and not remnashop_compat:
|
||||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||||
if not ref_user:
|
if not ref_user:
|
||||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
ref_user = await user_dal.get_user_by_referral_code(
|
||||||
|
session,
|
||||||
|
normalized,
|
||||||
|
include_legacy=remnashop_compat,
|
||||||
|
)
|
||||||
|
if not ref_user and normalized.isdigit() and remnashop_compat:
|
||||||
|
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||||
if not ref_user:
|
if not ref_user:
|
||||||
return None
|
continue
|
||||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||||
return None
|
continue
|
||||||
return int(ref_user.user_id)
|
return int(ref_user.user_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _apply_referral_to_existing_user(
|
async def _apply_referral_to_existing_user(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
@@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user(
|
|||||||
session,
|
session,
|
||||||
raw_referral_param,
|
raw_referral_param,
|
||||||
current_user_id=int(user.user_id),
|
current_user_id=int(user.user_id),
|
||||||
|
settings=request.app["settings"],
|
||||||
)
|
)
|
||||||
if not referred_by_id:
|
if not referred_by_id:
|
||||||
return False
|
return False
|
||||||
@@ -1374,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
|
|||||||
if not raw_referral_param or not user.referred_by_id:
|
if not raw_referral_param or not user.referred_by_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
if _referral_welcome_telegram_required_reason(settings, user):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
|
||||||
|
|
||||||
|
|
||||||
|
async def _grant_referral_welcome_bonus_if_eligible(
|
||||||
|
request: web.Request,
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
) -> Optional[datetime]:
|
||||||
|
if not user.referred_by_id:
|
||||||
|
return None
|
||||||
|
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
referral_welcome_days = max(
|
referral_welcome_days = max(
|
||||||
0,
|
0,
|
||||||
@@ -1383,6 +1497,10 @@ async def _apply_referral_welcome_bonus_if_needed(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
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:
|
try:
|
||||||
if await subscription_service.has_active_subscription(session, int(user.user_id)):
|
if await subscription_service.has_active_subscription(session, int(user.user_id)):
|
||||||
return None
|
return None
|
||||||
@@ -1394,6 +1512,68 @@ async def _apply_referral_welcome_bonus_if_needed(
|
|||||||
int(user.user_id),
|
int(user.user_id),
|
||||||
referral_welcome_days,
|
referral_welcome_days,
|
||||||
reason="referral_welcome_bonus",
|
reason="referral_welcome_bonus",
|
||||||
|
tariff_key=default_tariff_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
|
||||||
|
user_id = _require_user_id(request)
|
||||||
|
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||||
|
request,
|
||||||
|
user_id=user_id,
|
||||||
|
action="referral_welcome_claim",
|
||||||
|
)
|
||||||
|
if rate_limit_response:
|
||||||
|
return rate_limit_response
|
||||||
|
|
||||||
|
settings: Settings = request.app["settings"]
|
||||||
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or db_user.is_banned:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
|
||||||
|
reason = _referral_welcome_telegram_required_reason(settings, db_user)
|
||||||
|
if reason:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(400, "referral_welcome_telegram_required", reason)
|
||||||
|
|
||||||
|
end_date = await _grant_referral_welcome_bonus_if_eligible(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
db_user,
|
||||||
|
)
|
||||||
|
if not end_date:
|
||||||
|
await session.rollback()
|
||||||
|
return _json_error(
|
||||||
|
400,
|
||||||
|
"referral_welcome_unavailable",
|
||||||
|
"Referral welcome bonus is not available",
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Referral welcome bonus claim failed")
|
||||||
|
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
|
||||||
|
|
||||||
|
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"claimed": True,
|
||||||
|
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
|
||||||
|
"end_date_text": _webapp_datetime_text(end_date),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1427,6 +1607,7 @@ async def _ensure_user_from_telegram(
|
|||||||
session,
|
session,
|
||||||
referral_param or telegram_user.get("start_param"),
|
referral_param or telegram_user.get("start_param"),
|
||||||
current_user_id=user_id,
|
current_user_id=user_id,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
db_user, created = await user_dal.create_user(
|
db_user, created = await user_dal.create_user(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.app.web.webapp.auth import _trial_telegram_required_reason
|
||||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||||
|
from db.dal import message_log_dal
|
||||||
|
|
||||||
|
|
||||||
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_text_message(value: Any) -> str:
|
||||||
|
"""Strip Telegram-style HTML markup from a localized message for the web app."""
|
||||||
|
text = _HTML_TAG_RE.sub("", str(value))
|
||||||
|
return html.unescape(text).strip()
|
||||||
|
|
||||||
|
|
||||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||||
@@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return _json_error(400, "promo_apply_failed", str(result))
|
return _json_error(400, "promo_apply_failed", _plain_text_message(result))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
end_date = result if isinstance(result, datetime) else None
|
end_date = result if isinstance(result, datetime) else None
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -111,10 +122,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
hwid_quote: Optional[Dict[str, Any]] = None
|
hwid_quote: Optional[Dict[str, Any]] = None
|
||||||
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
|
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
|
||||||
|
|
||||||
|
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
|
||||||
|
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
|
||||||
if tariffs_config and requested_sale_mode in {
|
if tariffs_config and requested_sale_mode in {
|
||||||
"hwid_device",
|
"hwid_device",
|
||||||
"hwid_devices",
|
"hwid_devices",
|
||||||
"hwid_devices_renewal",
|
|
||||||
}:
|
}:
|
||||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||||
if not tariff_key:
|
if not tariff_key:
|
||||||
@@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
device_count=int(payment_units),
|
device_count=int(payment_units),
|
||||||
tariff_key=sale_tariff_key,
|
tariff_key=sale_tariff_key,
|
||||||
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
|
renewal=False,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
)
|
)
|
||||||
if not hwid_quote:
|
if not hwid_quote:
|
||||||
@@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
|||||||
else:
|
else:
|
||||||
price = float(hwid_quote["price"])
|
price = float(hwid_quote["price"])
|
||||||
stars_price = None
|
stars_price = None
|
||||||
|
elif _sale_mode_base(sale_mode) == "subscription" and bool(
|
||||||
|
payment_payload.renew_hwid_devices
|
||||||
|
):
|
||||||
|
currency = "stars" if method == "stars" else default_currency
|
||||||
|
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
|
||||||
|
if sale_tariff_key:
|
||||||
|
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
target_tariff_key=sale_tariff_key,
|
||||||
|
months=int(payment_units),
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
if hwid_quote:
|
||||||
|
if method == "stars":
|
||||||
|
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
|
||||||
|
else:
|
||||||
|
price = float(price or 0) + float(hwid_quote["price"])
|
||||||
|
stars_price = None
|
||||||
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
|
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
|
||||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||||
return await _create_subscription_payment(
|
return await _create_subscription_payment(
|
||||||
@@ -360,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
|||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user or db_user.is_banned:
|
if not db_user or db_user.is_banned:
|
||||||
return _json_error(403, "access_denied", "Access denied")
|
return _json_error(403, "access_denied", "Access denied")
|
||||||
|
telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
|
||||||
|
if telegram_required_reason:
|
||||||
|
return _json_error(
|
||||||
|
400,
|
||||||
|
"trial_telegram_required",
|
||||||
|
telegram_required_reason,
|
||||||
|
)
|
||||||
|
|
||||||
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
|
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
|
||||||
if not activation_result or not activation_result.get("activated"):
|
if not activation_result or not activation_result.get("activated"):
|
||||||
@@ -395,6 +433,28 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to send WebApp trial activation notification")
|
logger.exception("Failed to send WebApp trial activation notification")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"telegram_username": getattr(db_user, "username", None),
|
||||||
|
"telegram_first_name": getattr(db_user, "first_name", None),
|
||||||
|
"event_type": "webapp_trial_activate",
|
||||||
|
"content": (
|
||||||
|
f"Trial activated via WebApp for user_id={user_id}; "
|
||||||
|
f"email={getattr(db_user, 'email', None) or 'N/A'}"
|
||||||
|
),
|
||||||
|
"is_admin_event": False,
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to add WebApp trial activation audit log")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from db.dal import ad_dal as _ad_dal
|
from db.dal import ad_dal as _ad_dal
|
||||||
|
|
||||||
@@ -659,7 +719,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
|
||||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||||
extra_hwid_valid_until_text = (
|
extra_hwid_valid_until_text = (
|
||||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||||
@@ -681,7 +740,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
device_count=count,
|
device_count=count,
|
||||||
tariff_key=tariff.key,
|
tariff_key=tariff.key,
|
||||||
renewal=renewal_available,
|
renewal=False,
|
||||||
currency=default_currency,
|
currency=default_currency,
|
||||||
)
|
)
|
||||||
if count in currency_counts
|
if count in currency_counts
|
||||||
@@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
device_count=count,
|
device_count=count,
|
||||||
tariff_key=tariff.key,
|
tariff_key=tariff.key,
|
||||||
renewal=renewal_available,
|
renewal=False,
|
||||||
currency="stars",
|
currency="stars",
|
||||||
)
|
)
|
||||||
if count in stars_counts
|
if count in stars_counts
|
||||||
@@ -701,28 +760,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
if not currency_quote and not stars_quote:
|
if not currency_quote and not stars_quote:
|
||||||
continue
|
continue
|
||||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
quote = currency_quote or stars_quote
|
||||||
|
valid_from = quote.get("valid_from")
|
||||||
|
valid_until = quote.get("valid_until")
|
||||||
plan = {
|
plan = {
|
||||||
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
|
"id": f"{tariff.key}:hwid:{count}",
|
||||||
"tariff_key": tariff.key,
|
"tariff_key": tariff.key,
|
||||||
"tariff_name": tariff.name(lang),
|
"tariff_name": tariff.name(lang),
|
||||||
"billing_model": tariff.billing_model,
|
"billing_model": tariff.billing_model,
|
||||||
"sale_mode": sale_mode_for_plan,
|
"sale_mode": "hwid_devices",
|
||||||
|
"renewal": False,
|
||||||
"months": count,
|
"months": count,
|
||||||
"device_count": count,
|
"device_count": count,
|
||||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||||
"currency": default_currency_code,
|
"currency": default_currency_code,
|
||||||
"title": f"+{count}",
|
"title": f"+{count}",
|
||||||
"subtitle": tariff.name(lang),
|
"subtitle": tariff.name(lang),
|
||||||
"valid_from": _billing_iso_datetime(
|
"valid_from": _billing_iso_datetime(valid_from),
|
||||||
(currency_quote or stars_quote).get("valid_from")
|
"valid_from_text": _billing_datetime_text(valid_from),
|
||||||
),
|
"valid_until": _billing_iso_datetime(valid_until),
|
||||||
"valid_until": _billing_iso_datetime(
|
"valid_until_text": _billing_datetime_text(valid_until),
|
||||||
(currency_quote or stars_quote).get("valid_until")
|
"proration_ratio": float(quote.get("proration_ratio") or 0),
|
||||||
),
|
|
||||||
"proration_ratio": float(
|
|
||||||
(currency_quote or stars_quote).get("proration_ratio") or 0
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||||
plan["stars_price"] = int(stars_quote["price"])
|
plan["stars_price"] = int(stars_quote["price"])
|
||||||
@@ -738,10 +796,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
|||||||
else int(sub.extra_hwid_devices or 0),
|
else int(sub.extra_hwid_devices or 0),
|
||||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||||
"renewal_available": renewal_available,
|
"renewal_available": False,
|
||||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
"renewal_recommended_count": 0,
|
||||||
if active and renewal_available
|
|
||||||
else 0,
|
|
||||||
"plans": plans,
|
"plans": plans,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -907,7 +963,11 @@ async def payment_status_route(request: web.Request) -> web.Response:
|
|||||||
payment = await _refresh_yookassa_payment_status(request, session, payment)
|
payment = await _refresh_yookassa_payment_status(request, session, payment)
|
||||||
payment = await _refresh_wata_payment_status(request, session, payment)
|
payment = await _refresh_wata_payment_status(request, session, payment)
|
||||||
if payment.status == "succeeded":
|
if payment.status == "succeeded":
|
||||||
await invalidate_webapp_user_caches(request.app["settings"], user_id)
|
await invalidate_webapp_user_caches(
|
||||||
|
request.app["settings"],
|
||||||
|
user_id,
|
||||||
|
include_devices=True,
|
||||||
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -992,8 +1052,23 @@ async def _create_subscription_payment(
|
|||||||
"unsupported_currency",
|
"unsupported_currency",
|
||||||
"Payment method does not support this currency",
|
"Payment method does not support this currency",
|
||||||
)
|
)
|
||||||
return await provider_spec.create_webapp_payment(
|
if not provider_spec.is_usable_for_payment_amount(
|
||||||
WebAppPaymentContext(
|
settings,
|
||||||
|
payment_currency,
|
||||||
|
price,
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"WebApp payment method does not support amount: method=%s amount=%s currency=%s",
|
||||||
|
method,
|
||||||
|
price,
|
||||||
|
payment_currency,
|
||||||
|
)
|
||||||
|
return _json_error(
|
||||||
|
400,
|
||||||
|
"payment_amount_below_minimum",
|
||||||
|
"Payment amount is below the provider minimum",
|
||||||
|
)
|
||||||
|
payment_context = WebAppPaymentContext(
|
||||||
request=request,
|
request=request,
|
||||||
session=session,
|
session=session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -1005,6 +1080,7 @@ async def _create_subscription_payment(
|
|||||||
description=description,
|
description=description,
|
||||||
sale_mode=sale_mode,
|
sale_mode=sale_mode,
|
||||||
traffic_gb=traffic_gb,
|
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_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_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||||
@@ -1013,6 +1089,23 @@ async def _create_subscription_payment(
|
|||||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") 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,
|
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")
|
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
|
|||||||
device_count: Any = None
|
device_count: Any = None
|
||||||
tariff_key: Optional[constr(max_length=128)] = None
|
tariff_key: Optional[constr(max_length=128)] = None
|
||||||
sale_mode: Optional[constr(max_length=64)] = None
|
sale_mode: Optional[constr(max_length=64)] = None
|
||||||
|
renew_hwid_devices: Optional[bool] = None
|
||||||
description: Optional[constr(max_length=4096)] = None
|
description: Optional[constr(max_length=4096)] = None
|
||||||
comment: Optional[constr(max_length=4096)] = None
|
comment: Optional[constr(max_length=4096)] = None
|
||||||
note: Optional[constr(max_length=4096)] = None
|
note: Optional[constr(max_length=4096)] = None
|
||||||
|
|||||||
@@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||||
webapp_favicon_route,
|
webapp_favicon_route,
|
||||||
)
|
)
|
||||||
app.router.add_get(
|
|
||||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
|
||||||
webapp_animated_emoji_route,
|
|
||||||
)
|
|
||||||
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
@@ -89,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
|||||||
"/api/account/telegram/notifications/probe",
|
"/api/account/telegram/notifications/probe",
|
||||||
account_telegram_notifications_probe_route,
|
account_telegram_notifications_probe_route,
|
||||||
)
|
)
|
||||||
|
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route)
|
||||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||||
app.router.add_get("/api/devices", devices_route)
|
app.router.add_get("/api/devices", devices_route)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
|
||||||
|
from bot.app.web.webapp.auth import (
|
||||||
|
_referral_welcome_telegram_required_reason,
|
||||||
|
_trial_telegram_required_reason,
|
||||||
|
_user_has_linked_telegram,
|
||||||
|
)
|
||||||
from config.subscription_guides_config import subscription_guides_available
|
from config.subscription_guides_config import subscription_guides_available
|
||||||
from config.webapp_themes_config import public_themes_catalog_payload
|
from config.webapp_themes_config import public_themes_catalog_payload
|
||||||
from bot.services.telegram_notifications import (
|
from bot.services.telegram_notifications import (
|
||||||
@@ -64,20 +69,48 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
if active and local_sub
|
if active and local_sub
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
trial_available = bool(
|
trial_base_available = bool(
|
||||||
settings.TRIAL_ENABLED
|
settings.TRIAL_ENABLED
|
||||||
and settings.TRIAL_DURATION_DAYS > 0
|
and settings.TRIAL_DURATION_DAYS > 0
|
||||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||||
)
|
)
|
||||||
|
trial_telegram_required_reason = (
|
||||||
|
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
|
||||||
|
)
|
||||||
|
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
|
||||||
|
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||||
|
plans_payload = _serialize_plans(
|
||||||
|
settings,
|
||||||
|
lang,
|
||||||
|
subscription_options=cached["subscription_options"],
|
||||||
|
stars_subscription_options=cached["stars_subscription_options"],
|
||||||
|
traffic_packages=cached["traffic_packages"],
|
||||||
|
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||||
|
)
|
||||||
|
await _attach_hwid_renewal_quotes_to_plans(
|
||||||
|
session,
|
||||||
|
subscription_service,
|
||||||
|
user_id=user_id,
|
||||||
|
settings=settings,
|
||||||
|
active=active,
|
||||||
|
local_sub=local_sub,
|
||||||
|
plans=plans_payload,
|
||||||
|
)
|
||||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
|
||||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||||
|
telegram_linked = _user_has_linked_telegram(db_user)
|
||||||
|
referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0))
|
||||||
|
referral_welcome_telegram_required_reason = (
|
||||||
|
_referral_welcome_telegram_required_reason(settings, db_user)
|
||||||
|
if db_user.referred_by_id and not active and referral_welcome_days > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
telegram_notifications_status = normalize_telegram_notification_status(
|
telegram_notifications_status = normalize_telegram_notification_status(
|
||||||
getattr(db_user, "telegram_notifications_status", None)
|
getattr(db_user, "telegram_notifications_status", None)
|
||||||
)
|
)
|
||||||
@@ -94,7 +127,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
db_user.email and db_user.email_verified_at and db_user.password_hash
|
db_user.email and db_user.email_verified_at and db_user.password_hash
|
||||||
),
|
),
|
||||||
"telegram_id": db_user.telegram_id,
|
"telegram_id": db_user.telegram_id,
|
||||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
"telegram_linked": telegram_linked,
|
||||||
"telegram_notifications_status": telegram_notifications_status,
|
"telegram_notifications_status": telegram_notifications_status,
|
||||||
"telegram_notifications_enabled": (
|
"telegram_notifications_enabled": (
|
||||||
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||||
@@ -120,22 +153,20 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
"webapp_link": webapp_referral_link,
|
"webapp_link": webapp_referral_link,
|
||||||
"invited_count": referral_stats.get("invited_count", 0),
|
"invited_count": referral_stats.get("invited_count", 0),
|
||||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||||
"welcome_bonus_days": max(
|
"welcome_bonus_days": referral_welcome_days,
|
||||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
"welcome_bonus_without_telegram_enabled": bool(
|
||||||
|
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
|
||||||
),
|
),
|
||||||
|
"welcome_bonus_requires_telegram": bool(
|
||||||
|
referral_welcome_telegram_required_reason and not telegram_linked
|
||||||
|
),
|
||||||
|
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
|
||||||
"one_bonus_per_referee": bool(
|
"one_bonus_per_referee": bool(
|
||||||
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
|
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
|
||||||
),
|
),
|
||||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||||
},
|
},
|
||||||
"plans": _serialize_plans(
|
"plans": plans_payload,
|
||||||
settings,
|
|
||||||
lang,
|
|
||||||
subscription_options=cached["subscription_options"],
|
|
||||||
stars_subscription_options=cached["stars_subscription_options"],
|
|
||||||
traffic_packages=cached["traffic_packages"],
|
|
||||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
|
||||||
),
|
|
||||||
"payment_methods": _serialize_payment_methods(
|
"payment_methods": _serialize_payment_methods(
|
||||||
settings,
|
settings,
|
||||||
request.app,
|
request.app,
|
||||||
@@ -164,6 +195,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
|||||||
),
|
),
|
||||||
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
||||||
"trial_available": trial_available,
|
"trial_available": trial_available,
|
||||||
|
"trial_without_telegram_enabled": bool(
|
||||||
|
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
|
||||||
|
),
|
||||||
|
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
|
||||||
|
"trial_block_reason": trial_telegram_required_reason,
|
||||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||||
@@ -342,11 +378,12 @@ def _serialize_subscription(
|
|||||||
and tariff.premium_topup_packages.has_any()
|
and tariff.premium_topup_packages.has_any()
|
||||||
)
|
)
|
||||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
max_devices = _coerce_int_or_none(active.get("max_devices"))
|
||||||
|
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
|
||||||
can_topup_devices = bool(
|
can_topup_devices = bool(
|
||||||
tariff.billing_model == "period"
|
tariff.billing_model == "period"
|
||||||
and tariff.has_hwid_device_packages()
|
and tariff.has_hwid_device_packages()
|
||||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
and max_devices not in (None, 0)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
can_topup_regular_traffic = False
|
can_topup_regular_traffic = False
|
||||||
@@ -437,6 +474,102 @@ def _serialize_subscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
return normalized.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_hwid_renewal_quotes_to_plans(
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
settings: Settings,
|
||||||
|
active: Optional[Dict[str, Any]],
|
||||||
|
local_sub: Optional[Any],
|
||||||
|
plans: List[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
|
||||||
|
if not callable(quote_method):
|
||||||
|
return
|
||||||
|
if not active or not local_sub or not settings.tariffs_config:
|
||||||
|
return
|
||||||
|
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
|
default_currency_code = payment_currency_code(default_currency)
|
||||||
|
for plan in plans:
|
||||||
|
if str(plan.get("sale_mode") or "subscription") != "subscription":
|
||||||
|
continue
|
||||||
|
target_tariff_key = str(plan.get("tariff_key") or "").strip()
|
||||||
|
if not target_tariff_key:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
months = int(plan.get("months") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if months <= 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
currency_quote = await quote_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
target_tariff_key=target_tariff_key,
|
||||||
|
months=months,
|
||||||
|
currency=default_currency,
|
||||||
|
)
|
||||||
|
stars_quote = await quote_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
target_tariff_key=target_tariff_key,
|
||||||
|
months=months,
|
||||||
|
currency="stars",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to quote HWID renewal for plan %s/%s",
|
||||||
|
target_tariff_key,
|
||||||
|
months,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
quote = currency_quote or stars_quote
|
||||||
|
if not quote:
|
||||||
|
continue
|
||||||
|
valid_from = quote.get("valid_from")
|
||||||
|
valid_until = quote.get("valid_until")
|
||||||
|
active_until = quote.get("active_until")
|
||||||
|
renewal = {
|
||||||
|
"available": True,
|
||||||
|
"device_count": int(quote.get("device_count") or 0),
|
||||||
|
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||||
|
"currency": default_currency_code,
|
||||||
|
"valid_from": _webapp_iso_datetime(valid_from),
|
||||||
|
"valid_from_text": _webapp_datetime_text(valid_from),
|
||||||
|
"valid_until": _webapp_iso_datetime(valid_until),
|
||||||
|
"valid_until_text": _webapp_datetime_text(valid_until),
|
||||||
|
"active_until": _webapp_iso_datetime(active_until),
|
||||||
|
"active_until_text": _webapp_datetime_text(active_until),
|
||||||
|
"pricing_period_months": int(quote.get("pricing_period_months") or months),
|
||||||
|
}
|
||||||
|
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||||
|
renewal["stars_price"] = int(stars_quote["price"])
|
||||||
|
plan["hwid_renewal"] = renewal
|
||||||
|
|
||||||
|
|
||||||
def _build_install_share_link(
|
def _build_install_share_link(
|
||||||
request: Optional[web.Request],
|
request: Optional[web.Request],
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -495,7 +628,10 @@ def _serialize_plans(
|
|||||||
else [],
|
else [],
|
||||||
}
|
}
|
||||||
if tariff.billing_model == "period":
|
if tariff.billing_model == "period":
|
||||||
for months in sorted(tariff.enabled_periods):
|
# Render periods in the configured order (enabled_periods is the
|
||||||
|
# source of truth for purchase-period ordering, matching the bot
|
||||||
|
# keyboards). Do not sort so admins can reorder via drag & drop.
|
||||||
|
for months in tariff.enabled_periods:
|
||||||
price = tariff.period_price(int(months), default_currency)
|
price = tariff.period_price(int(months), default_currency)
|
||||||
stars_price = tariff.period_price(int(months), "stars")
|
stars_price = tariff.period_price(int(months), "stars")
|
||||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
@@ -528,7 +664,14 @@ def _serialize_plans(
|
|||||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
# Preserve the configured package order (default-currency list first,
|
||||||
|
# then any Stars-only volumes) so admins can reorder via drag & drop.
|
||||||
|
# Matches the bot keyboard, which iterates the package list as-is.
|
||||||
|
ordered_gb: List[float] = []
|
||||||
|
for traffic_gb in list(currency_packages) + list(stars_packages):
|
||||||
|
if traffic_gb not in ordered_gb:
|
||||||
|
ordered_gb.append(traffic_gb)
|
||||||
|
for traffic_gb in ordered_gb:
|
||||||
price = currency_packages.get(traffic_gb)
|
price = currency_packages.get(traffic_gb)
|
||||||
stars_price = stars_packages.get(traffic_gb)
|
stars_price = stars_packages.get(traffic_gb)
|
||||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||||
@@ -800,13 +943,15 @@ def _serialize_payment_methods(
|
|||||||
and spec.is_usable_for_payment_currency(settings, payment_currency)
|
and spec.is_usable_for_payment_currency(settings, payment_currency)
|
||||||
):
|
):
|
||||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||||
methods.append(
|
payload = {
|
||||||
{
|
|
||||||
"id": method,
|
"id": method,
|
||||||
"name": presentation.webapp_label,
|
"name": presentation.webapp_label,
|
||||||
"icon": presentation.webapp_icon,
|
"icon": presentation.webapp_icon,
|
||||||
}
|
}
|
||||||
)
|
minimum = spec.payment_minimum(settings, payment_currency)
|
||||||
|
if minimum:
|
||||||
|
payload.update(minimum)
|
||||||
|
methods.append(payload)
|
||||||
return methods
|
return methods
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ def get_user_card_keyboard(
|
|||||||
text=_(key="admin_user_traffic_grant_button"),
|
text=_(key="admin_user_traffic_grant_button"),
|
||||||
callback_data=f"user_action:traffic_grant:{user_id}",
|
callback_data=f"user_action:traffic_grant:{user_id}",
|
||||||
)
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_(key="admin_user_hwid_limit_button"),
|
||||||
|
callback_data=f"user_action:hwid_limit:{user_id}",
|
||||||
|
)
|
||||||
|
|
||||||
# Row 4: Quick links — only for users with a real Telegram profile
|
# Row 4: Quick links — only for users with a real Telegram profile
|
||||||
# (synthetic email-only users have a negative user_id with no tg profile).
|
# (synthetic email-only users have a negative user_id with no tg profile).
|
||||||
@@ -261,9 +265,9 @@ def get_user_card_keyboard(
|
|||||||
|
|
||||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||||
if quick_links_count == 0:
|
if quick_links_count == 0:
|
||||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
builder.adjust(2, 2, 2, 1, 3, 1, 2)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +407,26 @@ async def format_user_card(
|
|||||||
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
max_devices = subscription_details.get("max_devices")
|
||||||
|
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
|
||||||
|
if max_devices is not None:
|
||||||
|
if int(max_devices) == 0:
|
||||||
|
devices_display = _("admin_hwid_limit_state_unlimited")
|
||||||
|
elif extra_hwid_devices > 0:
|
||||||
|
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
|
||||||
|
if base_hwid_limit is None:
|
||||||
|
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||||
|
else:
|
||||||
|
devices_display = _(
|
||||||
|
"admin_hwid_limit_state_with_extra",
|
||||||
|
total=int(max_devices),
|
||||||
|
base=int(base_hwid_limit),
|
||||||
|
extra=extra_hwid_devices,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||||
|
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
|
||||||
|
|
||||||
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
||||||
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
||||||
if premium_unlimited:
|
if premium_unlimited:
|
||||||
@@ -706,6 +730,32 @@ async def user_action_handler(
|
|||||||
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
||||||
elif action == "traffic_grant_premium":
|
elif action == "traffic_grant_premium":
|
||||||
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
||||||
|
elif action == "hwid_limit":
|
||||||
|
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
|
||||||
|
elif action == "hwid_limit_set_unlimited":
|
||||||
|
await handle_hwid_limit_apply(
|
||||||
|
callback,
|
||||||
|
user,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
settings,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
hwid_device_limit=0,
|
||||||
|
)
|
||||||
|
elif action == "hwid_limit_reset":
|
||||||
|
await handle_hwid_limit_apply(
|
||||||
|
callback,
|
||||||
|
user,
|
||||||
|
subscription_service,
|
||||||
|
session,
|
||||||
|
settings,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
hwid_device_limit=None,
|
||||||
|
)
|
||||||
|
elif action == "hwid_limit_set_number":
|
||||||
|
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
|
||||||
else:
|
else:
|
||||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||||
|
|
||||||
@@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_hwid_limit_state_text(
|
||||||
|
get_text: Callable[..., str],
|
||||||
|
hwid_device_limit: Optional[int],
|
||||||
|
extra_hwid_devices: int = 0,
|
||||||
|
) -> str:
|
||||||
|
if hwid_device_limit is None:
|
||||||
|
return get_text("admin_hwid_limit_state_default")
|
||||||
|
base_limit = int(hwid_device_limit)
|
||||||
|
if base_limit == 0:
|
||||||
|
return get_text("admin_hwid_limit_state_unlimited")
|
||||||
|
extra = max(0, int(extra_hwid_devices or 0))
|
||||||
|
if extra > 0:
|
||||||
|
return get_text(
|
||||||
|
"admin_hwid_limit_state_with_extra",
|
||||||
|
total=base_limit + extra,
|
||||||
|
base=base_limit,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
return get_text("admin_hwid_limit_state_count", count=base_limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_hwid_limit_menu(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
) -> None:
|
||||||
|
"""Show HWID device limit override controls."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
|
||||||
|
if not active_sub:
|
||||||
|
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_text = _admin_hwid_limit_state_text(
|
||||||
|
_,
|
||||||
|
getattr(active_sub, "hwid_device_limit", None),
|
||||||
|
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
|
||||||
|
)
|
||||||
|
text = "\n".join(
|
||||||
|
[
|
||||||
|
f"<b>{_('admin_hwid_limit_title')}</b>",
|
||||||
|
"",
|
||||||
|
_("admin_hwid_limit_hint"),
|
||||||
|
"",
|
||||||
|
_("admin_hwid_limit_current", current=current_text),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_hwid_limit_btn_set_number"),
|
||||||
|
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_hwid_limit_btn_unlimited"),
|
||||||
|
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_hwid_limit_btn_reset"),
|
||||||
|
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=_("admin_user_back_to_card_button"),
|
||||||
|
callback_data=f"user_action:refresh:{user.user_id}",
|
||||||
|
)
|
||||||
|
builder.adjust(1, 1, 1, 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||||
|
except Exception:
|
||||||
|
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||||
|
await state.update_data(target_user_id=user.user_id)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_hwid_limit_apply(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
user: User,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
*,
|
||||||
|
hwid_device_limit: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
"""Persist a HWID device base limit override and push it to the panel."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, user.user_id
|
||||||
|
)
|
||||||
|
if not active_sub:
|
||||||
|
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
active_sub.hwid_device_limit = hwid_device_limit
|
||||||
|
|
||||||
|
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||||
|
session, user.user_id
|
||||||
|
)
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": callback.from_user.id if callback.from_user else user.user_id,
|
||||||
|
"event_type": "admin:hwid_device_limit",
|
||||||
|
"content": (
|
||||||
|
f"hwid_device_limit={hwid_device_limit!r} "
|
||||||
|
f"effective_hwid_device_limit={effective_limit!r}"
|
||||||
|
),
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": user.user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
|
||||||
|
await handle_refresh_user_card(
|
||||||
|
callback, user, subscription_service, session, settings, i18n_instance, lang
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(
|
||||||
|
"Failed to apply HWID device limit for user %s: %s",
|
||||||
|
user.user_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_hwid_limit_prompt(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
user: User,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
) -> None:
|
||||||
|
"""Ask admin for an explicit HWID device limit."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
await state.update_data(target_user_id=user.user_id)
|
||||||
|
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
|
||||||
|
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(prompt)
|
||||||
|
except Exception:
|
||||||
|
await callback.message.answer(prompt)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
async def handle_traffic_grant_menu(
|
async def handle_traffic_grant_menu(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler(
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
|
||||||
|
async def process_hwid_device_limit_handler(
|
||||||
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Read explicit HWID device limit and apply it."""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await message.reply("Language service error.")
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target_user_id = data.get("target_user_id")
|
||||||
|
if not target_user_id:
|
||||||
|
await message.answer(_("admin_hwid_limit_state_missing"))
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = (message.text or "").strip()
|
||||||
|
try:
|
||||||
|
hwid_device_limit = int(raw)
|
||||||
|
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||||
|
raise ValueError("out_of_range")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await message.answer(_("admin_hwid_limit_invalid"))
|
||||||
|
return
|
||||||
|
|
||||||
|
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||||
|
if not target_user:
|
||||||
|
await message.answer(_("admin_user_not_found_action"))
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
if not active_sub:
|
||||||
|
await message.answer(_("admin_hwid_limit_no_subscription"))
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
active_sub.hwid_device_limit = hwid_device_limit
|
||||||
|
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": message.from_user.id if message.from_user else target_user_id,
|
||||||
|
"event_type": "admin:hwid_device_limit",
|
||||||
|
"content": (
|
||||||
|
f"hwid_device_limit={hwid_device_limit!r} "
|
||||||
|
f"effective_hwid_device_limit={effective_limit!r}"
|
||||||
|
),
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
|
||||||
|
await message.answer(
|
||||||
|
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
|
bot_username = await _resolve_bot_username(message.bot)
|
||||||
|
user_card_text = await format_user_card(
|
||||||
|
target_user,
|
||||||
|
session,
|
||||||
|
subscription_service,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
referral_service,
|
||||||
|
settings=settings,
|
||||||
|
bot_username=bot_username,
|
||||||
|
)
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
target_user.user_id, i18n, current_lang, target_user.referred_by_id
|
||||||
|
)
|
||||||
|
await _send_with_profile_link_fallback(
|
||||||
|
message.answer,
|
||||||
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=target_user.user_id,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(
|
||||||
|
"Error setting HWID device limit for user %s: %s",
|
||||||
|
target_user_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await message.answer(_("admin_hwid_limit_save_error"))
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
||||||
async def process_traffic_grant_gb_handler(
|
async def process_traffic_grant_gb_handler(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback
|
|||||||
from bot.utils.channel_subscription import (
|
from bot.utils.channel_subscription import (
|
||||||
is_required_channel_access_error,
|
is_required_channel_access_error,
|
||||||
normalize_required_channel_id,
|
normalize_required_channel_id,
|
||||||
|
resolve_required_channel_link,
|
||||||
)
|
)
|
||||||
from bot.utils.install_links import (
|
from bot.utils.install_links import (
|
||||||
append_install_share_link_text,
|
append_install_share_link_text,
|
||||||
@@ -40,6 +41,67 @@ from db.models import User
|
|||||||
router = Router(name="user_start_router")
|
router = Router(name="user_start_router")
|
||||||
|
|
||||||
|
|
||||||
|
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
|
||||||
|
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _referral_code_lookup_candidates(
|
||||||
|
raw_ref_value: str,
|
||||||
|
*,
|
||||||
|
remnashop_compat: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
value = str(raw_ref_value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = [value]
|
||||||
|
if value and value[0].lower() == "u":
|
||||||
|
stripped_current_prefix = value[1:]
|
||||||
|
if remnashop_compat:
|
||||||
|
candidates.append(stripped_current_prefix)
|
||||||
|
else:
|
||||||
|
candidates = [stripped_current_prefix]
|
||||||
|
|
||||||
|
unique: list[str] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate = candidate.strip()
|
||||||
|
if candidate and candidate not in unique:
|
||||||
|
unique.append(candidate)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_referrer_from_start_ref(
|
||||||
|
session: AsyncSession,
|
||||||
|
raw_ref_value: str,
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
current_user_id: int,
|
||||||
|
) -> Optional[int]:
|
||||||
|
ref_user: Optional[User] = None
|
||||||
|
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
|
||||||
|
potential_referrer_id = int(raw_ref_value)
|
||||||
|
if potential_referrer_id != current_user_id:
|
||||||
|
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
|
||||||
|
|
||||||
|
include_legacy = _remnashop_referral_compat_enabled(settings)
|
||||||
|
if not ref_user:
|
||||||
|
for code in _referral_code_lookup_candidates(
|
||||||
|
raw_ref_value,
|
||||||
|
remnashop_compat=include_legacy,
|
||||||
|
):
|
||||||
|
ref_user = await user_dal.get_user_by_referral_code(
|
||||||
|
session,
|
||||||
|
code,
|
||||||
|
include_legacy=include_legacy,
|
||||||
|
)
|
||||||
|
if ref_user:
|
||||||
|
break
|
||||||
|
|
||||||
|
if ref_user and ref_user.user_id != current_user_id:
|
||||||
|
return int(ref_user.user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def should_show_trial_button(
|
async def should_show_trial_button(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
@@ -376,11 +438,12 @@ async def ensure_required_channel_subscription(
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
keyboard = (
|
channel_link = await resolve_required_channel_link(
|
||||||
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
|
bot_instance,
|
||||||
if i18n
|
required_channel_id,
|
||||||
else None
|
settings.REQUIRED_CHANNEL_LINK,
|
||||||
)
|
)
|
||||||
|
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
|
||||||
|
|
||||||
prompt_text = translate("channel_subscription_required")
|
prompt_text = translate("channel_subscription_required")
|
||||||
|
|
||||||
@@ -410,14 +473,10 @@ async def ensure_required_channel_subscription(
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
|
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
|
||||||
@router.message(
|
@router.message(
|
||||||
CommandStart(
|
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
|
||||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
|
||||||
"ref_match"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||||
@@ -534,22 +593,12 @@ async def start_command_handler(
|
|||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
raw_ref_value = ref_match.group(1)
|
raw_ref_value = ref_match.group(1)
|
||||||
if raw_ref_value.isdigit():
|
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||||
if settings.LEGACY_REFS:
|
session,
|
||||||
potential_referrer_id = int(raw_ref_value)
|
raw_ref_value,
|
||||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
settings=settings,
|
||||||
session, potential_referrer_id
|
current_user_id=user_id,
|
||||||
):
|
)
|
||||||
referred_by_user_id = potential_referrer_id
|
|
||||||
else:
|
|
||||||
normalized_code = raw_ref_value.strip()
|
|
||||||
if normalized_code and normalized_code[0].lower() == "u":
|
|
||||||
normalized_code = normalized_code[1:]
|
|
||||||
ref_user = None
|
|
||||||
if normalized_code:
|
|
||||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
|
|
||||||
if ref_user and ref_user.user_id != user_id:
|
|
||||||
referred_by_user_id = ref_user.user_id
|
|
||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||||
@@ -609,12 +658,17 @@ async def start_command_handler(
|
|||||||
)
|
)
|
||||||
if referred_by_user_id and referral_welcome_days > 0:
|
if referred_by_user_id and referral_welcome_days > 0:
|
||||||
try:
|
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 = (
|
referral_bonus_end_date = (
|
||||||
await subscription_service.extend_active_subscription_days(
|
await subscription_service.extend_active_subscription_days(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
referral_welcome_days,
|
referral_welcome_days,
|
||||||
reason="referral_welcome_bonus",
|
reason="referral_welcome_bonus",
|
||||||
|
tariff_key=default_tariff_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if referral_bonus_end_date:
|
if referral_bonus_end_date:
|
||||||
@@ -1135,7 +1189,7 @@ async def main_action_callback_handler(
|
|||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
privacy_url = settings.PRIVACY_POLICY_URL
|
privacy_url = settings.PRIVACY_POLICY_URL
|
||||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
user_agreement_url = settings.USER_AGREEMENT_URL
|
||||||
|
|
||||||
if not privacy_url and not user_agreement_url:
|
if not privacy_url and not user_agreement_url:
|
||||||
await safe_answer_callback(
|
await safe_answer_callback(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
get_tariff_packages_keyboard,
|
get_tariff_packages_keyboard,
|
||||||
get_tariff_periods_keyboard,
|
get_tariff_periods_keyboard,
|
||||||
sale_mode_with_callback_context,
|
sale_mode_with_callback_context,
|
||||||
subscription_options_callback,
|
tariff_purchase_back_callback,
|
||||||
)
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
@@ -303,7 +303,7 @@ async def select_tariff_callback(
|
|||||||
current_lang,
|
current_lang,
|
||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
back_callback=subscription_options_callback(callback_context),
|
back_callback=tariff_purchase_back_callback(callback_context),
|
||||||
callback_context=callback_context,
|
callback_context=callback_context,
|
||||||
)
|
)
|
||||||
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
|
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
|
||||||
@@ -319,7 +319,11 @@ async def select_tariff_callback(
|
|||||||
|
|
||||||
@router.callback_query(F.data.startswith("tariff:period:"))
|
@router.callback_query(F.data.startswith("tariff:period:"))
|
||||||
async def select_tariff_period_callback(
|
async def select_tariff_period_callback(
|
||||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
callback: types.CallbackQuery,
|
||||||
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
):
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
@@ -333,7 +337,9 @@ async def select_tariff_period_callback(
|
|||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
tariff_key, months_raw = parts[2], parts[3]
|
tariff_key, months_raw = parts[2], parts[3]
|
||||||
callback_context = parts[4] if len(parts) > 4 else None
|
callback_tokens = [part for part in parts[4:] if part]
|
||||||
|
callback_context = "bot" if "bot" in callback_tokens else None
|
||||||
|
renew_hwid_devices = "no_hwid" not in callback_tokens
|
||||||
tariff = config.require(tariff_key)
|
tariff = config.require(tariff_key)
|
||||||
months = int(months_raw)
|
months = int(months_raw)
|
||||||
default_currency = default_currency_key_for_settings(settings)
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
|
|||||||
if price_rub is None:
|
if price_rub is None:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||||
|
session,
|
||||||
|
user_id=callback.from_user.id,
|
||||||
|
target_tariff_key=tariff.key,
|
||||||
|
months=months,
|
||||||
|
currency=default_currency,
|
||||||
|
)
|
||||||
|
hwid_renewal_stars_quote = (
|
||||||
|
await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||||
|
session,
|
||||||
|
user_id=callback.from_user.id,
|
||||||
|
target_tariff_key=tariff.key,
|
||||||
|
months=months,
|
||||||
|
currency="stars",
|
||||||
|
)
|
||||||
|
)
|
||||||
markup = get_payment_method_keyboard(
|
markup = get_payment_method_keyboard(
|
||||||
months,
|
months,
|
||||||
price_rub,
|
price_rub,
|
||||||
@@ -354,6 +376,9 @@ async def select_tariff_period_callback(
|
|||||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
|
hwid_renewal_quote=hwid_renewal_quote,
|
||||||
|
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
|
||||||
|
hwid_renewal_selected=bool(renew_hwid_devices),
|
||||||
)
|
)
|
||||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
|
|||||||
if not packages:
|
if not packages:
|
||||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||||
return
|
return
|
||||||
renewal_available = bool(active.get("device_topup_renewal_available"))
|
|
||||||
markup = get_hwid_device_packages_keyboard(
|
markup = get_hwid_device_packages_keyboard(
|
||||||
tariff,
|
tariff,
|
||||||
packages,
|
packages,
|
||||||
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
|
|||||||
i18n,
|
i18n,
|
||||||
settings,
|
settings,
|
||||||
back_callback="main_action:my_devices",
|
back_callback="main_action:my_devices",
|
||||||
renewal=renewal_available,
|
renewal=False,
|
||||||
)
|
|
||||||
text_key = (
|
|
||||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
|
||||||
)
|
)
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
get_text(
|
get_text(
|
||||||
text_key,
|
"select_hwid_device_package",
|
||||||
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
||||||
),
|
),
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
|
|||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
||||||
|
renewal = action == "renewal_package"
|
||||||
default_currency = default_currency_key_for_settings(settings)
|
default_currency = default_currency_key_for_settings(settings)
|
||||||
currency_code = default_payment_currency_code_for_settings(settings)
|
currency_code = default_payment_currency_code_for_settings(settings)
|
||||||
currency_quote = await subscription_service.quote_hwid_device_topup(
|
currency_quote = await subscription_service.quote_hwid_device_topup(
|
||||||
@@ -647,7 +669,7 @@ async def hwid_devices_package_callback(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
device_count=count,
|
device_count=count,
|
||||||
tariff_key=tariff.key,
|
tariff_key=tariff.key,
|
||||||
renewal=action == "renewal_package",
|
renewal=renewal,
|
||||||
currency=default_currency,
|
currency=default_currency,
|
||||||
)
|
)
|
||||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||||
@@ -655,7 +677,7 @@ async def hwid_devices_package_callback(
|
|||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
device_count=count,
|
device_count=count,
|
||||||
tariff_key=tariff.key,
|
tariff_key=tariff.key,
|
||||||
renewal=action == "renewal_package",
|
renewal=renewal,
|
||||||
currency="stars",
|
currency="stars",
|
||||||
)
|
)
|
||||||
if not currency_quote and not stars_quote:
|
if not currency_quote and not stars_quote:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
|||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||||
|
|
||||||
from bot.middlewares.i18n import locale_language_options
|
from bot.middlewares.i18n import locale_language_options
|
||||||
|
from bot.utils.channel_subscription import normalize_required_channel_link
|
||||||
from bot.utils.install_links import bot_install_guide_url
|
from bot.utils.install_links import bot_install_guide_url
|
||||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -13,6 +14,13 @@ from config.tariffs_config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
BOT_MENU_CONTEXT = "bot"
|
BOT_MENU_CONTEXT = "bot"
|
||||||
|
HWID_RENEWAL_TOKEN = "hwid_renewal"
|
||||||
|
|
||||||
|
|
||||||
|
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
|
||||||
|
if not sale_mode or "|" not in sale_mode:
|
||||||
|
return ()
|
||||||
|
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
|
||||||
|
|
||||||
|
|
||||||
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
|
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
|
||||||
@@ -23,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
|
|||||||
|
|
||||||
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
|
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
|
||||||
sale_mode = sale_mode or "subscription"
|
sale_mode = sale_mode or "subscription"
|
||||||
if not context or "|" in sale_mode:
|
if not context or context in sale_mode_tokens(sale_mode):
|
||||||
return sale_mode
|
return sale_mode
|
||||||
return f"{sale_mode}|{context}"
|
return f"{sale_mode}|{context}"
|
||||||
|
|
||||||
|
|
||||||
|
def sale_mode_with_token(sale_mode: str, token: str) -> str:
|
||||||
|
sale_mode = sale_mode or "subscription"
|
||||||
|
token = str(token or "").strip()
|
||||||
|
if not token or token in sale_mode_tokens(sale_mode):
|
||||||
|
return sale_mode
|
||||||
|
return f"{sale_mode}|{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def sale_mode_without_token(sale_mode: str, token: str) -> str:
|
||||||
|
sale_mode = sale_mode or "subscription"
|
||||||
|
token = str(token or "").strip()
|
||||||
|
if not token or "|" not in sale_mode:
|
||||||
|
return sale_mode
|
||||||
|
base, *tokens = sale_mode.split("|")
|
||||||
|
kept = [item for item in tokens if item.strip() and item.strip() != token]
|
||||||
|
return "|".join([base, *kept])
|
||||||
|
|
||||||
|
|
||||||
|
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
|
||||||
|
return str(token or "").strip() in sale_mode_tokens(sale_mode)
|
||||||
|
|
||||||
|
|
||||||
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
|
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
|
||||||
if not sale_mode or "|" not in sale_mode:
|
tokens = sale_mode_tokens(sale_mode)
|
||||||
return None
|
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
|
||||||
context = str(sale_mode).split("|", 1)[1].strip()
|
|
||||||
return context or None
|
|
||||||
|
|
||||||
|
|
||||||
def callback_suffix_for_context(context: Optional[str]) -> str:
|
def callback_suffix_for_context(context: Optional[str]) -> str:
|
||||||
@@ -43,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"
|
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(
|
def payment_methods_back_callback(
|
||||||
value: str, sale_mode: str = "subscription", price: Optional[float] = None
|
value: str, sale_mode: str = "subscription", price: Optional[float] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -138,8 +172,7 @@ def get_main_menu_inline_keyboard(
|
|||||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
|
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
|
||||||
)
|
)
|
||||||
@@ -206,8 +239,7 @@ def get_bot_interface_inline_keyboard(
|
|||||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
|
||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
|
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
|
||||||
@@ -483,6 +515,9 @@ def get_payment_method_keyboard(
|
|||||||
back_callback: Optional[str] = None,
|
back_callback: Optional[str] = None,
|
||||||
user_id: Optional[int] = None,
|
user_id: Optional[int] = None,
|
||||||
is_admin: Optional[bool] = None,
|
is_admin: Optional[bool] = None,
|
||||||
|
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
|
||||||
|
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
|
||||||
|
hwid_renewal_selected: bool = True,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
@@ -491,12 +526,39 @@ def get_payment_method_keyboard(
|
|||||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||||
|
|
||||||
value_str = _format_value(months)
|
value_str = _format_value(months)
|
||||||
import logging as _kbd_logging
|
payment_sale_mode = sale_mode
|
||||||
|
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
|
||||||
_kbd_logging.info(
|
if selected_hwid_quote:
|
||||||
"payment_method_keyboard build: order=%s",
|
tariff_key = None
|
||||||
settings.payment_methods_order,
|
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
|
||||||
|
if "@" in sale_mode_main:
|
||||||
|
tariff_key = sale_mode_main.split("@", 1)[1]
|
||||||
|
context = callback_context_from_sale_mode(sale_mode)
|
||||||
|
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
|
||||||
|
if context:
|
||||||
|
toggle_tokens.append(context)
|
||||||
|
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_(
|
||||||
|
"payment_hwid_renewal_toggle_on"
|
||||||
|
if hwid_renewal_selected
|
||||||
|
else "payment_hwid_renewal_toggle_off",
|
||||||
|
count=int(selected_hwid_quote.get("device_count") or 0),
|
||||||
|
price=(
|
||||||
|
hwid_renewal_quote.get("price")
|
||||||
|
if hwid_renewal_quote
|
||||||
|
else hwid_renewal_stars_quote.get("price")
|
||||||
|
),
|
||||||
|
currency_symbol=currency_symbol_val,
|
||||||
|
),
|
||||||
|
callback_data=":".join(toggle_tokens),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
if hwid_renewal_selected:
|
||||||
|
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||||
|
else:
|
||||||
|
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||||
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
|
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
|
||||||
|
|
||||||
for method in settings.payment_methods_order:
|
for method in settings.payment_methods_order:
|
||||||
@@ -504,7 +566,7 @@ def get_payment_method_keyboard(
|
|||||||
if (
|
if (
|
||||||
not spec
|
not spec
|
||||||
or not spec.callback_prefix
|
or not spec.callback_prefix
|
||||||
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
|
or not spec.is_usable_for_payment(settings, currency_symbol_val, price)
|
||||||
or not spec.is_available_to_user(
|
or not spec.is_available_to_user(
|
||||||
settings,
|
settings,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -517,7 +579,7 @@ def get_payment_method_keyboard(
|
|||||||
value=value_str,
|
value=value_str,
|
||||||
rub_price=price,
|
rub_price=price,
|
||||||
stars_price=stars_price,
|
stars_price=stars_price,
|
||||||
sale_mode=sale_mode,
|
sale_mode=payment_sale_mode,
|
||||||
)
|
)
|
||||||
if not callback_data:
|
if not callback_data:
|
||||||
continue
|
continue
|
||||||
@@ -576,7 +638,7 @@ def get_yk_autopay_choice_keyboard(
|
|||||||
builder.row(
|
builder.row(
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
builder.row(
|
builder.row(
|
||||||
@@ -718,10 +780,11 @@ def get_channel_subscription_keyboard(
|
|||||||
|
|
||||||
has_buttons = False
|
has_buttons = False
|
||||||
|
|
||||||
if channel_link:
|
channel_url = normalize_required_channel_link(channel_link)
|
||||||
|
if channel_url:
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="channel_subscription_join_button"),
|
text=_(key="channel_subscription_join_button"),
|
||||||
url=channel_link,
|
url=channel_url,
|
||||||
)
|
)
|
||||||
has_buttons = True
|
has_buttons = True
|
||||||
|
|
||||||
|
|||||||
+19
-5
@@ -91,12 +91,9 @@ async def register_all_routers(dp: Dispatcher, settings: Settings):
|
|||||||
logging.info("All application routers registered.")
|
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"]
|
bot: Bot = dispatcher["bot_instance"]
|
||||||
settings: Settings = dispatcher["settings"]
|
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
|
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||||
if telegram_webhook_url_to_set:
|
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.")
|
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:
|
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||||
|
|
||||||
async def _configure_mini_app_menu() -> None:
|
async def _configure_mini_app_menu() -> None:
|
||||||
@@ -331,8 +336,17 @@ async def run_bot(settings_param: Settings):
|
|||||||
_yk_path,
|
_yk_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _after_webhooks_started() -> None:
|
||||||
|
await configure_telegram_webhook(dp)
|
||||||
|
|
||||||
async def web_server_task():
|
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")]
|
main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")]
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
result = await handler(event, data)
|
result = await handler(event, data)
|
||||||
|
|
||||||
|
if data.get("skip_action_log") or data.get("antiflood_dropped"):
|
||||||
|
return result
|
||||||
|
|
||||||
session: AsyncSession = data["session"]
|
session: AsyncSession = data["session"]
|
||||||
event_user: Optional[User] = data.get("event_from_user")
|
event_user: Optional[User] = data.get("event_from_user")
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.utils.channel_subscription import normalize_required_channel_id
|
from bot.utils.channel_subscription import (
|
||||||
|
normalize_required_channel_id,
|
||||||
|
resolve_required_channel_link,
|
||||||
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
|
||||||
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
|||||||
return i18n_instance.gettext(current_lang, key)
|
return i18n_instance.gettext(current_lang, key)
|
||||||
return key
|
return key
|
||||||
|
|
||||||
keyboard = (
|
bot_instance = data.get("bot") or data.get("bot_instance")
|
||||||
get_channel_subscription_keyboard(
|
channel_link = await resolve_required_channel_link(
|
||||||
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
bot_instance,
|
||||||
|
required_channel_id,
|
||||||
|
self.settings.REQUIRED_CHANNEL_LINK,
|
||||||
)
|
)
|
||||||
|
keyboard = (
|
||||||
|
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
|
||||||
if i18n_instance
|
if i18n_instance
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Awaitable, Callable, Deque, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Update
|
||||||
|
|
||||||
|
from bot.infra.redis import get_redis, redis_key
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_WINDOW_SECONDS = 60
|
||||||
|
DEFAULT_MAX_UPDATES_PER_WINDOW = 180
|
||||||
|
DEFAULT_MESSAGE_MAX_PER_WINDOW = 120
|
||||||
|
DEFAULT_CALLBACK_MAX_PER_WINDOW = 240
|
||||||
|
DEFAULT_INLINE_MAX_PER_WINDOW = 60
|
||||||
|
DEFAULT_START_MAX_PER_WINDOW = 30
|
||||||
|
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW = 60
|
||||||
|
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS = 20
|
||||||
|
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS = 30
|
||||||
|
|
||||||
|
EXPENSIVE_CALLBACK_PREFIXES = (
|
||||||
|
"pay_",
|
||||||
|
"trial_action:confirm_activate",
|
||||||
|
"main_action:request_trial",
|
||||||
|
"main_action:apply_promo",
|
||||||
|
"main_action:bot_apply_promo",
|
||||||
|
"tariff_change:apply:",
|
||||||
|
"tariff_change:confirm_pay:",
|
||||||
|
"tariff_change:pay:",
|
||||||
|
"autorenew:confirm:",
|
||||||
|
"disconnect_device:",
|
||||||
|
)
|
||||||
|
|
||||||
|
TRIAL_CALLBACK_PREFIXES = (
|
||||||
|
"trial_action:confirm_activate",
|
||||||
|
"main_action:request_trial",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RateLimitRule:
|
||||||
|
window_seconds: int
|
||||||
|
max_events: int
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAntiFloodMiddleware(BaseMiddleware):
|
||||||
|
"""Drop extreme update floods before DB-backed middleware runs."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
default_rule: Optional[RateLimitRule] = None,
|
||||||
|
action_rules: Optional[Dict[str, RateLimitRule]] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.settings = settings
|
||||||
|
self.default_rule = default_rule or RateLimitRule(
|
||||||
|
window_seconds=int(
|
||||||
|
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
|
||||||
|
or DEFAULT_WINDOW_SECONDS
|
||||||
|
),
|
||||||
|
max_events=int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
|
||||||
|
DEFAULT_MAX_UPDATES_PER_WINDOW,
|
||||||
|
)
|
||||||
|
or DEFAULT_MAX_UPDATES_PER_WINDOW
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.action_rules = action_rules or _default_action_rules(settings)
|
||||||
|
self._local_buckets: Dict[str, Deque[float]] = defaultdict(deque)
|
||||||
|
self._local_cooldowns: Dict[str, float] = {}
|
||||||
|
self._local_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Update,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
if bool(getattr(self.settings, "TELEGRAM_DROP_NON_PRIVATE_UPDATES", True)):
|
||||||
|
chat_type = _message_or_callback_chat_type(event)
|
||||||
|
if chat_type is not None and chat_type != "private":
|
||||||
|
logger.info(
|
||||||
|
"Telegram update dropped outside private chat: chat_type=%s update_type=%s",
|
||||||
|
chat_type,
|
||||||
|
getattr(event, "event_type", "unknown"),
|
||||||
|
)
|
||||||
|
_mark_dropped(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not bool(getattr(self.settings, "TELEGRAM_ANTIFLOOD_ENABLED", True)):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
actor_key = _update_actor_key(event)
|
||||||
|
if not actor_key:
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
action_key = _update_action_key(event)
|
||||||
|
cooldown = _update_action_cooldown(event, self.settings)
|
||||||
|
if cooldown and await self._is_cooldown_active(cooldown[0], cooldown[1]):
|
||||||
|
logger.info(
|
||||||
|
"Telegram callback dropped by action cooldown: actor=%s cooldown=%s",
|
||||||
|
actor_key,
|
||||||
|
cooldown[0],
|
||||||
|
)
|
||||||
|
_mark_dropped(data)
|
||||||
|
await _quietly_answer_callback(event)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if await self._is_limited("updates", actor_key, self.default_rule) or (
|
||||||
|
action_key
|
||||||
|
and action_key in self.action_rules
|
||||||
|
and await self._is_limited(action_key, actor_key, self.action_rules[action_key])
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Telegram update dropped by anti-flood: actor=%s update_type=%s",
|
||||||
|
actor_key,
|
||||||
|
action_key or getattr(event, "event_type", "unknown"),
|
||||||
|
)
|
||||||
|
_mark_dropped(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
async def _is_limited(self, bucket_name: str, actor_key: str, rule: RateLimitRule) -> bool:
|
||||||
|
if rule.window_seconds <= 0 or rule.max_events <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis = await get_redis(self.settings)
|
||||||
|
if redis is not None:
|
||||||
|
key = redis_key(
|
||||||
|
self.settings,
|
||||||
|
"rate-limit",
|
||||||
|
"telegram",
|
||||||
|
bucket_name,
|
||||||
|
actor_key,
|
||||||
|
)
|
||||||
|
current = int(await redis.incr(key))
|
||||||
|
if current == 1:
|
||||||
|
await redis.expire(key, rule.window_seconds)
|
||||||
|
return current > rule.max_events
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Redis telegram anti-flood unavailable; using local fallback: %s", exc)
|
||||||
|
|
||||||
|
return await self._is_limited_local(f"{bucket_name}:{actor_key}", rule)
|
||||||
|
|
||||||
|
async def _is_cooldown_active(self, cooldown_key: str, ttl_seconds: int) -> bool:
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis = await get_redis(self.settings)
|
||||||
|
if redis is not None:
|
||||||
|
key = redis_key(
|
||||||
|
self.settings,
|
||||||
|
"cooldown",
|
||||||
|
"telegram",
|
||||||
|
cooldown_key,
|
||||||
|
)
|
||||||
|
acquired = await redis.set(key, "1", nx=True, ex=ttl_seconds)
|
||||||
|
return not bool(acquired)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Redis telegram cooldown unavailable; using local fallback: %s", exc)
|
||||||
|
|
||||||
|
return await self._is_cooldown_active_local(cooldown_key, ttl_seconds)
|
||||||
|
|
||||||
|
async def _is_cooldown_active_local(self, cooldown_key: str, ttl_seconds: int) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
async with self._local_lock:
|
||||||
|
expired = [
|
||||||
|
key for key, expires_at in self._local_cooldowns.items() if expires_at <= now
|
||||||
|
]
|
||||||
|
for key in expired:
|
||||||
|
self._local_cooldowns.pop(key, None)
|
||||||
|
|
||||||
|
expires_at = self._local_cooldowns.get(cooldown_key)
|
||||||
|
if expires_at and expires_at > now:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._local_cooldowns[cooldown_key] = now + ttl_seconds
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _is_limited_local(self, actor_key: str, rule: RateLimitRule) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
cutoff = now - rule.window_seconds
|
||||||
|
async with self._local_lock:
|
||||||
|
bucket = self._local_buckets[actor_key]
|
||||||
|
while bucket and bucket[0] <= cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
bucket.append(now)
|
||||||
|
if len(bucket) > rule.max_events:
|
||||||
|
return True
|
||||||
|
if not bucket:
|
||||||
|
self._local_buckets.pop(actor_key, None)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _update_actor_key(update: Update) -> Optional[str]:
|
||||||
|
user_id = None
|
||||||
|
chat_id = None
|
||||||
|
|
||||||
|
if update.message:
|
||||||
|
user_id = update.message.from_user.id if update.message.from_user else None
|
||||||
|
chat_id = update.message.chat.id if update.message.chat else None
|
||||||
|
elif update.callback_query:
|
||||||
|
user_id = update.callback_query.from_user.id if update.callback_query.from_user else None
|
||||||
|
if update.callback_query.message and update.callback_query.message.chat:
|
||||||
|
chat_id = update.callback_query.message.chat.id
|
||||||
|
elif update.inline_query:
|
||||||
|
user_id = update.inline_query.from_user.id if update.inline_query.from_user else None
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
return f"user:{int(user_id)}"
|
||||||
|
if chat_id is not None:
|
||||||
|
return f"chat:{int(chat_id)}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_or_callback_chat_type(update: Update) -> Optional[str]:
|
||||||
|
if update.message and update.message.chat:
|
||||||
|
return str(update.message.chat.type)
|
||||||
|
if (
|
||||||
|
update.callback_query
|
||||||
|
and update.callback_query.message
|
||||||
|
and update.callback_query.message.chat
|
||||||
|
):
|
||||||
|
return str(update.callback_query.message.chat.type)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _update_action_key(update: Update) -> str:
|
||||||
|
if update.message:
|
||||||
|
text = update.message.text or ""
|
||||||
|
if text.startswith("/start"):
|
||||||
|
return "start"
|
||||||
|
return "message"
|
||||||
|
if update.callback_query:
|
||||||
|
data = update.callback_query.data or ""
|
||||||
|
if data.startswith(EXPENSIVE_CALLBACK_PREFIXES):
|
||||||
|
return "expensive_callback"
|
||||||
|
return "callback"
|
||||||
|
if update.inline_query:
|
||||||
|
return "inline"
|
||||||
|
return "updates"
|
||||||
|
|
||||||
|
|
||||||
|
def _update_action_cooldown(update: Update, settings: Settings) -> Optional[tuple[str, int]]:
|
||||||
|
if not bool(getattr(settings, "TELEGRAM_ACTION_COOLDOWN_ENABLED", True)):
|
||||||
|
return None
|
||||||
|
if not update.callback_query or not update.callback_query.from_user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
callback_data = update.callback_query.data or ""
|
||||||
|
if not callback_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = int(update.callback_query.from_user.id)
|
||||||
|
data_digest = hashlib.sha256(callback_data.encode("utf-8")).hexdigest()[:24]
|
||||||
|
|
||||||
|
if callback_data.startswith("pay_"):
|
||||||
|
ttl = int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
|
||||||
|
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
|
or DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
return f"payment:user:{user_id}:data:{data_digest}", ttl
|
||||||
|
|
||||||
|
if callback_data.startswith(TRIAL_CALLBACK_PREFIXES):
|
||||||
|
ttl = int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
|
||||||
|
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
|
or DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
return f"trial:user:{user_id}:data:{data_digest}", ttl
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _quietly_answer_callback(update: Update) -> None:
|
||||||
|
callback = update.callback_query
|
||||||
|
if not callback:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_dropped(data: Dict[str, Any]) -> None:
|
||||||
|
data["antiflood_dropped"] = True
|
||||||
|
data["skip_action_log"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def _default_action_rules(settings: Settings) -> Dict[str, RateLimitRule]:
|
||||||
|
window_seconds = int(
|
||||||
|
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
|
||||||
|
or DEFAULT_WINDOW_SECONDS
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"message": RateLimitRule(
|
||||||
|
window_seconds,
|
||||||
|
int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
|
||||||
|
DEFAULT_MESSAGE_MAX_PER_WINDOW,
|
||||||
|
)
|
||||||
|
or DEFAULT_MESSAGE_MAX_PER_WINDOW
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"callback": RateLimitRule(
|
||||||
|
window_seconds,
|
||||||
|
int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
|
||||||
|
DEFAULT_CALLBACK_MAX_PER_WINDOW,
|
||||||
|
)
|
||||||
|
or DEFAULT_CALLBACK_MAX_PER_WINDOW
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"inline": RateLimitRule(
|
||||||
|
window_seconds,
|
||||||
|
int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
|
||||||
|
DEFAULT_INLINE_MAX_PER_WINDOW,
|
||||||
|
)
|
||||||
|
or DEFAULT_INLINE_MAX_PER_WINDOW
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"start": RateLimitRule(
|
||||||
|
window_seconds,
|
||||||
|
int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
|
||||||
|
DEFAULT_START_MAX_PER_WINDOW,
|
||||||
|
)
|
||||||
|
or DEFAULT_START_MAX_PER_WINDOW
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"expensive_callback": RateLimitRule(
|
||||||
|
window_seconds,
|
||||||
|
int(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
|
||||||
|
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW,
|
||||||
|
)
|
||||||
|
or DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
|
|||||||
sale_mode: str
|
sale_mode: str
|
||||||
currency: str = "RUB"
|
currency: str = "RUB"
|
||||||
traffic_gb: Optional[float] = None
|
traffic_gb: Optional[float] = None
|
||||||
|
hwid_device_count: Optional[int] = None
|
||||||
hwid_valid_from: Optional[Any] = None
|
hwid_valid_from: Optional[Any] = None
|
||||||
hwid_valid_until: Optional[Any] = None
|
hwid_valid_until: Optional[Any] = None
|
||||||
hwid_pricing_period_months: Optional[int] = None
|
hwid_pricing_period_months: Optional[int] = None
|
||||||
@@ -126,7 +127,10 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
|
|||||||
WebhookPathGetter = Callable[[Any], str]
|
WebhookPathGetter = Callable[[Any], str]
|
||||||
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
||||||
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
|
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
|
||||||
|
ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]]
|
||||||
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
|
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
|
||||||
|
PaymentAmountResolver = Callable[[Any, Any, Any], bool]
|
||||||
|
PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
|
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
|
||||||
@@ -177,6 +181,7 @@ class PaymentProviderSpec:
|
|||||||
webhook_route: Optional[WebhookRoute] = None
|
webhook_route: Optional[WebhookRoute] = None
|
||||||
webhook_requires_base_url: bool = False
|
webhook_requires_base_url: bool = False
|
||||||
create_webapp_payment: Optional[WebAppPaymentFactory] = None
|
create_webapp_payment: Optional[WebAppPaymentFactory] = None
|
||||||
|
reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None
|
||||||
requires_configured_service: bool = True
|
requires_configured_service: bool = True
|
||||||
price_source: str = "rub"
|
price_source: str = "rub"
|
||||||
emoji: str = "💳"
|
emoji: str = "💳"
|
||||||
@@ -191,6 +196,8 @@ class PaymentProviderSpec:
|
|||||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
admin_only_enabled: Optional[EnabledPredicate] = None
|
||||||
supported_currencies: Optional[Sequence[str]] = ("RUB",)
|
supported_currencies: Optional[Sequence[str]] = ("RUB",)
|
||||||
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
|
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
|
||||||
|
payment_amount_resolver: Optional[PaymentAmountResolver] = None
|
||||||
|
payment_minimum_resolver: Optional[PaymentMinimumResolver] = None
|
||||||
currency_support_note: str = ""
|
currency_support_note: str = ""
|
||||||
currency_support_url: Optional[str] = None
|
currency_support_url: Optional[str] = None
|
||||||
|
|
||||||
@@ -305,6 +312,30 @@ class PaymentProviderSpec:
|
|||||||
return True
|
return True
|
||||||
return self.supports_currency(source, currency)
|
return self.supports_currency(source, currency)
|
||||||
|
|
||||||
|
def payment_minimum(self, source: Any, currency: Any) -> Optional[Mapping[str, Any]]:
|
||||||
|
if self.payment_minimum_resolver is None:
|
||||||
|
return None
|
||||||
|
source_for_amount = self._currency_source(source)
|
||||||
|
try:
|
||||||
|
return self.payment_minimum_resolver(source_for_amount, currency)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_usable_for_payment_amount(self, source: Any, currency: Any, amount: Any) -> bool:
|
||||||
|
if self.price_source == "stars" or self.payment_amount_resolver is None:
|
||||||
|
return True
|
||||||
|
source_for_amount = self._currency_source(source)
|
||||||
|
try:
|
||||||
|
return bool(self.payment_amount_resolver(source_for_amount, currency, amount))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_usable_for_payment(self, source: Any, currency: Any, amount: Any) -> bool:
|
||||||
|
return self.is_usable_for_payment_currency(
|
||||||
|
source,
|
||||||
|
currency,
|
||||||
|
) and self.is_usable_for_payment_amount(source, currency, amount)
|
||||||
|
|
||||||
def is_visible(self, source: Any, app: Any) -> bool:
|
def is_visible(self, source: Any, app: Any) -> bool:
|
||||||
return self.is_enabled(source) and self.is_service_configured(app)
|
return self.is_enabled(source) and self.is_service_configured(app)
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class CryptoPayService:
|
|||||||
sale_mode: str = "subscription",
|
sale_mode: str = "subscription",
|
||||||
url_kind: str = "bot",
|
url_kind: str = "bot",
|
||||||
hwid_quote: Optional[dict] = None,
|
hwid_quote: Optional[dict] = None,
|
||||||
|
hwid_device_count: Optional[int] = None,
|
||||||
currency: Optional[str] = None,
|
currency: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
if not self.configured or not self.client:
|
if not self.configured or not self.client:
|
||||||
@@ -210,7 +211,11 @@ class CryptoPayService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
sale_base = sale_mode_base(sale_mode)
|
sale_base = sale_mode_base(sale_mode)
|
||||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
amounts = payment_record_amounts(
|
||||||
|
months=months,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
hwid_device_count=hwid_device_count,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
payment_record = await payment_dal.create_payment_record(
|
payment_record = await payment_dal.create_payment_record(
|
||||||
session,
|
session,
|
||||||
@@ -252,6 +257,7 @@ class CryptoPayService:
|
|||||||
"payment_db_id": str(payment_record.payment_id),
|
"payment_db_id": str(payment_record.payment_id),
|
||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
||||||
|
"hwid_devices": amounts.purchased_hwid_devices,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
}
|
}
|
||||||
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
||||||
else None,
|
else None,
|
||||||
|
hwid_device_count=ctx.hwid_device_count,
|
||||||
)
|
)
|
||||||
if not url:
|
if not url:
|
||||||
return payment_failed()
|
return payment_failed()
|
||||||
|
|||||||
@@ -53,11 +53,14 @@ from .shared import (
|
|||||||
notify_service_unavailable,
|
notify_service_unavailable,
|
||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
payment_units_for_activation,
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
|
render_payment_link,
|
||||||
|
safe_callback_answer,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOG = "freekassa"
|
_LOG = "freekassa"
|
||||||
@@ -76,7 +79,7 @@ class FreeKassaConfig(ProviderEnvConfig):
|
|||||||
MERCHANT_ID: Optional[str] = None
|
MERCHANT_ID: Optional[str] = None
|
||||||
FIRST_SECRET: Optional[str] = None
|
FIRST_SECRET: Optional[str] = None
|
||||||
SECOND_SECRET: Optional[str] = None
|
SECOND_SECRET: Optional[str] = None
|
||||||
PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
PAYMENT_URL: str = Field(default="https://pay.freekassa.net/")
|
||||||
API_KEY: Optional[str] = None
|
API_KEY: Optional[str] = None
|
||||||
PAYMENT_IP: Optional[str] = None
|
PAYMENT_IP: Optional[str] = None
|
||||||
PAYMENT_METHOD_ID: Optional[int] = None
|
PAYMENT_METHOD_ID: Optional[int] = None
|
||||||
@@ -153,7 +156,7 @@ class FreeKassaService(HttpClientMixin):
|
|||||||
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
|
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
|
||||||
|
|
||||||
self.api_base_url: str = "https://api.fk.life/v1"
|
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._nonce_lock = asyncio.Lock()
|
||||||
self._last_nonce = int(time.time() * 1000)
|
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",
|
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 def _generate_nonce(self) -> int:
|
||||||
async with self._nonce_lock:
|
async with self._nonce_lock:
|
||||||
candidate = int(time.time() * 1000)
|
candidate = int(time.time() * 1000)
|
||||||
@@ -513,6 +572,39 @@ async def pay_fk_callback_handler(
|
|||||||
hwid_quote=hwid_quote,
|
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:
|
try:
|
||||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||||
await session.commit()
|
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(
|
_PRESENTATION_MANIFEST = tuple(
|
||||||
ProviderManifestField(
|
ProviderManifestField(
|
||||||
key=key,
|
key=key,
|
||||||
@@ -723,7 +823,7 @@ _CONFIG_MANIFEST = (
|
|||||||
"FREEKASSA_PAYMENT_URL",
|
"FREEKASSA_PAYMENT_URL",
|
||||||
"url",
|
"url",
|
||||||
"Payment URL",
|
"Payment URL",
|
||||||
placeholder="https://pay.freekassa.ru/",
|
placeholder="https://pay.freekassa.net/",
|
||||||
subsection="FreeKassa",
|
subsection="FreeKassa",
|
||||||
attr="PAYMENT_URL",
|
attr="PAYMENT_URL",
|
||||||
),
|
),
|
||||||
@@ -772,6 +872,7 @@ SPEC = PaymentProviderSpec(
|
|||||||
webhook_path=lambda source: "/webhook/freekassa",
|
webhook_path=lambda source: "/webhook/freekassa",
|
||||||
webhook_route=freekassa_webhook_route,
|
webhook_route=freekassa_webhook_route,
|
||||||
create_webapp_payment=create_webapp_payment,
|
create_webapp_payment=create_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
config_class=FreeKassaConfig,
|
config_class=FreeKassaConfig,
|
||||||
presentation_class=FreeKassaPresentation,
|
presentation_class=FreeKassaPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -54,10 +55,12 @@ from .shared import (
|
|||||||
notify_user_payment_failed,
|
notify_user_payment_failed,
|
||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
payment_units_for_activation,
|
payment_units_for_activation,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
|
render_payment_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router(name="user_subscription_payments_heleket_router")
|
router = Router(name="user_subscription_payments_heleket_router")
|
||||||
@@ -243,7 +246,7 @@ class HeleketService(HttpClientMixin):
|
|||||||
self.referral_service = referral_service
|
self.referral_service = referral_service
|
||||||
self._default_return_url = default_return_url
|
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:
|
if not self.configured:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"HeleketService initialized but not fully configured. Payments disabled."
|
"HeleketService initialized but not fully configured. Payments disabled."
|
||||||
@@ -372,6 +375,70 @@ class HeleketService(HttpClientMixin):
|
|||||||
logging.exception("Heleket create_payment_link: request failed.")
|
logging.exception("Heleket create_payment_link: request failed.")
|
||||||
return False, {"message": str(exc)}
|
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:
|
def _verify_signature(self, payload: Dict[str, Any]) -> bool:
|
||||||
received = payload.get("sign")
|
received = payload.get("sign")
|
||||||
if not isinstance(received, str) or not received:
|
if not isinstance(received, str) or not received:
|
||||||
@@ -612,6 +679,38 @@ async def pay_heleket_callback_handler(
|
|||||||
hwid_quote=hwid_quote,
|
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:
|
try:
|
||||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||||
await session.commit()
|
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:
|
async def heleket_webhook_route(request: web.Request) -> web.Response:
|
||||||
service: HeleketService = request.app["heleket_service"]
|
service: HeleketService = request.app["heleket_service"]
|
||||||
return await service.webhook_route(request)
|
return await service.webhook_route(request)
|
||||||
@@ -887,6 +993,7 @@ SPEC = PaymentProviderSpec(
|
|||||||
webhook_path=lambda source: "/webhook/heleket",
|
webhook_path=lambda source: "/webhook/heleket",
|
||||||
webhook_route=heleket_webhook_route,
|
webhook_route=heleket_webhook_route,
|
||||||
create_webapp_payment=create_webapp_payment,
|
create_webapp_payment=create_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
emoji="🪙",
|
emoji="🪙",
|
||||||
config_class=HeleketConfig,
|
config_class=HeleketConfig,
|
||||||
presentation_class=HeleketPresentation,
|
presentation_class=HeleketPresentation,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@ from .shared import (
|
|||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
|
render_payment_link,
|
||||||
safe_callback_answer,
|
safe_callback_answer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ class PlategaService(HttpClientMixin):
|
|||||||
self.referral_service = referral_service
|
self.referral_service = referral_service
|
||||||
self._default_return_url = default_return_url
|
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:
|
if not self.configured:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"PlategaService initialized but not fully configured. Payments disabled."
|
"PlategaService initialized but not fully configured. Payments disabled."
|
||||||
@@ -282,6 +283,69 @@ class PlategaService(HttpClientMixin):
|
|||||||
log_prefix="Platega create_transaction",
|
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:
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
return web.Response(status=503, text="platega_disabled")
|
return web.Response(status=503, text="platega_disabled")
|
||||||
@@ -519,6 +583,43 @@ async def pay_platega_callback_handler(
|
|||||||
hwid_quote=hwid_quote,
|
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:
|
try:
|
||||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -549,7 +650,6 @@ async def pay_platega_callback_handler(
|
|||||||
)
|
)
|
||||||
transaction_id = first_value(response_data, "transactionId", "id")
|
transaction_id = first_value(response_data, "transactionId", "id")
|
||||||
redirect_url = first_value(response_data, "redirect", "url", "paymentUrl")
|
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
|
# 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
|
# usable payment — neither field is sufficient on its own. Skipping the
|
||||||
# persistence step when the redirect is missing matches the pre-refactor
|
# persistence step when the redirect is missing matches the pre-refactor
|
||||||
@@ -566,7 +666,6 @@ async def pay_platega_callback_handler(
|
|||||||
api_success=success,
|
api_success=success,
|
||||||
payment_url=redirect_url,
|
payment_url=redirect_url,
|
||||||
provider_payment_id=persistable_id,
|
provider_payment_id=persistable_id,
|
||||||
new_status=provider_status if persistable_id else None,
|
|
||||||
log_prefix=_LOG,
|
log_prefix=_LOG,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -609,6 +708,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
|||||||
months=ctx.months,
|
months=ctx.months,
|
||||||
sale_mode=ctx.sale_mode,
|
sale_mode=ctx.sale_mode,
|
||||||
traffic_gb=ctx.traffic_gb,
|
traffic_gb=ctx.traffic_gb,
|
||||||
|
hwid_device_count=ctx.hwid_device_count,
|
||||||
)
|
)
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -651,7 +751,6 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
|||||||
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
|
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
|
||||||
),
|
),
|
||||||
provider_payment_id=first_value(response_data, "transactionId", "id"),
|
provider_payment_id=first_value(response_data, "transactionId", "id"),
|
||||||
new_status=str((response_data or {}).get("status") or payment.status),
|
|
||||||
log_prefix="Platega",
|
log_prefix="Platega",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -664,6 +763,19 @@ async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Respons
|
|||||||
return await _create_webapp_payment(ctx, "platega_crypto")
|
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:
|
def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple:
|
||||||
return tuple(
|
return tuple(
|
||||||
ProviderManifestField(
|
ProviderManifestField(
|
||||||
@@ -818,6 +930,7 @@ SBP_SPEC = PaymentProviderSpec(
|
|||||||
webhook_path=lambda source: "/webhook/platega",
|
webhook_path=lambda source: "/webhook/platega",
|
||||||
webhook_route=platega_webhook_route,
|
webhook_route=platega_webhook_route,
|
||||||
create_webapp_payment=create_sbp_webapp_payment,
|
create_webapp_payment=create_sbp_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
config_class=PlategaConfig,
|
config_class=PlategaConfig,
|
||||||
presentation_class=PlategaSbpPresentation,
|
presentation_class=PlategaSbpPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST
|
manifest_fields=_CONFIG_MANIFEST
|
||||||
@@ -850,6 +963,7 @@ CRYPTO_SPEC = PaymentProviderSpec(
|
|||||||
service_key="platega_service",
|
service_key="platega_service",
|
||||||
callback_prefix="pay_platega_crypto",
|
callback_prefix="pay_platega_crypto",
|
||||||
create_webapp_payment=create_crypto_webapp_payment,
|
create_webapp_payment=create_crypto_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
config_class=PlategaConfig,
|
config_class=PlategaConfig,
|
||||||
presentation_class=PlategaCryptoPresentation,
|
presentation_class=PlategaCryptoPresentation,
|
||||||
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
|
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||||
|
|
||||||
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa
|
from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa
|
||||||
from .base import (
|
from .base import (
|
||||||
PaymentProviderPresentation,
|
PaymentProviderPresentation,
|
||||||
PaymentProviderSpec,
|
PaymentProviderSpec,
|
||||||
@@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
|
|||||||
stars.SPEC,
|
stars.SPEC,
|
||||||
cryptopay.SPEC,
|
cryptopay.SPEC,
|
||||||
heleket.SPEC,
|
heleket.SPEC,
|
||||||
|
paykilla.SPEC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ from .shared import (
|
|||||||
notify_user_payment_failed,
|
notify_user_payment_failed,
|
||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
payment_units_for_activation,
|
payment_units_for_activation,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
quote_hwid_callback_parts,
|
quote_hwid_callback_parts,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
|
render_payment_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOG = "severpay"
|
_LOG = "severpay"
|
||||||
@@ -136,7 +138,7 @@ class SeverPayService(HttpClientMixin):
|
|||||||
self.referral_service = referral_service
|
self.referral_service = referral_service
|
||||||
self._default_return_url = default_return_url
|
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:
|
if not self.configured:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -248,6 +250,48 @@ class SeverPayService(HttpClientMixin):
|
|||||||
return True, response_data.get("data") or response_data
|
return True, response_data.get("data") or response_data
|
||||||
return False, 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:
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
|
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,
|
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:
|
try:
|
||||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||||
await session.commit()
|
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(
|
_PRESENTATION_MANIFEST = tuple(
|
||||||
ProviderManifestField(
|
ProviderManifestField(
|
||||||
key=key,
|
key=key,
|
||||||
@@ -677,6 +760,7 @@ SPEC = PaymentProviderSpec(
|
|||||||
webhook_path=lambda source: "/webhook/severpay",
|
webhook_path=lambda source: "/webhook/severpay",
|
||||||
webhook_route=severpay_webhook_route,
|
webhook_route=severpay_webhook_route,
|
||||||
create_webapp_payment=create_webapp_payment,
|
create_webapp_payment=create_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
config_class=SeverPayConfig,
|
config_class=SeverPayConfig,
|
||||||
presentation_class=SeverPayPresentation,
|
presentation_class=SeverPayPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from .common import (
|
|||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
payment_units_for_activation,
|
payment_units_for_activation,
|
||||||
|
reusable_webapp_payment_response,
|
||||||
sale_mode_base,
|
sale_mode_base,
|
||||||
sale_mode_is_hwid_devices,
|
sale_mode_is_hwid_devices,
|
||||||
sale_mode_is_traffic,
|
sale_mode_is_traffic,
|
||||||
@@ -117,6 +118,7 @@ __all__ = [
|
|||||||
"payment_link_message_text",
|
"payment_link_message_text",
|
||||||
"payment_link_response",
|
"payment_link_response",
|
||||||
"payment_record_amounts",
|
"payment_record_amounts",
|
||||||
|
"reusable_webapp_payment_response",
|
||||||
"payment_units_for_activation",
|
"payment_units_for_activation",
|
||||||
"payment_unavailable",
|
"payment_unavailable",
|
||||||
"post_json_request",
|
"post_json_request",
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from aiogram import types
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
from bot.keyboards.inline.user_keyboards import (
|
||||||
|
HWID_RENEWAL_TOKEN,
|
||||||
get_payment_url_keyboard,
|
get_payment_url_keyboard,
|
||||||
payment_methods_back_callback,
|
payment_methods_back_callback,
|
||||||
|
sale_mode_has_token,
|
||||||
)
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from db.dal import payment_dal
|
from db.dal import payment_dal
|
||||||
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
currency: str = "rub",
|
currency: str = "rub",
|
||||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||||
|
base = sale_mode_base(parts.sale_mode)
|
||||||
|
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
|
||||||
|
try:
|
||||||
|
months = int(parts.months)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, None
|
||||||
|
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||||
|
months=months,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
if not quote:
|
||||||
|
return parts, None
|
||||||
|
quoted_parts = PaymentCallbackParts(
|
||||||
|
months=months,
|
||||||
|
price=float(parts.price or 0) + float(quote.get("price") or 0),
|
||||||
|
sale_mode=parts.sale_mode,
|
||||||
|
)
|
||||||
|
return quoted_parts, quote
|
||||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||||
return parts, None
|
return parts, None
|
||||||
device_count = parse_positive_int_units(parts.months)
|
device_count = parse_positive_int_units(parts.months)
|
||||||
@@ -262,6 +285,7 @@ async def safe_store_provider_payment_id(
|
|||||||
payment: Payment,
|
payment: Payment,
|
||||||
*,
|
*,
|
||||||
provider_payment_id: str,
|
provider_payment_id: str,
|
||||||
|
provider_payment_url: Optional[str] = None,
|
||||||
new_status: Optional[str] = None,
|
new_status: Optional[str] = None,
|
||||||
log_prefix: str,
|
log_prefix: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -277,6 +301,7 @@ async def safe_store_provider_payment_id(
|
|||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
str(provider_payment_id),
|
str(provider_payment_id),
|
||||||
new_status or payment.status,
|
new_status or payment.status,
|
||||||
|
provider_payment_url=provider_payment_url,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
@@ -331,11 +356,12 @@ async def render_link_or_fail(
|
|||||||
payment as ``failed_creation``. Every link-style provider used to inline
|
payment as ``failed_creation``. Every link-style provider used to inline
|
||||||
this same sequence.
|
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(
|
await safe_store_provider_payment_id(
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
provider_payment_id=provider_payment_id,
|
provider_payment_id=provider_payment_id,
|
||||||
|
provider_payment_url=payment_url,
|
||||||
new_status=new_status,
|
new_status=new_status,
|
||||||
log_prefix=log_prefix,
|
log_prefix=log_prefix,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ def build_payment_record_payload(
|
|||||||
base = sale_mode_base(sale_mode)
|
base = sale_mode_base(sale_mode)
|
||||||
is_traffic = sale_mode_is_traffic(sale_mode)
|
is_traffic = sale_mode_is_traffic(sale_mode)
|
||||||
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
||||||
|
hwid_devices = int(float(months)) if is_hwid else None
|
||||||
|
if hwid_quote:
|
||||||
|
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||||
|
if quote_devices is not None:
|
||||||
|
hwid_devices = quote_devices
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
@@ -111,9 +116,9 @@ def build_payment_record_payload(
|
|||||||
"sale_mode": sale_mode,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||||
"purchased_gb": float(months) if is_traffic else None,
|
"purchased_gb": float(months) if is_traffic else None,
|
||||||
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
|
"purchased_hwid_devices": hwid_devices,
|
||||||
}
|
}
|
||||||
if hwid_quote and is_hwid:
|
if hwid_quote and hwid_devices is not None:
|
||||||
payload.update(
|
payload.update(
|
||||||
{
|
{
|
||||||
"hwid_valid_from": hwid_quote.get("valid_from"),
|
"hwid_valid_from": hwid_quote.get("valid_from"),
|
||||||
@@ -164,14 +169,20 @@ def payment_record_amounts(
|
|||||||
months: Any,
|
months: Any,
|
||||||
sale_mode: str,
|
sale_mode: str,
|
||||||
traffic_gb: Optional[float] = None,
|
traffic_gb: Optional[float] = None,
|
||||||
|
hwid_device_count: Optional[int] = None,
|
||||||
) -> PaymentRecordAmounts:
|
) -> PaymentRecordAmounts:
|
||||||
traffic_sale = sale_mode_is_traffic(sale_mode)
|
traffic_sale = sale_mode_is_traffic(sale_mode)
|
||||||
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
|
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
|
||||||
units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
||||||
|
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
|
||||||
|
if not hwid_devices_sale and hwid_device_count is not None:
|
||||||
|
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
|
||||||
|
if parsed_hwid_devices is not None:
|
||||||
|
purchased_hwid_devices = parsed_hwid_devices
|
||||||
return PaymentRecordAmounts(
|
return PaymentRecordAmounts(
|
||||||
months=int(float(units)) if traffic_sale else int(float(months)),
|
months=int(float(units)) if traffic_sale else int(float(months)),
|
||||||
purchased_gb=float(units) if traffic_sale else None,
|
purchased_gb=float(units) if traffic_sale else None,
|
||||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
purchased_hwid_devices=purchased_hwid_devices,
|
||||||
tariff_key=sale_mode_tariff_key(sale_mode),
|
tariff_key=sale_mode_tariff_key(sale_mode),
|
||||||
traffic_sale=traffic_sale,
|
traffic_sale=traffic_sale,
|
||||||
hwid_devices_sale=hwid_devices_sale,
|
hwid_devices_sale=hwid_devices_sale,
|
||||||
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
|
|||||||
months=ctx.months,
|
months=ctx.months,
|
||||||
sale_mode=ctx.sale_mode,
|
sale_mode=ctx.sale_mode,
|
||||||
traffic_gb=ctx.traffic_gb,
|
traffic_gb=ctx.traffic_gb,
|
||||||
|
hwid_device_count=ctx.hwid_device_count,
|
||||||
)
|
)
|
||||||
return await create_base_payment_record(
|
return await create_base_payment_record(
|
||||||
ctx.session,
|
ctx.session,
|
||||||
@@ -303,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:
|
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 payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
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]
|
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:
|
def http_ok(status: int, _body: Any) -> bool:
|
||||||
@@ -14,6 +18,29 @@ def http_ok(status: int, _body: Any) -> bool:
|
|||||||
return status == 200
|
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(
|
async def post_json_request(
|
||||||
session: ClientSession,
|
session: ClientSession,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -29,11 +56,14 @@ async def post_json_request(
|
|||||||
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
|
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
|
||||||
can decide what to do (typically: mark the payment as ``failed_creation``).
|
can decide what to do (typically: mark the payment as ``failed_creation``).
|
||||||
"""
|
"""
|
||||||
|
for attempt in range(1, _TRANSPORT_ATTEMPTS + 1):
|
||||||
|
trace_ctx: dict[str, Any] = {"headers_sent": False}
|
||||||
try:
|
try:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
url,
|
url,
|
||||||
json=body,
|
json=body,
|
||||||
headers=dict(headers) if headers else None,
|
headers=dict(headers) if headers else None,
|
||||||
|
trace_request_ctx=trace_ctx,
|
||||||
) as response:
|
) as response:
|
||||||
response_text = await response.text()
|
response_text = await response.text()
|
||||||
try:
|
try:
|
||||||
@@ -55,8 +85,18 @@ async def post_json_request(
|
|||||||
return False, {"status": response.status, "message": response_data}
|
return False, {"status": response.status, "message": response_data}
|
||||||
return True, response_data
|
return True, response_data
|
||||||
except Exception as exc:
|
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,
|
||||||
|
attempt + 1,
|
||||||
|
_TRANSPORT_ATTEMPTS,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
logging.exception("%s: request failed.", log_prefix)
|
logging.exception("%s: request failed.", log_prefix)
|
||||||
return False, {"message": str(exc)}
|
return False, {"message": str(exc)}
|
||||||
|
return False, {"message": "request_failed"}
|
||||||
|
|
||||||
|
|
||||||
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
|
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
|
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
|
||||||
``__init__`` and inherits ``_get_session`` / ``close``. The session is
|
``__init__`` and inherits ``_get_session`` / ``close``. The session is
|
||||||
created on first use and recreated transparently if it was closed.
|
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]
|
_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:
|
def _init_http_client(self, *, total_timeout: TimeoutSource = _DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||||
self._timeout = ClientTimeout(total=total_timeout)
|
self._timeout_source = total_timeout
|
||||||
self._session = None
|
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:
|
async def _get_session(self) -> ClientSession:
|
||||||
if self._session is None or self._session.closed:
|
timeout_seconds = self._current_timeout_seconds()
|
||||||
self._session = ClientSession(timeout=self._timeout)
|
session = self._session
|
||||||
return 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:
|
async def close(self) -> None:
|
||||||
if self._session and not self._session.closed:
|
for task in list(self._session_cleanup_tasks):
|
||||||
await self._session.close()
|
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()
|
||||||
|
|||||||
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
|
|||||||
return f"{text}\n\n{note}"
|
return f"{text}\n\n{note}"
|
||||||
|
|
||||||
|
|
||||||
|
def append_hwid_renewed_note(
|
||||||
|
text: str,
|
||||||
|
translator: Translator,
|
||||||
|
*,
|
||||||
|
count: Any,
|
||||||
|
valid_until: Optional[datetime],
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
count_int = int(count or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count_int = 0
|
||||||
|
if count_int <= 0:
|
||||||
|
return text
|
||||||
|
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
|
||||||
|
note = translator(
|
||||||
|
"payment_successful_hwid_devices_renewed_note",
|
||||||
|
count=format_human_units(count_int),
|
||||||
|
date=date_text,
|
||||||
|
)
|
||||||
|
return f"{text}\n\n{note}"
|
||||||
|
|
||||||
|
|
||||||
async def send_success_message_to_user(
|
async def send_success_message_to_user(
|
||||||
*,
|
*,
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
|
|||||||
req.log_prefix,
|
req.log_prefix,
|
||||||
req.payment.payment_id,
|
req.payment.payment_id,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
|
req.session,
|
||||||
|
req.payment.payment_id,
|
||||||
|
"activation_failed",
|
||||||
|
)
|
||||||
|
await req.session.commit()
|
||||||
|
except Exception:
|
||||||
|
await req.session.rollback()
|
||||||
|
logging.exception(
|
||||||
|
"%s: failed to mark payment %s activation_failed.",
|
||||||
|
req.log_prefix,
|
||||||
|
req.payment.payment_id,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||||
|
|
||||||
|
await invalidate_webapp_user_caches(
|
||||||
|
req.settings,
|
||||||
|
req.user_id,
|
||||||
|
include_devices=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"%s: failed to invalidate webapp caches for user %s.",
|
||||||
|
req.log_prefix,
|
||||||
|
req.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
db_user, language = await resolve_user_language(
|
db_user, language = await resolve_user_language(
|
||||||
req.session,
|
req.session,
|
||||||
user_id=req.user_id,
|
user_id=req.user_id,
|
||||||
@@ -363,6 +414,14 @@ async def finalize_successful_payment(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if is_subscription and activation:
|
if is_subscription and activation:
|
||||||
|
if activation.get("hwid_devices_renewed_count"):
|
||||||
|
success_text = append_hwid_renewed_note(
|
||||||
|
success_text,
|
||||||
|
translator,
|
||||||
|
count=activation.get("hwid_devices_renewed_count"),
|
||||||
|
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
success_text = append_hwid_renewal_note(
|
success_text = append_hwid_renewal_note(
|
||||||
success_text,
|
success_text,
|
||||||
translator,
|
translator,
|
||||||
|
|||||||
@@ -39,13 +39,16 @@ async def finalize_webapp_link_payment(
|
|||||||
log_prefix="Wata",
|
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:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
str(provider_payment_id),
|
str(provider_payment_id),
|
||||||
new_status or payment.status,
|
new_status or payment.status,
|
||||||
|
provider_payment_url=payment_url,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
|
|||||||
message_key: str = "payment_failed",
|
message_key: str = "payment_failed",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send the localized ``payment_failed`` text to the user; never raises."""
|
"""Send the localized ``payment_failed`` text to the user; never raises."""
|
||||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||||
language = (
|
language = (
|
||||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
months=ctx.months,
|
months=ctx.months,
|
||||||
sale_mode=ctx.sale_mode,
|
sale_mode=ctx.sale_mode,
|
||||||
traffic_gb=ctx.traffic_gb,
|
traffic_gb=ctx.traffic_gb,
|
||||||
|
hwid_device_count=ctx.hwid_device_count,
|
||||||
)
|
)
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ from .shared import (
|
|||||||
notify_user_payment_failed,
|
notify_user_payment_failed,
|
||||||
parse_payment_callback,
|
parse_payment_callback,
|
||||||
payment_failed,
|
payment_failed,
|
||||||
payment_link_response,
|
|
||||||
payment_record_amounts,
|
payment_record_amounts,
|
||||||
payment_unavailable,
|
payment_unavailable,
|
||||||
payment_units_for_activation,
|
payment_units_for_activation,
|
||||||
@@ -63,7 +62,6 @@ from .shared import (
|
|||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
render_payment_link,
|
render_payment_link,
|
||||||
safe_callback_answer,
|
safe_callback_answer,
|
||||||
sale_mode_base,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router(name="user_subscription_payments_wata_router")
|
router = Router(name="user_subscription_payments_wata_router")
|
||||||
@@ -206,7 +204,7 @@ class WataService(HttpClientMixin):
|
|||||||
self._default_return_url = default_return_url
|
self._default_return_url = default_return_url
|
||||||
self._cached_public_key_pem = None # populated by webhook on first verify
|
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:
|
if not self.configured:
|
||||||
logging.warning("WataService initialized but not fully configured. Payments disabled.")
|
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):
|
if not success or not isinstance(data, dict):
|
||||||
return None
|
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()
|
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
|
||||||
if status and status not in _WATA_LINK_OPENED_STATUSES:
|
if status and status not in _WATA_LINK_OPENED_STATUSES:
|
||||||
return None
|
return None
|
||||||
@@ -889,17 +900,15 @@ async def pay_wata_callback_handler(
|
|||||||
payment_description = describe_payment(translator, parts)
|
payment_description = describe_payment(translator, parts)
|
||||||
|
|
||||||
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
|
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(
|
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||||
session,
|
session,
|
||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
provider="wata",
|
provider="wata",
|
||||||
pending_status="pending_wata",
|
pending_status="pending_wata",
|
||||||
amount=parts.price,
|
amount=parts.price,
|
||||||
|
currency=currency_code,
|
||||||
sale_mode=parts.sale_mode,
|
sale_mode=parts.sale_mode,
|
||||||
months=months_for_lookup,
|
months=reuse_amounts.months,
|
||||||
purchased_gb=reuse_amounts.purchased_gb,
|
purchased_gb=reuse_amounts.purchased_gb,
|
||||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||||
tariff_key=reuse_amounts.tariff_key,
|
tariff_key=reuse_amounts.tariff_key,
|
||||||
@@ -974,44 +983,6 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
|
|
||||||
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
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,
|
|
||||||
)
|
|
||||||
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:
|
try:
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1043,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:
|
async def wata_webhook_route(request: web.Request) -> web.Response:
|
||||||
service: WataService = request.app["wata_service"]
|
service: WataService = request.app["wata_service"]
|
||||||
return await service.webhook_route(request)
|
return await service.webhook_route(request)
|
||||||
@@ -1203,6 +1181,7 @@ SPEC = PaymentProviderSpec(
|
|||||||
webhook_path=lambda source: "/webhook/wata",
|
webhook_path=lambda source: "/webhook/wata",
|
||||||
webhook_route=wata_webhook_route,
|
webhook_route=wata_webhook_route,
|
||||||
create_webapp_payment=create_webapp_payment,
|
create_webapp_payment=create_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
config_class=WataConfig,
|
config_class=WataConfig,
|
||||||
presentation_class=WataPresentation,
|
presentation_class=WataPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||||
|
|||||||
@@ -382,6 +382,10 @@ class YooKassaService:
|
|||||||
"title": pm_title,
|
"title": pm_title,
|
||||||
"card_last4": last4_val,
|
"card_last4": last4_val,
|
||||||
}
|
}
|
||||||
|
confirmation = getattr(payment_info_yk, "confirmation", None)
|
||||||
|
confirmation_url = (
|
||||||
|
getattr(confirmation, "confirmation_url", None) if confirmation else None
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": payment_info_yk.id,
|
"id": payment_info_yk.id,
|
||||||
"status": payment_info_yk.status,
|
"status": payment_info_yk.status,
|
||||||
@@ -399,6 +403,7 @@ class YooKassaService:
|
|||||||
and hasattr(payment_info_yk.captured_at, "isoformat")
|
and hasattr(payment_info_yk.captured_at, "isoformat")
|
||||||
else None,
|
else None,
|
||||||
"payment_method": pm_payload,
|
"payment_method": pm_payload,
|
||||||
|
"confirmation_url": confirmation_url,
|
||||||
"test_mode": getattr(payment_info_yk, "test", None),
|
"test_mode": getattr(payment_info_yk, "test", None),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
@@ -448,6 +453,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
|
|||||||
return value is not None and str(value).strip() != ""
|
return value is not None and str(value).strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_int(value: Optional[Any]) -> Optional[int]:
|
||||||
|
if not _metadata_value_present(value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(str(value).strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_float(value: Optional[Any]) -> Optional[float]:
|
||||||
|
if not _metadata_value_present(value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
|
||||||
|
if not _metadata_value_present(value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _resolve_yookassa_activation_amounts(
|
def _resolve_yookassa_activation_amounts(
|
||||||
*,
|
*,
|
||||||
sale_mode_base: str,
|
sale_mode_base: str,
|
||||||
@@ -559,6 +594,11 @@ async def process_successful_payment(
|
|||||||
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
||||||
payment_value = float(amount_data.get("value", 0.0))
|
payment_value = float(amount_data.get("value", 0.0))
|
||||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||||
|
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
|
||||||
|
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
|
||||||
|
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
|
||||||
|
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
|
||||||
|
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
|
||||||
|
|
||||||
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -574,6 +614,19 @@ async def process_successful_payment(
|
|||||||
yk_payment_id_from_hook,
|
yk_payment_id_from_hook,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if sale_mode_base == "subscription" and hwid_devices_count > 0:
|
||||||
|
if (
|
||||||
|
not hwid_valid_from
|
||||||
|
or not hwid_valid_until
|
||||||
|
or hwid_valid_from >= hwid_valid_until
|
||||||
|
or hwid_full_price is None
|
||||||
|
):
|
||||||
|
logging.error(
|
||||||
|
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
|
||||||
|
yk_payment_id_from_hook,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
payment_record = None
|
payment_record = None
|
||||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||||
@@ -600,6 +653,16 @@ async def process_successful_payment(
|
|||||||
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||||
provider="yookassa",
|
provider="yookassa",
|
||||||
provider_payment_id=yk_payment_id_from_hook,
|
provider_payment_id=yk_payment_id_from_hook,
|
||||||
|
sale_mode=sale_mode,
|
||||||
|
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||||
|
purchased_hwid_devices=(
|
||||||
|
hwid_devices_count if hwid_devices_count > 0 else None
|
||||||
|
),
|
||||||
|
hwid_valid_from=hwid_valid_from,
|
||||||
|
hwid_valid_until=hwid_valid_until,
|
||||||
|
hwid_pricing_period_months=hwid_pricing_period_months,
|
||||||
|
hwid_proration_ratio=hwid_proration_ratio,
|
||||||
|
hwid_full_price=hwid_full_price,
|
||||||
)
|
)
|
||||||
payment_db_id = payment_record.payment_id
|
payment_db_id = payment_record.payment_id
|
||||||
except Exception as e_ensure:
|
except Exception as e_ensure:
|
||||||
@@ -1315,6 +1378,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
|
||||||
|
parts = payload.split(":")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
months = float(parts[0])
|
||||||
|
price = float(parts[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
page = 0
|
||||||
|
sale_mode = "subscription"
|
||||||
|
if len(parts) > 2:
|
||||||
|
try:
|
||||||
|
page = int(parts[2])
|
||||||
|
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||||
|
except ValueError:
|
||||||
|
sale_mode = parts[2]
|
||||||
|
return months, price, page, sale_mode
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_iso(value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return value.isoformat()
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
def _format_saved_payment_method_title(
|
def _format_saved_payment_method_title(
|
||||||
get_text, network: Optional[str], last4: Optional[str], is_default: bool
|
get_text, network: Optional[str], last4: Optional[str], is_default: bool
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -1363,6 +1456,9 @@ async def _initiate_yk_payment(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
sale_base = _sale_mode_base(sale_mode)
|
sale_base = _sale_mode_base(sale_mode)
|
||||||
|
hwid_device_count = None
|
||||||
|
if hwid_quote:
|
||||||
|
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||||
payment_description = (
|
payment_description = (
|
||||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||||
@@ -1379,12 +1475,14 @@ async def _initiate_yk_payment(
|
|||||||
"status": "pending_yookassa",
|
"status": "pending_yookassa",
|
||||||
"description": payment_description,
|
"description": payment_description,
|
||||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||||
"sale_mode": sale_base,
|
"sale_mode": sale_mode,
|
||||||
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
|
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
|
||||||
"purchased_gb": float(months)
|
"purchased_gb": float(months)
|
||||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||||
else None,
|
else None,
|
||||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
"purchased_hwid_devices": (
|
||||||
|
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
|
||||||
|
),
|
||||||
"hwid_valid_from": hwid_quote.get("valid_from") 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_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||||
@@ -1430,6 +1528,19 @@ async def _initiate_yk_payment(
|
|||||||
yookassa_metadata["traffic_gb"] = str(months)
|
yookassa_metadata["traffic_gb"] = str(months)
|
||||||
if sale_base in HWID_DEVICE_SALE_BASES:
|
if sale_base in HWID_DEVICE_SALE_BASES:
|
||||||
yookassa_metadata["hwid_devices"] = str(months)
|
yookassa_metadata["hwid_devices"] = str(months)
|
||||||
|
elif hwid_device_count:
|
||||||
|
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
|
||||||
|
if hwid_quote and hwid_device_count:
|
||||||
|
hwid_metadata = {
|
||||||
|
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
|
||||||
|
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
|
||||||
|
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
|
||||||
|
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
|
||||||
|
"hwid_full_price": hwid_quote.get("full_price"),
|
||||||
|
}
|
||||||
|
yookassa_metadata.update(
|
||||||
|
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
|
||||||
|
)
|
||||||
if payment_method_id:
|
if payment_method_id:
|
||||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||||
|
|
||||||
@@ -1497,7 +1608,7 @@ async def _initiate_yk_payment(
|
|||||||
await payment_dal.update_payment_status_by_db_id(
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
session,
|
session,
|
||||||
payment_db_id=db_payment_record.payment_id,
|
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"),
|
yk_payment_id=payment_response_yk.get("id"),
|
||||||
)
|
)
|
||||||
if selected_method_internal_id is not None:
|
if selected_method_internal_id is not None:
|
||||||
@@ -1565,12 +1676,11 @@ async def _initiate_yk_payment(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if payment_response_yk and payment_method_id:
|
if payment_response_yk and payment_method_id:
|
||||||
status_to_store = payment_response_yk.get("status", "pending")
|
|
||||||
try:
|
try:
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
session,
|
session,
|
||||||
payment_db_id=db_payment_record.payment_id,
|
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"),
|
yk_payment_id=payment_response_yk.get("id"),
|
||||||
)
|
)
|
||||||
if selected_method_internal_id is not None:
|
if selected_method_internal_id is not None:
|
||||||
@@ -1709,22 +1819,6 @@ async def pay_yk_callback_handler(
|
|||||||
|
|
||||||
months, price_rub, sale_mode = parsed
|
months, price_rub, sale_mode = parsed
|
||||||
hwid_quote = None
|
hwid_quote = None
|
||||||
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
|
|
||||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
|
||||||
session=session,
|
|
||||||
user_id=callback.from_user.id,
|
|
||||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
|
||||||
subscription_service=yookassa_service.subscription_service,
|
|
||||||
currency=default_currency_key_for_settings(settings),
|
|
||||||
)
|
|
||||||
if not quoted_parts:
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
months = quoted_parts.months
|
|
||||||
price_rub = quoted_parts.price
|
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||||
autopay_enabled = bool(
|
autopay_enabled = bool(
|
||||||
@@ -1786,6 +1880,22 @@ async def pay_yk_callback_handler(
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
|
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||||
|
session=session,
|
||||||
|
user_id=callback.from_user.id,
|
||||||
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||||
|
subscription_service=yookassa_service.subscription_service,
|
||||||
|
currency=default_currency_key_for_settings(settings),
|
||||||
|
)
|
||||||
|
if not quoted_parts:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
months = quoted_parts.months
|
||||||
|
price_rub = quoted_parts.price
|
||||||
|
|
||||||
await _initiate_yk_payment(
|
await _initiate_yk_payment(
|
||||||
callback,
|
callback,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
@@ -1863,6 +1973,22 @@ async def pay_yk_new_card_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
months, price_rub, sale_mode = parsed
|
months, price_rub, sale_mode = parsed
|
||||||
|
hwid_quote = None
|
||||||
|
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||||
|
session=session,
|
||||||
|
user_id=callback.from_user.id,
|
||||||
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||||
|
subscription_service=yookassa_service.subscription_service,
|
||||||
|
currency=default_currency_key_for_settings(settings),
|
||||||
|
)
|
||||||
|
if not quoted_parts:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
months = quoted_parts.months
|
||||||
|
price_rub = quoted_parts.price
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||||
autopay_enabled = bool(
|
autopay_enabled = bool(
|
||||||
@@ -1889,6 +2015,7 @@ async def pay_yk_new_card_handler(
|
|||||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||||
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
||||||
sale_mode=sale_mode,
|
sale_mode=sale_mode,
|
||||||
|
hwid_quote=hwid_quote,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -1928,27 +2055,15 @@ async def pay_yk_saved_list_handler(
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = data_payload.split(":")
|
parsed_saved_list = _parse_saved_list_payload(data_payload)
|
||||||
if len(parts) < 2:
|
if not parsed_saved_list:
|
||||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||||
try:
|
try:
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
months, price_rub, page, sale_mode = parsed_saved_list
|
||||||
try:
|
|
||||||
months = float(parts[0])
|
|
||||||
price_rub = float(parts[1])
|
|
||||||
page = int(parts[2]) if len(parts) > 2 else 0
|
|
||||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
|
||||||
try:
|
|
||||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
autopay_enabled = bool(
|
autopay_enabled = bool(
|
||||||
settings.yookassa_autopayments_active
|
settings.yookassa_autopayments_active
|
||||||
@@ -2138,6 +2253,24 @@ async def pay_yk_use_saved_handler(
|
|||||||
|
|
||||||
method_identifier = parts[2]
|
method_identifier = parts[2]
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
|
base_months = months
|
||||||
|
base_price_rub = price_rub
|
||||||
|
hwid_quote = None
|
||||||
|
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||||
|
session=session,
|
||||||
|
user_id=user_id,
|
||||||
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||||
|
subscription_service=yookassa_service.subscription_service,
|
||||||
|
currency=default_currency_key_for_settings(settings),
|
||||||
|
)
|
||||||
|
if not quoted_parts:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
months = quoted_parts.months
|
||||||
|
price_rub = quoted_parts.price
|
||||||
|
|
||||||
try:
|
try:
|
||||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||||
@@ -2182,10 +2315,13 @@ async def pay_yk_use_saved_handler(
|
|||||||
price_rub=price_rub,
|
price_rub=price_rub,
|
||||||
currency_code_for_yk=currency_code_for_yk,
|
currency_code_for_yk=currency_code_for_yk,
|
||||||
save_payment_method=False,
|
save_payment_method=False,
|
||||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
back_callback=(
|
||||||
|
f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}"
|
||||||
|
),
|
||||||
payment_method_id=selected_method.provider_payment_method_id,
|
payment_method_id=selected_method.provider_payment_method_id,
|
||||||
selected_method_internal_id=selected_method.method_id,
|
selected_method_internal_id=selected_method.method_id,
|
||||||
sale_mode=sale_mode,
|
sale_mode=sale_mode,
|
||||||
|
hwid_quote=hwid_quote,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -2754,6 +2890,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
months=ctx.months,
|
months=ctx.months,
|
||||||
sale_mode=ctx.sale_mode,
|
sale_mode=ctx.sale_mode,
|
||||||
traffic_gb=ctx.traffic_gb,
|
traffic_gb=ctx.traffic_gb,
|
||||||
|
hwid_device_count=ctx.hwid_device_count,
|
||||||
)
|
)
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -2775,8 +2912,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
}
|
}
|
||||||
if amounts.traffic_sale:
|
if amounts.traffic_sale:
|
||||||
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
|
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
|
||||||
if amounts.hwid_devices_sale:
|
if amounts.purchased_hwid_devices:
|
||||||
metadata["hwid_devices"] = str(int(float(ctx.months)))
|
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
|
||||||
if amounts.tariff_key:
|
if amounts.tariff_key:
|
||||||
metadata["tariff_key"] = amounts.tariff_key
|
metadata["tariff_key"] = amounts.tariff_key
|
||||||
response = await service.create_payment(
|
response = await service.create_payment(
|
||||||
@@ -2795,7 +2932,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
await payment_dal.update_payment_status_by_db_id(
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
ctx.session,
|
ctx.session,
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
response.get("status", "pending"),
|
"pending_yookassa",
|
||||||
yk_payment_id=response.get("id"),
|
yk_payment_id=response.get("id"),
|
||||||
)
|
)
|
||||||
await ctx.session.commit()
|
await ctx.session.commit()
|
||||||
@@ -2806,6 +2943,36 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
return payment_failed()
|
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(
|
_PRESENTATION_MANIFEST = tuple(
|
||||||
ProviderManifestField(
|
ProviderManifestField(
|
||||||
key=key,
|
key=key,
|
||||||
@@ -2940,6 +3107,7 @@ SPEC = PaymentProviderSpec(
|
|||||||
webhook_route=yookassa_webhook_route,
|
webhook_route=yookassa_webhook_route,
|
||||||
webhook_requires_base_url=True,
|
webhook_requires_base_url=True,
|
||||||
create_webapp_payment=create_webapp_payment,
|
create_webapp_payment=create_webapp_payment,
|
||||||
|
reuse_webapp_payment=reuse_webapp_payment,
|
||||||
config_class=YooKassaConfig,
|
config_class=YooKassaConfig,
|
||||||
presentation_class=YooKassaPresentation,
|
presentation_class=YooKassaPresentation,
|
||||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
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,13 +10,13 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from typing import Optional
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.email_templates import EmailContent, render_login_code
|
from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code
|
||||||
from bot.services.message_audit import log_user_message_delivery
|
from bot.services.message_audit import log_user_message_delivery
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import security_dal, user_dal
|
from db.dal import security_dal, user_dal
|
||||||
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
|
|||||||
return (value or "").strip().lower()
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def email_domain(value: Optional[str]) -> str:
|
||||||
|
email = normalize_email(value or "")
|
||||||
|
if "@" not in email:
|
||||||
|
return ""
|
||||||
|
return email.rsplit("@", 1)[1].strip().lower().rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
def is_valid_email(value: str) -> bool:
|
def is_valid_email(value: str) -> bool:
|
||||||
email = normalize_email(value)
|
email = normalize_email(value)
|
||||||
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_disposable_domain_values(value: str) -> list[str]:
|
||||||
|
return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_disposable_email(value: Optional[str], settings: Settings) -> bool:
|
||||||
|
domain = email_domain(value)
|
||||||
|
if not domain:
|
||||||
|
return False
|
||||||
|
blocked_domains = getattr(settings, "disposable_email_domains", None)
|
||||||
|
if blocked_domains is None:
|
||||||
|
blocked_domains = _split_disposable_domain_values(
|
||||||
|
str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "")
|
||||||
|
)
|
||||||
|
blocked_domains = blocked_domains or []
|
||||||
|
for blocked in blocked_domains:
|
||||||
|
normalized = str(blocked or "").strip().lower().lstrip("@.")
|
||||||
|
if normalized and (domain == normalized or domain.endswith(f".{normalized}")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
|
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
|
||||||
target_part = "none" if target_user_id is None else str(target_user_id)
|
target_part = "none" if target_user_id is None else str(target_user_id)
|
||||||
return f"{purpose}:{target_part}:{email}"
|
return f"{purpose}:{target_part}:{email}"
|
||||||
@@ -453,6 +481,7 @@ class EmailAuthService:
|
|||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
html_body: Optional[str] = None,
|
html_body: Optional[str] = None,
|
||||||
|
inline_images: Sequence[EmailInlineImage] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._send_custom_email_sync,
|
self._send_custom_email_sync,
|
||||||
@@ -460,6 +489,7 @@ class EmailAuthService:
|
|||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
html_body=html_body,
|
html_body=html_body,
|
||||||
|
inline_images=inline_images,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def send_rendered_email(
|
async def send_rendered_email(
|
||||||
@@ -473,6 +503,7 @@ class EmailAuthService:
|
|||||||
subject=content.subject,
|
subject=content.subject,
|
||||||
body=content.text,
|
body=content.text,
|
||||||
html_body=content.html,
|
html_body=content.html,
|
||||||
|
inline_images=content.inline_images,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _send_code_email_sync(
|
def _send_code_email_sync(
|
||||||
@@ -493,17 +524,13 @@ class EmailAuthService:
|
|||||||
i18n=self.i18n,
|
i18n=self.i18n,
|
||||||
)
|
)
|
||||||
|
|
||||||
message = EmailMessage()
|
message = self._build_email_message(
|
||||||
message["Subject"] = content.subject
|
email=email,
|
||||||
message["From"] = formataddr(
|
subject=content.subject,
|
||||||
(
|
body=content.text,
|
||||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
html_body=content.html,
|
||||||
self.settings.SMTP_FROM_EMAIL or "",
|
inline_images=content.inline_images,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
message["To"] = email
|
|
||||||
message.set_content(content.text)
|
|
||||||
message.add_alternative(content.html, subtype="html")
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
smtp_host = self.settings.SMTP_HOST
|
smtp_host = self.settings.SMTP_HOST
|
||||||
@@ -554,19 +581,15 @@ class EmailAuthService:
|
|||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
html_body: Optional[str] = None,
|
html_body: Optional[str] = None,
|
||||||
|
inline_images: Sequence[EmailInlineImage] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
message = EmailMessage()
|
message = self._build_email_message(
|
||||||
message["Subject"] = subject
|
email=email,
|
||||||
message["From"] = formataddr(
|
subject=subject,
|
||||||
(
|
body=body,
|
||||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
html_body=html_body,
|
||||||
self.settings.SMTP_FROM_EMAIL or "",
|
inline_images=inline_images,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
message["To"] = email
|
|
||||||
message.set_content(body)
|
|
||||||
if html_body:
|
|
||||||
message.add_alternative(html_body, subtype="html")
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
smtp_host = self.settings.SMTP_HOST
|
smtp_host = self.settings.SMTP_HOST
|
||||||
@@ -610,6 +633,64 @@ class EmailAuthService:
|
|||||||
if last_error:
|
if last_error:
|
||||||
raise last_error
|
raise last_error
|
||||||
|
|
||||||
|
def _build_email_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
html_body: Optional[str] = None,
|
||||||
|
inline_images: Sequence[EmailInlineImage] = (),
|
||||||
|
) -> EmailMessage:
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = formataddr(
|
||||||
|
(
|
||||||
|
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||||
|
self.settings.SMTP_FROM_EMAIL or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message["To"] = email
|
||||||
|
message.set_content(body)
|
||||||
|
if html_body:
|
||||||
|
message.add_alternative(html_body, subtype="html")
|
||||||
|
self._attach_inline_images(message, inline_images)
|
||||||
|
return message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_inline_images(
|
||||||
|
message: EmailMessage,
|
||||||
|
inline_images: Sequence[EmailInlineImage],
|
||||||
|
) -> None:
|
||||||
|
if not inline_images:
|
||||||
|
return
|
||||||
|
html_part = message.get_body(("html",))
|
||||||
|
if html_part is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for image in inline_images:
|
||||||
|
content_type = (image.content_type or "").split(";", 1)[0].strip().lower()
|
||||||
|
if "/" not in content_type:
|
||||||
|
continue
|
||||||
|
maintype, subtype = content_type.split("/", 1)
|
||||||
|
if maintype != "image" or not subtype:
|
||||||
|
continue
|
||||||
|
|
||||||
|
body = bytes(image.data or b"")
|
||||||
|
content_id = (image.content_id or "").strip()
|
||||||
|
if not body or not content_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cid_header = content_id
|
||||||
|
if not (cid_header.startswith("<") and cid_header.endswith(">")):
|
||||||
|
cid_header = f"<{cid_header}>"
|
||||||
|
html_part.add_related(
|
||||||
|
body,
|
||||||
|
maintype=maintype,
|
||||||
|
subtype=subtype,
|
||||||
|
cid=cid_header,
|
||||||
|
)
|
||||||
|
|
||||||
def _send_message_via_smtp(
|
def _send_message_via_smtp(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ copy goes through the shared `JsonI18n` instance so translations live in
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import io
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
@@ -27,6 +29,28 @@ _TEXT_MUTED = "#9aa3b2"
|
|||||||
_TEXT_DIM = "#5d6573"
|
_TEXT_DIM = "#5d6573"
|
||||||
_DEFAULT_ACCENT = "#00fe7a"
|
_DEFAULT_ACCENT = "#00fe7a"
|
||||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||||
|
_EMAIL_LOGO_CONTENT_ID = "webapp-logo"
|
||||||
|
_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||||
|
_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads"
|
||||||
|
_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)")
|
||||||
|
_LOGO_CONTENT_TYPES = {
|
||||||
|
".gif": "image/gif",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
}
|
||||||
|
_EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS = {".ico", ".webp"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EmailInlineImage:
|
||||||
|
content_id: str
|
||||||
|
content_type: str
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -34,6 +58,13 @@ class EmailContent:
|
|||||||
subject: str
|
subject: str
|
||||||
text: str
|
text: str
|
||||||
html: str
|
html: str
|
||||||
|
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _EmailLayout:
|
||||||
|
html: str
|
||||||
|
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def _safe_color(value: Optional[str]) -> str:
|
def _safe_color(value: Optional[str]) -> str:
|
||||||
@@ -45,11 +76,22 @@ def _safe_color(value: Optional[str]) -> str:
|
|||||||
return _DEFAULT_ACCENT
|
return _DEFAULT_ACCENT
|
||||||
|
|
||||||
|
|
||||||
|
def _theme_accent(settings: Settings) -> str:
|
||||||
|
primary = _safe_color(getattr(settings, "WEBAPP_PRIMARY_COLOR", None))
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a
|
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a
|
||||||
stored public https URL can be used directly. Anything else is dropped."""
|
stored public https URL can be used directly. Anything else is dropped."""
|
||||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
|
||||||
return None
|
|
||||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
@@ -59,6 +101,91 @@ def _public_logo_url(settings: Settings) -> Optional[str]:
|
|||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||||
|
parsed = urlsplit(str(url or ""))
|
||||||
|
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||||
|
prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||||
|
if not path.startswith(prefix):
|
||||||
|
return None
|
||||||
|
filename = path.removeprefix(prefix)
|
||||||
|
return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
|
||||||
|
filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip())
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower())
|
||||||
|
if not content_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve()
|
||||||
|
logo_path = (uploads_dir / filename).resolve()
|
||||||
|
logo_path.relative_to(uploads_dir)
|
||||||
|
body = logo_path.read_bytes()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
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,
|
||||||
|
data=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return f"cid:{inline_logo.content_id}", (inline_logo,)
|
||||||
|
|
||||||
|
public_url = _public_logo_url(settings)
|
||||||
|
if public_url:
|
||||||
|
return public_url, ()
|
||||||
|
|
||||||
|
return None, ()
|
||||||
|
|
||||||
|
|
||||||
def _brand_title(settings: Settings) -> str:
|
def _brand_title(settings: Settings) -> str:
|
||||||
title = (settings.WEBAPP_TITLE or "").strip()
|
title = (settings.WEBAPP_TITLE or "").strip()
|
||||||
return title or "Subscription"
|
return title or "Subscription"
|
||||||
@@ -98,20 +225,21 @@ def _layout(
|
|||||||
intro_html: str,
|
intro_html: str,
|
||||||
body_html: str,
|
body_html: str,
|
||||||
footer_html: str,
|
footer_html: str,
|
||||||
) -> str:
|
accent: Optional[str] = None,
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
) -> _EmailLayout:
|
||||||
|
accent = _safe_color(accent) if accent else _theme_accent(settings)
|
||||||
brand_title = html.escape(_brand_title(settings))
|
brand_title = html.escape(_brand_title(settings))
|
||||||
logo_url = _public_logo_url(settings)
|
logo_url, inline_images = _email_logo(settings)
|
||||||
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||||
logo_block = ""
|
logo_block = ""
|
||||||
if logo_url:
|
if logo_url:
|
||||||
logo_block = (
|
logo_block = (
|
||||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
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'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||||
f'border-radius:16px;">'
|
f'border-radius:16px;background:transparent;background-color:transparent;">'
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
layout_html = f"""<!DOCTYPE html>
|
||||||
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@@ -151,6 +279,16 @@ def _layout(
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
|
return _EmailLayout(html=layout_html, inline_images=inline_images)
|
||||||
|
|
||||||
|
|
||||||
|
def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent:
|
||||||
|
return EmailContent(
|
||||||
|
subject=subject,
|
||||||
|
text=text,
|
||||||
|
html=layout.html,
|
||||||
|
inline_images=layout.inline_images,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||||
@@ -258,7 +396,7 @@ def render_login_code(
|
|||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _theme_accent(settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code"
|
template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code"
|
||||||
safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else ""
|
safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else ""
|
||||||
@@ -318,8 +456,9 @@ def render_login_code(
|
|||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
|
accent=accent,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_account_merged(
|
def render_account_merged(
|
||||||
@@ -372,7 +511,7 @@ def render_account_merged(
|
|||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text=text, html=rendered)
|
return _email_content(subject=subject, text=text, layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_payment_success(
|
def render_payment_success(
|
||||||
@@ -391,7 +530,7 @@ def render_payment_success(
|
|||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _theme_accent(settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||||
is_traffic = sale_base in {
|
is_traffic = sale_base in {
|
||||||
@@ -505,8 +644,9 @@ def render_payment_success(
|
|||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
|
accent=accent,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_user_notification(
|
def render_user_notification(
|
||||||
@@ -523,7 +663,7 @@ def render_user_notification(
|
|||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _theme_accent(settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
safe_dashboard_url = (dashboard_url or "").strip()
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
final_subject = (subject or "").strip() or _t_text(
|
final_subject = (subject or "").strip() or _t_text(
|
||||||
@@ -556,6 +696,7 @@ def render_user_notification(
|
|||||||
intro_html=html.escape(final_intro),
|
intro_html=html.escape(final_intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
|
accent=accent,
|
||||||
)
|
)
|
||||||
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
||||||
if safe_dashboard_url:
|
if safe_dashboard_url:
|
||||||
@@ -567,7 +708,7 @@ def render_user_notification(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_subscription_expiring(
|
def render_subscription_expiring(
|
||||||
@@ -581,7 +722,7 @@ def render_subscription_expiring(
|
|||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _theme_accent(settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
safe_dashboard_url = (dashboard_url or "").strip()
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
days = max(0, int(days_left))
|
days = max(0, int(days_left))
|
||||||
@@ -630,8 +771,9 @@ def render_subscription_expiring(
|
|||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
|
accent=accent,
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _subscription_lifecycle_title(
|
def _subscription_lifecycle_title(
|
||||||
@@ -678,7 +820,7 @@ def render_subscription_lifecycle_notification(
|
|||||||
) -> EmailContent:
|
) -> EmailContent:
|
||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language_code, settings)
|
lang = _normalize_lang(language_code, settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _theme_accent(settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
safe_dashboard_url = (dashboard_url or "").strip()
|
safe_dashboard_url = (dashboard_url or "").strip()
|
||||||
end_date = end_date_text or "—"
|
end_date = end_date_text or "—"
|
||||||
@@ -718,6 +860,7 @@ def render_subscription_lifecycle_notification(
|
|||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
|
accent=accent,
|
||||||
)
|
)
|
||||||
|
|
||||||
text_lines = [subject, "", message_text]
|
text_lines = [subject, "", message_text]
|
||||||
@@ -733,7 +876,7 @@ def render_subscription_lifecycle_notification(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def _support_email(
|
def _support_email(
|
||||||
@@ -752,7 +895,7 @@ def _support_email(
|
|||||||
i18n = _resolve_i18n(i18n)
|
i18n = _resolve_i18n(i18n)
|
||||||
lang = _normalize_lang(language, settings)
|
lang = _normalize_lang(language, settings)
|
||||||
brand = _brand_title(settings)
|
brand = _brand_title(settings)
|
||||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
accent = _theme_accent(settings)
|
||||||
safe_url = (ticket_url or "").strip()
|
safe_url = (ticket_url or "").strip()
|
||||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||||
localized_rows = [
|
localized_rows = [
|
||||||
@@ -775,6 +918,7 @@ def _support_email(
|
|||||||
intro_html=html.escape(intro),
|
intro_html=html.escape(intro),
|
||||||
body_html="".join(body_parts),
|
body_html="".join(body_parts),
|
||||||
footer_html=footer,
|
footer_html=footer,
|
||||||
|
accent=accent,
|
||||||
)
|
)
|
||||||
text_lines = [
|
text_lines = [
|
||||||
intro,
|
intro,
|
||||||
@@ -785,7 +929,7 @@ def _support_email(
|
|||||||
]
|
]
|
||||||
if safe_url:
|
if safe_url:
|
||||||
text_lines.extend(["", safe_url])
|
text_lines.extend(["", safe_url])
|
||||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||||
|
|
||||||
|
|
||||||
def render_support_new_ticket_admin(
|
def render_support_new_ticket_admin(
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ LOCALE_GROUPS = [
|
|||||||
"prefixes": (
|
"prefixes": (
|
||||||
"admin_user_",
|
"admin_user_",
|
||||||
"admin_users_",
|
"admin_users_",
|
||||||
|
"admin_hwid_",
|
||||||
"admin_ban_",
|
"admin_ban_",
|
||||||
"admin_unban_",
|
"admin_unban_",
|
||||||
"admin_banned_",
|
"admin_banned_",
|
||||||
@@ -115,6 +116,7 @@ LOCALE_GROUPS = [
|
|||||||
"admin_traffic_grant_",
|
"admin_traffic_grant_",
|
||||||
"admin_view_banned_",
|
"admin_view_banned_",
|
||||||
"user_card_",
|
"user_card_",
|
||||||
|
"user_hwid_",
|
||||||
"user_premium_",
|
"user_premium_",
|
||||||
"user_regular_",
|
"user_regular_",
|
||||||
"user_traffic_",
|
"user_traffic_",
|
||||||
@@ -203,7 +205,6 @@ LOCALE_GROUPS = [
|
|||||||
"admin_settings_field_subscription_mini_app_url",
|
"admin_settings_field_subscription_mini_app_url",
|
||||||
"admin_settings_field_support_link",
|
"admin_settings_field_support_link",
|
||||||
"admin_settings_field_server_status_url",
|
"admin_settings_field_server_status_url",
|
||||||
"admin_settings_field_terms_",
|
|
||||||
"admin_settings_field_privacy_",
|
"admin_settings_field_privacy_",
|
||||||
"admin_settings_field_user_agreement_",
|
"admin_settings_field_user_agreement_",
|
||||||
"appearance_",
|
"appearance_",
|
||||||
|
|||||||
@@ -237,11 +237,12 @@ class NotificationService:
|
|||||||
text: str,
|
text: str,
|
||||||
path: str,
|
path: str,
|
||||||
fallback_url: str,
|
fallback_url: str,
|
||||||
|
web_app_button: bool = True,
|
||||||
) -> InlineKeyboardButton:
|
) -> InlineKeyboardButton:
|
||||||
webapp_url = self._support_webapp_url(path)
|
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, 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:
|
def _support_text(self, language: Optional[str], key: str, fallback: str) -> str:
|
||||||
if not self.i18n:
|
if not self.i18n:
|
||||||
@@ -316,7 +317,14 @@ class NotificationService:
|
|||||||
return enabled
|
return enabled
|
||||||
return self._coerce_bool_setting(raw_value, 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 = (
|
ticket_path = (
|
||||||
f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
|
f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
|
||||||
)
|
)
|
||||||
@@ -326,6 +334,7 @@ class NotificationService:
|
|||||||
text="Открыть тикет",
|
text="Открыть тикет",
|
||||||
path=ticket_path,
|
path=ticket_path,
|
||||||
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
|
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
|
||||||
|
web_app_button=web_app_buttons,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@@ -342,12 +351,35 @@ class NotificationService:
|
|||||||
text="Карточка пользователя",
|
text="Карточка пользователя",
|
||||||
path=user_card_path,
|
path=user_card_path,
|
||||||
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
|
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
|
||||||
|
web_app_button=web_app_buttons,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if profile_row:
|
if profile_row:
|
||||||
rows.append(profile_row)
|
rows.append(profile_row)
|
||||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
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:
|
def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup:
|
||||||
button_text = self._support_text(
|
button_text = self._support_text(
|
||||||
getattr(user, "language_code", None),
|
getattr(user, "language_code", None),
|
||||||
@@ -415,12 +447,12 @@ class NotificationService:
|
|||||||
f"статус: {hd.quote(str(snapshot.get('panel_status') or '—'))}\n\n"
|
f"статус: {hd.quote(str(snapshot.get('panel_status') or '—'))}\n\n"
|
||||||
f"<b>Текст обращения</b>\n{hd.quote(preview)}"
|
f"<b>Текст обращения</b>\n{hd.quote(preview)}"
|
||||||
)
|
)
|
||||||
keyboard = self._support_keyboard(ticket, user, admin=True)
|
admin_keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||||
await self._send_to_admins(message, reply_markup=keyboard)
|
log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
|
||||||
await self._send_to_log_channel(
|
await self._send_admin_support_telegram(
|
||||||
message,
|
message,
|
||||||
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
|
admin_markup=admin_keyboard,
|
||||||
reply_markup=keyboard,
|
log_markup=log_keyboard,
|
||||||
)
|
)
|
||||||
await self._send_admin_support_email(
|
await self._send_admin_support_email(
|
||||||
render_support_new_ticket_admin,
|
render_support_new_ticket_admin,
|
||||||
@@ -456,13 +488,13 @@ class NotificationService:
|
|||||||
f"💬 <b>Ответ пользователя в тикете #{ticket.ticket_id}</b>\n"
|
f"💬 <b>Ответ пользователя в тикете #{ticket.ticket_id}</b>\n"
|
||||||
f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}"
|
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):
|
if send_telegram and getattr(self.settings, "LOG_SUPPORT", True):
|
||||||
await self._send_to_admins(text, reply_markup=keyboard)
|
admin_keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||||
await self._send_to_log_channel(
|
log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
|
||||||
|
await self._send_admin_support_telegram(
|
||||||
text,
|
text,
|
||||||
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
|
admin_markup=admin_keyboard,
|
||||||
reply_markup=keyboard,
|
log_markup=log_keyboard,
|
||||||
)
|
)
|
||||||
if send_email:
|
if send_email:
|
||||||
await self._send_admin_support_email(
|
await self._send_admin_support_email(
|
||||||
|
|||||||
@@ -15,6 +15,40 @@ from config.settings import Settings
|
|||||||
from db.dal import panel_sync_dal
|
from db.dal import panel_sync_dal
|
||||||
from db.models import PanelSyncStatus
|
from db.models import PanelSyncStatus
|
||||||
|
|
||||||
|
# Static endpoint prefixes used as log/metric labels instead of the raw request
|
||||||
|
# path. Endpoints embed user identifiers (telegram id, username, email, uuids),
|
||||||
|
# so logging the path verbatim would leak private data into log files; the
|
||||||
|
# label keeps only the constant prefix. Longest prefixes first so e.g.
|
||||||
|
# "/users/by-email/..." does not collapse into "/users".
|
||||||
|
_ENDPOINT_LOG_LABELS = (
|
||||||
|
"/users/by-telegram-id",
|
||||||
|
"/users/by-username",
|
||||||
|
"/users/by-email",
|
||||||
|
"/users",
|
||||||
|
"/subscriptions/subpage-config",
|
||||||
|
"/subscription-page-configs",
|
||||||
|
"/hwid/devices/delete",
|
||||||
|
"/hwid/devices",
|
||||||
|
"/system/stats/bandwidth",
|
||||||
|
"/system/stats/nodes",
|
||||||
|
"/system/stats",
|
||||||
|
"/system/tools/happ/encrypt",
|
||||||
|
"/bandwidth-stats/users",
|
||||||
|
"/bandwidth-stats/nodes",
|
||||||
|
"/internal-squads",
|
||||||
|
"/hosts",
|
||||||
|
"/nodes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint_log_label(endpoint: str) -> str:
|
||||||
|
"""Map a request endpoint to a constant, identifier-free label for logs."""
|
||||||
|
path = "/" + endpoint.split("?", 1)[0].strip("/")
|
||||||
|
for label in _ENDPOINT_LOG_LABELS:
|
||||||
|
if path == label or path.startswith(label + "/"):
|
||||||
|
return label
|
||||||
|
return "/other"
|
||||||
|
|
||||||
|
|
||||||
class PanelApiService:
|
class PanelApiService:
|
||||||
# Status codes returned by _request_once for failures we consider transient
|
# Status codes returned by _request_once for failures we consider transient
|
||||||
@@ -22,6 +56,11 @@ class PanelApiService:
|
|||||||
_TRANSIENT_STATUS_CODES = (-1, -3)
|
_TRANSIENT_STATUS_CODES = (-1, -3)
|
||||||
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
||||||
_RETRY_BACKOFF_SECONDS = 0.5
|
_RETRY_BACKOFF_SECONDS = 0.5
|
||||||
|
_MIN_TIMEOUT_SECONDS = 0.1
|
||||||
|
_DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0
|
||||||
|
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0
|
||||||
|
_DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0
|
||||||
|
_DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0
|
||||||
|
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -70,17 +109,46 @@ class PanelApiService:
|
|||||||
|
|
||||||
async def _get_session(self) -> aiohttp.ClientSession:
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
if self._session is None or self._session.closed:
|
if self._session is None or self._session.closed:
|
||||||
# Separate connect/read timeouts so a stuck panel does not hold a
|
self._session = aiohttp.ClientSession(timeout=self._client_timeout())
|
||||||
# bot worker for the full window; total caps worst-case latency.
|
|
||||||
timeout = aiohttp.ClientTimeout(
|
|
||||||
total=15,
|
|
||||||
connect=3,
|
|
||||||
sock_connect=3,
|
|
||||||
sock_read=10,
|
|
||||||
)
|
|
||||||
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float:
|
||||||
|
raw_value = getattr(settings, name, default)
|
||||||
|
try:
|
||||||
|
value = float(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
if value <= 0:
|
||||||
|
return default
|
||||||
|
return max(cls._MIN_TIMEOUT_SECONDS, value)
|
||||||
|
|
||||||
|
def _client_timeout(self) -> aiohttp.ClientTimeout:
|
||||||
|
# Separate connect/read timeouts so a slow panel route has more room,
|
||||||
|
# while genuinely stuck requests still cannot pin a worker forever.
|
||||||
|
return aiohttp.ClientTimeout(
|
||||||
|
total=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_TOTAL_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
connect=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
sock_connect=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
sock_read=self._timeout_setting(
|
||||||
|
self.settings,
|
||||||
|
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||||
|
self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def close_session(self):
|
async def close_session(self):
|
||||||
if self._session and not self._session.closed:
|
if self._session and not self._session.closed:
|
||||||
await self._session.close()
|
await self._session.close()
|
||||||
@@ -121,6 +189,15 @@ class PanelApiService:
|
|||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
|
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
|
||||||
if attempt + 1 < max_attempts and self._is_transient_error(result):
|
if attempt + 1 < max_attempts and self._is_transient_error(result):
|
||||||
|
logging.warning(
|
||||||
|
"Retrying transient Panel API request method=%s endpoint=%s "
|
||||||
|
"attempt=%s/%s status_code=%s",
|
||||||
|
method.upper(),
|
||||||
|
_endpoint_log_label(endpoint),
|
||||||
|
attempt + 1,
|
||||||
|
max_attempts,
|
||||||
|
result.get("status_code") if isinstance(result, dict) else None,
|
||||||
|
)
|
||||||
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
|
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
|
||||||
continue
|
continue
|
||||||
return result
|
return result
|
||||||
@@ -137,6 +214,7 @@ class PanelApiService:
|
|||||||
headers = await self._prepare_headers()
|
headers = await self._prepare_headers()
|
||||||
|
|
||||||
url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||||
|
endpoint_label = _endpoint_log_label(endpoint)
|
||||||
|
|
||||||
current_params = kwargs.get("params")
|
current_params = kwargs.get("params")
|
||||||
url_with_params_for_log = url_for_request
|
url_with_params_for_log = url_for_request
|
||||||
@@ -158,8 +236,8 @@ class PanelApiService:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
||||||
try:
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
async with aiohttp_session.request(
|
async with aiohttp_session.request(
|
||||||
method.upper(), url_for_request, headers=headers, **kwargs
|
method.upper(), url_for_request, headers=headers, **kwargs
|
||||||
) as response:
|
) as response:
|
||||||
@@ -169,7 +247,7 @@ class PanelApiService:
|
|||||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=%s",
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=%s",
|
||||||
time.monotonic() - started,
|
time.monotonic() - started,
|
||||||
method.upper(),
|
method.upper(),
|
||||||
endpoint,
|
endpoint_label,
|
||||||
response_status,
|
response_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,17 +306,67 @@ class PanelApiService:
|
|||||||
return {"error": True, "status_code": response_status, "details": error_details}
|
return {"error": True, "status_code": response_status, "details": error_details}
|
||||||
|
|
||||||
except aiohttp.ClientConnectorError as e:
|
except aiohttp.ClientConnectorError as e:
|
||||||
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
"Panel API ClientConnectorError method=%s endpoint=%s: %s",
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
e,
|
||||||
|
)
|
||||||
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
|
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
|
||||||
|
except aiohttp.ServerTimeoutError as e:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
)
|
||||||
|
logging.warning(
|
||||||
|
"Panel API timeout method=%s endpoint=%s: %s", method.upper(), endpoint_label, e
|
||||||
|
)
|
||||||
|
return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"}
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
logging.exception("Panel API ClientError to %s.", url_for_request)
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
)
|
||||||
|
logging.exception(
|
||||||
|
"Panel API ClientError method=%s endpoint=%s.", method.upper(), endpoint_label
|
||||||
|
)
|
||||||
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
|
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logging.error(f"Panel API request to {url_for_request} timed out.")
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
"Panel API request timed out method=%s endpoint=%s.",
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
)
|
||||||
return {"error": True, "status_code": -3, "message": "Request timed out"}
|
return {"error": True, "status_code": -3, "message": "Request timed out"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logging.info(
|
||||||
|
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error",
|
||||||
|
time.monotonic() - started,
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
)
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
|
"Unexpected Panel API request error method=%s endpoint=%s: %s",
|
||||||
|
method.upper(),
|
||||||
|
endpoint_label,
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return {"error": True, "status_code": -4, "message": f"Unexpected error: {str(e)}"}
|
return {"error": True, "status_code": -4, "message": f"Unexpected error: {str(e)}"}
|
||||||
|
|
||||||
@@ -885,7 +1013,14 @@ class PanelApiService:
|
|||||||
await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
|
await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
|
||||||
|
|
||||||
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
||||||
|
if squads is not None:
|
||||||
|
return squads
|
||||||
|
stale_squads = self._squads_cache.get_stale("list")
|
||||||
|
if stale_squads is not None:
|
||||||
|
logging.warning("Using stale internal squads cache after panel fetch failed.")
|
||||||
|
return stale_squads
|
||||||
|
return None
|
||||||
|
|
||||||
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
|
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
|
||||||
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
|
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ class PromoCodeService:
|
|||||||
user_lang: str,
|
user_lang: str,
|
||||||
) -> Tuple[bool, datetime | str]:
|
) -> Tuple[bool, datetime | str]:
|
||||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
preserve_case = bool(
|
||||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False)
|
||||||
|
)
|
||||||
|
code_input_clean = (code_input or "").strip()[:100]
|
||||||
|
lookup_code = code_input_clean if preserve_case else code_input_clean.upper()
|
||||||
|
code_display = html_escape(lookup_code[:100], quote=False)
|
||||||
throttle_identifier = self._throttle_identifier(user_id)
|
throttle_identifier = self._throttle_identifier(user_id)
|
||||||
|
|
||||||
throttle = await security_dal.check_throttle(
|
throttle = await security_dal.check_throttle(
|
||||||
@@ -54,7 +58,7 @@ class PromoCodeService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||||
session, code_input_upper
|
session, lookup_code, preserve_case=preserve_case
|
||||||
)
|
)
|
||||||
|
|
||||||
if not promo_data:
|
if not promo_data:
|
||||||
@@ -74,6 +78,8 @@ class PromoCodeService:
|
|||||||
)
|
)
|
||||||
return False, _("promo_code_not_found", code=code_display)
|
return False, _("promo_code_not_found", code=code_display)
|
||||||
|
|
||||||
|
applied_code = str(promo_data.code or lookup_code)
|
||||||
|
code_display = html_escape(applied_code[:100], quote=False)
|
||||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||||
session, promo_data.promo_code_id, user_id
|
session, promo_data.promo_code_id, user_id
|
||||||
)
|
)
|
||||||
@@ -81,12 +87,17 @@ class PromoCodeService:
|
|||||||
return False, _("promo_code_already_used_by_user", code=code_display)
|
return False, _("promo_code_already_used_by_user", code=code_display)
|
||||||
|
|
||||||
bonus_days = promo_data.bonus_days
|
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(
|
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||||
session=session,
|
session=session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
bonus_days=bonus_days,
|
bonus_days=bonus_days,
|
||||||
reason=f"promo code {code_input_upper}",
|
reason=f"promo code {applied_code}",
|
||||||
|
tariff_key=default_tariff_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_end_date:
|
if new_end_date:
|
||||||
@@ -109,7 +120,7 @@ class PromoCodeService:
|
|||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
await notification_service.notify_promo_activation(
|
await notification_service.notify_promo_activation(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
promo_code=code_input_upper,
|
promo_code=applied_code,
|
||||||
bonus_days=bonus_days,
|
bonus_days=bonus_days,
|
||||||
username=user.username if user else None,
|
username=user.username if user else None,
|
||||||
email=getattr(user, "email", None) if user else None,
|
email=getattr(user, "email", None) if user else None,
|
||||||
|
|||||||
@@ -197,6 +197,9 @@ class ReferralService:
|
|||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||||
"auto_renew_enabled": False,
|
"auto_renew_enabled": False,
|
||||||
|
# Short bonus grant: warn only hours before it
|
||||||
|
# ends, not days ahead. A real payment clears this.
|
||||||
|
"suppress_early_expiry_notifications": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ from db.dal import app_settings_dal
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
APPEARANCE_OVERRIDE_KEYS = {
|
APPEARANCE_OVERRIDE_KEYS = {
|
||||||
"WEBAPP_LOGO_USE_EMOJI",
|
|
||||||
"WEBAPP_LOGO_URL",
|
"WEBAPP_LOGO_URL",
|
||||||
"WEBAPP_LOGO_EMOJI",
|
|
||||||
"WEBAPP_LOGO_EMOJI_FONT",
|
|
||||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||||
"WEBAPP_FAVICON_URL",
|
"WEBAPP_FAVICON_URL",
|
||||||
"WEBAPP_LOGO_FAVICON_URL",
|
"WEBAPP_LOGO_FAVICON_URL",
|
||||||
@@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
|
|||||||
snapshot["WEBAPP_FAVICON_URL"] = favicon_url
|
snapshot["WEBAPP_FAVICON_URL"] = favicon_url
|
||||||
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False):
|
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False):
|
||||||
snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True
|
snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True
|
||||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
|
||||||
snapshot["WEBAPP_LOGO_USE_EMOJI"] = True
|
|
||||||
snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "")
|
|
||||||
emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "")
|
|
||||||
if emoji_font and emoji_font != "system":
|
|
||||||
snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font
|
|
||||||
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None)
|
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None)
|
||||||
if primary_color and primary_color != "#00fe7a":
|
if primary_color and primary_color != "#00fe7a":
|
||||||
snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color
|
snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color
|
||||||
|
|||||||
@@ -153,6 +153,15 @@ class SubscriptionNotificationWorker:
|
|||||||
hours_before=hours_before,
|
hours_before=hours_before,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Trial and registration/referral-bonus subscriptions last only a
|
||||||
|
# few days, so a multi-day "ending soon" reminder would fire almost
|
||||||
|
# the moment they are granted and needlessly alarm newcomers. Skip
|
||||||
|
# the day-before stages for them — they still get the hours-before
|
||||||
|
# reminder above and the expiry/after-expiry notices below. Paying
|
||||||
|
# for a real subscription clears the flag and restores all stages.
|
||||||
|
if bool(getattr(sub, "suppress_early_expiry_notifications", False)):
|
||||||
|
return None
|
||||||
|
|
||||||
days_before_limit = max(
|
days_before_limit = max(
|
||||||
0,
|
0,
|
||||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||||
|
|||||||
@@ -31,6 +31,49 @@ class HwidDeviceMixin:
|
|||||||
)
|
)
|
||||||
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
||||||
|
|
||||||
|
async def sync_hwid_device_limit_to_panel(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Push the current local HWID device limit override to the panel."""
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or not db_user.panel_user_uuid:
|
||||||
|
return None
|
||||||
|
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, user_id, db_user.panel_user_uuid
|
||||||
|
)
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
|
||||||
|
base_hwid_limit = (
|
||||||
|
int(sub.hwid_device_limit)
|
||||||
|
if sub.hwid_device_limit is not None
|
||||||
|
else self._base_hwid_limit_for_tariff(tariff)
|
||||||
|
)
|
||||||
|
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||||
|
sub.extra_hwid_devices = extra_hwid_devices
|
||||||
|
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||||
|
if effective_hwid_limit is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
panel_payload = self._build_panel_update_payload(
|
||||||
|
panel_user_uuid=db_user.panel_user_uuid,
|
||||||
|
expire_at=sub.end_date,
|
||||||
|
status="ACTIVE",
|
||||||
|
hwid_device_limit=effective_hwid_limit,
|
||||||
|
include_default_squads=False,
|
||||||
|
)
|
||||||
|
panel_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||||
|
try:
|
||||||
|
await self.panel_service.update_user_details_on_panel(
|
||||||
|
db_user.panel_user_uuid, panel_payload
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id)
|
||||||
|
return effective_hwid_limit
|
||||||
|
|
||||||
async def _hwid_topup_validity_window(
|
async def _hwid_topup_validity_window(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -73,6 +116,64 @@ class HwidDeviceMixin:
|
|||||||
packages = package_set.for_currency(currency)
|
packages = package_set.for_currency(currency)
|
||||||
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _quote_hwid_full_period_package_price(
|
||||||
|
tariff: Tariff,
|
||||||
|
*,
|
||||||
|
device_count: int,
|
||||||
|
period_months: int,
|
||||||
|
currency: str,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
package_set = tariff.hwid_device_packages
|
||||||
|
if not package_set:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
target_count = int(device_count)
|
||||||
|
months = max(1, int(period_months))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if target_count <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
packages = [
|
||||||
|
package
|
||||||
|
for package in package_set.for_currency(currency)
|
||||||
|
if int(getattr(package, "count", 0) or 0) > 0
|
||||||
|
]
|
||||||
|
if not packages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])}
|
||||||
|
for count in range(1, target_count + 1):
|
||||||
|
best_for_count: Optional[tuple[float, List[Any]]] = None
|
||||||
|
for package in packages:
|
||||||
|
package_count = int(package.count)
|
||||||
|
previous = best.get(count - package_count)
|
||||||
|
if previous is None:
|
||||||
|
continue
|
||||||
|
price = previous[0] + float(package.price_for_period(months))
|
||||||
|
selected = [*previous[1], package]
|
||||||
|
if best_for_count is None or price < best_for_count[0]:
|
||||||
|
best_for_count = (price, selected)
|
||||||
|
if best_for_count is not None:
|
||||||
|
best[count] = best_for_count
|
||||||
|
|
||||||
|
resolved = best.get(target_count)
|
||||||
|
if resolved is None:
|
||||||
|
return None
|
||||||
|
full_price, selected_packages = resolved
|
||||||
|
rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency)
|
||||||
|
if currency == "stars":
|
||||||
|
rounded_price = float(int(math.ceil(rounded_price)))
|
||||||
|
return {
|
||||||
|
"price": rounded_price,
|
||||||
|
"full_price": float(full_price),
|
||||||
|
"pricing_period_months": months,
|
||||||
|
"proration_ratio": 1.0,
|
||||||
|
"currency": currency,
|
||||||
|
"package_counts": [int(package.count) for package in selected_packages],
|
||||||
|
}
|
||||||
|
|
||||||
def _quote_hwid_package_price(
|
def _quote_hwid_package_price(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -85,16 +186,10 @@ class HwidDeviceMixin:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
|
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
|
||||||
full_price = float(package.price_for_period(period_months))
|
full_price = float(package.price_for_period(period_months))
|
||||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
|
||||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
|
||||||
inferred_period_start = add_months(period_end, -period_months)
|
|
||||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
|
||||||
period_start = inferred_period_start
|
|
||||||
|
|
||||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
|
||||||
billable_start = max(now, valid_from)
|
billable_start = max(now, valid_from)
|
||||||
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
|
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
|
||||||
ratio = billable_seconds / basis_seconds
|
ratio = min(1.0, billable_seconds / basis_seconds)
|
||||||
raw_price = full_price * ratio
|
raw_price = full_price * ratio
|
||||||
price = self._round_hwid_price(raw_price, currency=currency)
|
price = self._round_hwid_price(raw_price, currency=currency)
|
||||||
min_price = getattr(package, "min_price", None)
|
min_price = getattr(package, "min_price", None)
|
||||||
@@ -150,7 +245,7 @@ class HwidDeviceMixin:
|
|||||||
if sub.hwid_device_limit is not None
|
if sub.hwid_device_limit is not None
|
||||||
else self._base_hwid_limit_for_tariff(tariff)
|
else self._base_hwid_limit_for_tariff(tariff)
|
||||||
)
|
)
|
||||||
if base_hwid_limit == 0:
|
if base_hwid_limit in (None, 0):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
||||||
@@ -187,6 +282,80 @@ class HwidDeviceMixin:
|
|||||||
)
|
)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
async def quote_hwid_device_renewal_for_subscription(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
target_tariff_key: str,
|
||||||
|
months: int,
|
||||||
|
currency: str = "rub",
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
period_months = int(months)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if period_months <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not db_user or not db_user.panel_user_uuid:
|
||||||
|
return None
|
||||||
|
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, user_id, db_user.panel_user_uuid
|
||||||
|
)
|
||||||
|
if not sub or not sub.end_date:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
subscription_end = self._as_aware_utc(sub.end_date)
|
||||||
|
if not subscription_end or subscription_end <= now:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tariff = self._resolve_tariff(target_tariff_key)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not tariff or tariff.billing_model != "period":
|
||||||
|
return None
|
||||||
|
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||||
|
if base_hwid_limit in (None, 0):
|
||||||
|
return None
|
||||||
|
|
||||||
|
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||||
|
session,
|
||||||
|
subscription_id=sub.subscription_id,
|
||||||
|
at=now,
|
||||||
|
)
|
||||||
|
active_devices = int(entitlement_summary.get("active_devices") or 0)
|
||||||
|
if active_devices <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
price_quote = self._quote_hwid_full_period_package_price(
|
||||||
|
tariff,
|
||||||
|
device_count=active_devices,
|
||||||
|
period_months=period_months,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
if not price_quote:
|
||||||
|
return None
|
||||||
|
|
||||||
|
valid_from = subscription_end
|
||||||
|
valid_until = add_months(valid_from, period_months)
|
||||||
|
price_quote.update(
|
||||||
|
{
|
||||||
|
"subscription_id": sub.subscription_id,
|
||||||
|
"tariff_key": tariff.key,
|
||||||
|
"device_count": active_devices,
|
||||||
|
"renewal": True,
|
||||||
|
"valid_from": valid_from,
|
||||||
|
"valid_until": valid_until,
|
||||||
|
"active_until": entitlement_summary.get("active_until"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return price_quote
|
||||||
|
|
||||||
async def activate_hwid_device_topup(
|
async def activate_hwid_device_topup(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -248,7 +417,7 @@ class HwidDeviceMixin:
|
|||||||
if sub.hwid_device_limit is not None
|
if sub.hwid_device_limit is not None
|
||||||
else self._base_hwid_limit_for_tariff(tariff)
|
else self._base_hwid_limit_for_tariff(tariff)
|
||||||
)
|
)
|
||||||
if base_hwid_limit == 0:
|
if base_hwid_limit in (None, 0):
|
||||||
logging.info(
|
logging.info(
|
||||||
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
|
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
target_tariff_key: str,
|
target_tariff_key: str,
|
||||||
mode: str,
|
mode: str,
|
||||||
|
payment_id: Optional[int] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
config = self._tariffs_config()
|
config = self._tariffs_config()
|
||||||
if not config:
|
if not config:
|
||||||
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
|
|||||||
"from_tariff_key": before_tariff_key,
|
"from_tariff_key": before_tariff_key,
|
||||||
"to_tariff_key": target.key,
|
"to_tariff_key": target.key,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"payment_id": None,
|
"payment_id": payment_id,
|
||||||
"days_before": options.get("remaining_days"),
|
"days_before": options.get("remaining_days"),
|
||||||
"days_after": (updated.end_date - now).days
|
"days_after": (updated.end_date - now).days
|
||||||
if updated.end_date and target.billing_model == "period"
|
if updated.end_date and target.billing_model == "period"
|
||||||
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
|
|||||||
user_id,
|
user_id,
|
||||||
tariff_key,
|
tariff_key,
|
||||||
"paid_diff",
|
"paid_diff",
|
||||||
|
payment_id=payment_db_id,
|
||||||
)
|
)
|
||||||
if result:
|
if result:
|
||||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||||
if sub:
|
if sub:
|
||||||
await tariff_dal.create_tariff_change(
|
|
||||||
session,
|
|
||||||
{
|
|
||||||
"subscription_id": sub.subscription_id,
|
|
||||||
"from_tariff_key": None,
|
|
||||||
"to_tariff_key": tariff_key,
|
|
||||||
"mode": "paid_diff",
|
|
||||||
"payment_id": payment_db_id,
|
|
||||||
"days_before": None,
|
|
||||||
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
|
|
||||||
if sub.end_date
|
|
||||||
else None,
|
|
||||||
"converted_bytes": None,
|
|
||||||
"eff_price_before": None,
|
|
||||||
"eff_price_after": sub.effective_monthly_price_rub,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
result["end_date"] = sub.end_date
|
result["end_date"] = sub.end_date
|
||||||
result["is_active"] = sub.is_active
|
result["is_active"] = sub.is_active
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
|
|||||||
await self._record_payment_context(
|
await self._record_payment_context(
|
||||||
session,
|
session,
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
sale_mode=sale_mode_base,
|
sale_mode=sale_mode,
|
||||||
tariff_key=tariff.key if tariff else tariff_key,
|
tariff_key=tariff.key if tariff else tariff_key,
|
||||||
purchased_gb=None,
|
purchased_gb=None,
|
||||||
)
|
)
|
||||||
|
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||||
|
try:
|
||||||
|
hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hwid_renewal_devices = 0
|
||||||
|
try:
|
||||||
|
hwid_renewal_price = (
|
||||||
|
float(getattr(payment, "hwid_full_price", 0) or 0)
|
||||||
|
if hwid_renewal_devices > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hwid_renewal_price = 0.0
|
||||||
|
hwid_renewal_valid_from = self._as_aware_utc(
|
||||||
|
getattr(payment, "hwid_valid_from", None) if payment else None
|
||||||
|
)
|
||||||
|
hwid_renewal_valid_until = self._as_aware_utc(
|
||||||
|
getattr(payment, "hwid_valid_until", None) if payment else None
|
||||||
|
)
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
|
|||||||
promo_code_id_from_payment = None
|
promo_code_id_from_payment = None
|
||||||
|
|
||||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||||
|
if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days:
|
||||||
|
hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta(
|
||||||
|
days=applied_promo_bonus_days
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
payment.hwid_valid_until = hwid_renewal_valid_until
|
||||||
|
elif applied_promo_bonus_days > 0 and current_active_sub:
|
||||||
|
try:
|
||||||
|
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||||
|
session,
|
||||||
|
subscription_id=current_active_sub.subscription_id,
|
||||||
|
at=datetime.now(timezone.utc),
|
||||||
|
subscription_end_before=start_date,
|
||||||
|
delta=timedelta(days=applied_promo_bonus_days),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to extend HWID device purchases for promo payment bonus of user %s",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_user_uuid, panel_sub_link_id
|
session, panel_user_uuid, panel_sub_link_id
|
||||||
)
|
)
|
||||||
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
|
|||||||
premium_topup_balance_bytes,
|
premium_topup_balance_bytes,
|
||||||
premium_topup_used_bytes,
|
premium_topup_used_bytes,
|
||||||
)
|
)
|
||||||
effective_monthly_price = float(payment_amount) / max(1, months_int)
|
subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price)
|
||||||
|
effective_monthly_price = subscription_amount_for_pricing / max(1, months_int)
|
||||||
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
|
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
|
||||||
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
|
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
|
||||||
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
|
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
|
||||||
@@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin:
|
|||||||
"traffic_limit_bytes": traffic_limit_bytes,
|
"traffic_limit_bytes": traffic_limit_bytes,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"skip_notifications": False,
|
"skip_notifications": False,
|
||||||
|
# A real payment restores the full reminder spectrum, clearing any
|
||||||
|
# trial/bonus suppression carried over on this panel subscription.
|
||||||
|
"suppress_early_expiry_notifications": False,
|
||||||
"auto_renew_enabled": auto_renew_should_enable,
|
"auto_renew_enabled": auto_renew_should_enable,
|
||||||
"tariff_key": tariff.key if tariff else None,
|
"tariff_key": tariff.key if tariff else None,
|
||||||
"tier_baseline_bytes": tier_baseline_bytes,
|
"tier_baseline_bytes": tier_baseline_bytes,
|
||||||
@@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin:
|
|||||||
|
|
||||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||||
|
hwid_devices_renewed_count = 0
|
||||||
|
hwid_devices_renewed_until = None
|
||||||
|
if hwid_renewal_devices > 0:
|
||||||
|
if (
|
||||||
|
hwid_renewal_valid_from
|
||||||
|
and hwid_renewal_valid_until
|
||||||
|
and hwid_renewal_valid_from < hwid_renewal_valid_until
|
||||||
|
):
|
||||||
|
await tariff_dal.create_hwid_device_purchase(
|
||||||
|
session,
|
||||||
|
subscription_id=new_or_updated_sub.subscription_id,
|
||||||
|
payment_id=payment_db_id,
|
||||||
|
purchased_devices=hwid_renewal_devices,
|
||||||
|
valid_from=hwid_renewal_valid_from,
|
||||||
|
valid_until=hwid_renewal_valid_until,
|
||||||
|
)
|
||||||
|
hwid_devices_renewed_count = hwid_renewal_devices
|
||||||
|
hwid_devices_renewed_until = hwid_renewal_valid_until
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"Skipping HWID renewal purchase for payment %s: invalid window %s -> %s",
|
||||||
|
payment_db_id,
|
||||||
|
hwid_renewal_valid_from,
|
||||||
|
hwid_renewal_valid_until,
|
||||||
|
)
|
||||||
|
|
||||||
await self._send_payment_success_email(
|
await self._send_payment_success_email(
|
||||||
db_user=db_user,
|
db_user=db_user,
|
||||||
@@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin:
|
|||||||
"subscription_url": final_subscription_url,
|
"subscription_url": final_subscription_url,
|
||||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||||
"tariff_key": tariff.key if tariff else None,
|
"tariff_key": tariff.key if tariff else None,
|
||||||
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
|
"hwid_devices_renewal_recommended_count": 0
|
||||||
"hwid_devices_valid_until": hwid_devices_valid_until,
|
if hwid_devices_renewed_count
|
||||||
|
else extra_hwid_devices,
|
||||||
|
"hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until,
|
||||||
|
"hwid_devices_renewed_count": hwid_devices_renewed_count,
|
||||||
|
"hwid_devices_renewed_until": hwid_devices_renewed_until,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def extend_active_subscription_days(
|
async def extend_active_subscription_days(
|
||||||
@@ -725,6 +782,8 @@ class SubscriptionLifecycleMixin:
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
bonus_days: int,
|
bonus_days: int,
|
||||||
reason: str = "bonus",
|
reason: str = "bonus",
|
||||||
|
extend_hwid_devices: bool = True,
|
||||||
|
tariff_key: Optional[str] = None,
|
||||||
) -> Optional[datetime]:
|
) -> Optional[datetime]:
|
||||||
reason_lower = (reason or "").lower()
|
reason_lower = (reason or "").lower()
|
||||||
apply_main_traffic_limit = any(
|
apply_main_traffic_limit = any(
|
||||||
@@ -751,6 +810,17 @@ class SubscriptionLifecycleMixin:
|
|||||||
preserve_tariff_limits = bool(
|
preserve_tariff_limits = bool(
|
||||||
active_sub and active_sub.tariff_key and self._tariffs_config()
|
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:
|
if not active_sub or not active_sub.end_date:
|
||||||
logging.info(
|
logging.info(
|
||||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
||||||
@@ -760,10 +830,16 @@ class SubscriptionLifecycleMixin:
|
|||||||
|
|
||||||
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501
|
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501
|
||||||
traffic_limit = (
|
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
|
if apply_main_traffic_limit
|
||||||
else self.settings.trial_traffic_limit_bytes
|
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 = {
|
bonus_sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -776,6 +852,24 @@ class SubscriptionLifecycleMixin:
|
|||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": traffic_limit,
|
"traffic_limit_bytes": traffic_limit,
|
||||||
"auto_renew_enabled": False,
|
"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,
|
||||||
}
|
}
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, panel_sub_uuid
|
session, panel_uuid, panel_sub_uuid
|
||||||
@@ -792,6 +886,21 @@ class SubscriptionLifecycleMixin:
|
|||||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||||
session, active_sub.subscription_id, new_end_date_obj
|
session, active_sub.subscription_id, new_end_date_obj
|
||||||
)
|
)
|
||||||
|
if updated_sub_model and extend_hwid_devices:
|
||||||
|
try:
|
||||||
|
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||||
|
session,
|
||||||
|
subscription_id=active_sub.subscription_id,
|
||||||
|
at=now_utc,
|
||||||
|
subscription_end_before=current_end_date,
|
||||||
|
delta=timedelta(days=bonus_days),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to extend HWID device purchases for %s bonus of user %s",
|
||||||
|
reason,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
apply_main_traffic_limit
|
apply_main_traffic_limit
|
||||||
@@ -810,13 +919,35 @@ class SubscriptionLifecycleMixin:
|
|||||||
panel_update_payload = self._build_panel_update_payload(
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
expire_at=new_end_date_obj,
|
expire_at=new_end_date_obj,
|
||||||
traffic_limit_bytes=(
|
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
|
if apply_main_traffic_limit and not preserve_tariff_limits
|
||||||
else None
|
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_uuid=False,
|
||||||
include_default_squads=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_update_success = await self.panel_service.update_user_details_on_panel(
|
||||||
panel_uuid,
|
panel_uuid,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class PaymentContextMixin:
|
|||||||
"severpay": "SeverPay",
|
"severpay": "SeverPay",
|
||||||
"wata": "Wata",
|
"wata": "Wata",
|
||||||
"cryptopay": "Crypto Pay",
|
"cryptopay": "Crypto Pay",
|
||||||
|
"paykilla": "PayKilla",
|
||||||
"telegram_stars": "Telegram Stars",
|
"telegram_stars": "Telegram Stars",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class PaymentContextMixin:
|
|||||||
payment.sale_mode = sale_mode
|
payment.sale_mode = sale_mode
|
||||||
payment.tariff_key = tariff_key
|
payment.tariff_key = tariff_key
|
||||||
payment.purchased_gb = purchased_gb
|
payment.purchased_gb = purchased_gb
|
||||||
|
if purchased_hwid_devices is not None:
|
||||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||||
if hwid_valid_from is not None:
|
if hwid_valid_from is not None:
|
||||||
payment.hwid_valid_from = hwid_valid_from
|
payment.hwid_valid_from = hwid_valid_from
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class RenewalMixin:
|
|||||||
|
|
||||||
months = sub.duration_months or 1
|
months = sub.duration_months or 1
|
||||||
currency = default_payment_currency_code_for_settings(self.settings)
|
currency = default_payment_currency_code_for_settings(self.settings)
|
||||||
|
tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None
|
||||||
|
sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription"
|
||||||
amount = None
|
amount = None
|
||||||
tariffs_config = (
|
tariffs_config = (
|
||||||
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
||||||
@@ -62,11 +64,55 @@ class RenewalMixin:
|
|||||||
logging.error(f"Auto-renew price missing for {months} months")
|
logging.error(f"Auto-renew price missing for {months} months")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
hwid_quote = None
|
||||||
|
quote_hwid_renewal = getattr(
|
||||||
|
self,
|
||||||
|
"quote_hwid_device_renewal_for_subscription",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if tariff_key and callable(quote_hwid_renewal):
|
||||||
|
try:
|
||||||
|
hwid_quote = await quote_hwid_renewal(
|
||||||
|
session,
|
||||||
|
user_id=sub.user_id,
|
||||||
|
target_tariff_key=tariff_key,
|
||||||
|
months=int(months),
|
||||||
|
currency=default_currency_key_for_settings(self.settings),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to quote HWID devices for auto-renew user %s",
|
||||||
|
sub.user_id,
|
||||||
|
)
|
||||||
|
hwid_quote = None
|
||||||
|
if hwid_quote:
|
||||||
|
amount = float(amount) + float(hwid_quote.get("price") or 0)
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"user_id": str(sub.user_id),
|
"user_id": str(sub.user_id),
|
||||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||||
"subscription_months": str(months),
|
"subscription_months": str(months),
|
||||||
|
"sale_mode": sale_mode,
|
||||||
}
|
}
|
||||||
|
if hwid_quote:
|
||||||
|
metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0))
|
||||||
|
for source_key, metadata_key in (
|
||||||
|
("valid_from", "hwid_valid_from"),
|
||||||
|
("valid_until", "hwid_valid_until"),
|
||||||
|
):
|
||||||
|
value = hwid_quote.get(source_key)
|
||||||
|
if value:
|
||||||
|
metadata[metadata_key] = (
|
||||||
|
value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||||
|
)
|
||||||
|
for key in (
|
||||||
|
"pricing_period_months",
|
||||||
|
"proration_ratio",
|
||||||
|
"full_price",
|
||||||
|
):
|
||||||
|
value = hwid_quote.get(key)
|
||||||
|
if value is not None:
|
||||||
|
metadata[f"hwid_{key}"] = str(value)
|
||||||
resp = await yk.create_payment(
|
resp = await yk.create_payment(
|
||||||
amount=float(amount),
|
amount=float(amount),
|
||||||
currency=currency,
|
currency=currency,
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ class TariffMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
|
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
|
||||||
if base_limit is None:
|
if base_limit is None:
|
||||||
return None
|
return 0
|
||||||
base_int = max(0, int(base_limit))
|
base_int = max(0, int(base_limit))
|
||||||
if base_int == 0:
|
if base_int == 0:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class TrialSubscriptionMixin:
|
|||||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||||
"auto_renew_enabled": False,
|
"auto_renew_enabled": False,
|
||||||
"provider": "trial",
|
"provider": "trial",
|
||||||
|
# Short trial: only warn a few hours before it ends, not days ahead.
|
||||||
|
"suppress_early_expiry_notifications": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from bot.infra.redis import redis_lock
|
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 config.settings import Settings
|
||||||
from db.dal import app_settings_dal, user_dal
|
from db.dal import app_settings_dal, user_dal
|
||||||
|
|
||||||
@@ -169,11 +174,15 @@ class TelemetryWorker:
|
|||||||
|
|
||||||
version = resolve_app_version()
|
version = resolve_app_version()
|
||||||
version_tag = resolve_app_version_tag()
|
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
|
# Person properties (``$set``) snapshot the latest state per install, so
|
||||||
# "version breakdown" in PostHog is a person-property breakdown.
|
# "version breakdown" in PostHog is a person-property breakdown.
|
||||||
person_props = {
|
person_props = {
|
||||||
"app_version": version,
|
"app_version": version,
|
||||||
"app_version_tag": version_tag,
|
"app_version_tag": version_tag,
|
||||||
|
"build_provenance": build_provenance,
|
||||||
|
"image_modified": image_modified,
|
||||||
"os": platform.system().lower() or "unknown",
|
"os": platform.system().lower() or "unknown",
|
||||||
"arch": platform.machine().lower() or "unknown",
|
"arch": platform.machine().lower() or "unknown",
|
||||||
"python_version": platform.python_version(),
|
"python_version": platform.python_version(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminStates(StatesGroup):
|
|||||||
waiting_for_user_delete_confirmation = State()
|
waiting_for_user_delete_confirmation = State()
|
||||||
waiting_for_premium_override_bonus_gb = State()
|
waiting_for_premium_override_bonus_gb = State()
|
||||||
waiting_for_traffic_grant_gb = State()
|
waiting_for_traffic_grant_gb = State()
|
||||||
|
waiting_for_hwid_device_limit = State()
|
||||||
|
|
||||||
# Ads campaigns
|
# Ads campaigns
|
||||||
waiting_for_ad_source = State()
|
waiting_for_ad_source = State()
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ the runtime container agree on the value:
|
|||||||
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
||||||
> ``dev+unknown``
|
> ``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
|
The same value powers the admin sidebar (web process) and the anonymous
|
||||||
telemetry beacon (worker process), so "active installs" and version
|
telemetry beacon (worker process), so "active installs" and version
|
||||||
breakdowns line up across both.
|
breakdowns line up across both.
|
||||||
@@ -24,6 +27,16 @@ from typing import Optional
|
|||||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
_APP_VERSION_CACHE: Optional[str] = None
|
_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:
|
def _run_git_command(*args: str) -> str:
|
||||||
@@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str:
|
|||||||
return ""
|
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:
|
def resolve_app_version() -> str:
|
||||||
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
||||||
global _APP_VERSION_CACHE
|
global _APP_VERSION_CACHE
|
||||||
@@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str:
|
|||||||
if tag:
|
if tag:
|
||||||
return tag
|
return tag
|
||||||
return resolve_app_version()
|
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
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
from typing import Optional
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE)
|
||||||
|
_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$")
|
||||||
|
|
||||||
|
|
||||||
def normalize_required_channel_id(value: object) -> Optional[int]:
|
def normalize_required_channel_id(value: object) -> Optional[int]:
|
||||||
@@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]:
|
|||||||
return -int(f"100{raw_abs}")
|
return -int(f"100{raw_abs}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_required_channel_link(value: object) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw = str(value).strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _TELEGRAM_LINK_RE.match(raw):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
raw = raw.lstrip("@").strip()
|
||||||
|
if not raw or re.search(r"\s", raw):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if raw.startswith(("t.me/", "telegram.me/")):
|
||||||
|
return f"https://{raw}"
|
||||||
|
|
||||||
|
if raw.startswith(("+", "joinchat/", "c/")):
|
||||||
|
return f"https://t.me/{raw}"
|
||||||
|
|
||||||
|
if _TELEGRAM_USERNAME_RE.fullmatch(raw):
|
||||||
|
return f"https://t.me/{raw}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _required_channel_link_from_chat(chat: Any) -> Optional[str]:
|
||||||
|
username = str(getattr(chat, "username", "") or "").strip().lstrip("@")
|
||||||
|
if username:
|
||||||
|
return f"https://t.me/{username}"
|
||||||
|
|
||||||
|
invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None))
|
||||||
|
if invite_link:
|
||||||
|
return invite_link
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_required_channel_link(
|
||||||
|
bot: Any,
|
||||||
|
required_channel_id: Optional[int],
|
||||||
|
configured_link: object,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if bot is not None and required_channel_id:
|
||||||
|
try:
|
||||||
|
chat = await bot.get_chat(required_channel_id)
|
||||||
|
resolved_link = _required_channel_link_from_chat(chat)
|
||||||
|
if resolved_link:
|
||||||
|
return resolved_link
|
||||||
|
except Exception as error:
|
||||||
|
logging.warning(
|
||||||
|
"Failed to resolve required channel link from chat %s: %s",
|
||||||
|
required_channel_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalize_required_channel_link(configured_link)
|
||||||
|
|
||||||
|
|
||||||
def is_required_channel_access_error(error: BaseException) -> bool:
|
def is_required_channel_access_error(error: BaseException) -> bool:
|
||||||
message = str(error).lower()
|
message = str(error).lower()
|
||||||
configuration_markers = (
|
configuration_markers = (
|
||||||
|
|||||||
@@ -34,12 +34,26 @@ def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
|
|||||||
return None
|
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()]
|
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
|
||||||
if not candidates:
|
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
|
return None
|
||||||
candidate = candidates[-1]
|
|
||||||
return candidate if _parse_ip(candidate) is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def request_client_ip(
|
def request_client_ip(
|
||||||
@@ -48,20 +62,21 @@ def request_client_ip(
|
|||||||
trusted_proxies: Optional[Sequence[str] | str] = None,
|
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
remote_ip = _parse_ip(request.remote or "")
|
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)
|
trusted_networks = parse_ip_entries(trusted_proxies)
|
||||||
if any(remote_ip in network for network in trusted_networks):
|
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:
|
if forwarded_ip:
|
||||||
return forwarded_ip
|
return forwarded_ip
|
||||||
|
|
||||||
if remote_ip:
|
if remote_ip:
|
||||||
return str(remote_ip)
|
return str(remote_ip)
|
||||||
|
|
||||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
if forwarded_ips:
|
||||||
return forwarded_ip
|
return str(forwarded_ips[-1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ip_in_allowlist(
|
def ip_in_allowlist(
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ class AsyncTTLCache:
|
|||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def get_stale(self, key: str) -> Optional[Any]:
|
||||||
|
entry = self._data.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
_, value = entry
|
||||||
|
if not self._is_cacheable(value):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_cacheable(value: Any) -> bool:
|
def _is_cacheable(value: Any) -> bool:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|||||||
+201
-37
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -25,7 +26,128 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
|
|||||||
def _split_csv(value: Optional[str]) -> List[str]:
|
def _split_csv(value: Optional[str]) -> List[str]:
|
||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
|
||||||
|
[
|
||||||
|
"10minutemail.com",
|
||||||
|
"10minutemail.net",
|
||||||
|
"10minutemail.org",
|
||||||
|
"20minutemail.com",
|
||||||
|
"33mail.com",
|
||||||
|
"anonbox.net",
|
||||||
|
"anonymbox.com",
|
||||||
|
"armyspy.com",
|
||||||
|
"byom.de",
|
||||||
|
"crazymailing.com",
|
||||||
|
"cuvox.de",
|
||||||
|
"dayrep.com",
|
||||||
|
"deadaddress.com",
|
||||||
|
"dispostable.com",
|
||||||
|
"dodgeit.com",
|
||||||
|
"dodgit.com",
|
||||||
|
"dropmail.me",
|
||||||
|
"easytrashmail.com",
|
||||||
|
"emailfake.com",
|
||||||
|
"emailondeck.com",
|
||||||
|
"emailtemporanea.com",
|
||||||
|
"emailtemporanea.net",
|
||||||
|
"einrot.com",
|
||||||
|
"fakeinbox.com",
|
||||||
|
"filzmail.com",
|
||||||
|
"fleckens.hu",
|
||||||
|
"generator.email",
|
||||||
|
"getairmail.com",
|
||||||
|
"getnada.com",
|
||||||
|
"grr.la",
|
||||||
|
"guerrillamail.biz",
|
||||||
|
"guerrillamail.com",
|
||||||
|
"guerrillamail.de",
|
||||||
|
"guerrillamail.info",
|
||||||
|
"guerrillamail.net",
|
||||||
|
"guerrillamail.org",
|
||||||
|
"guerrillamailblock.com",
|
||||||
|
"gustr.com",
|
||||||
|
"hmamail.com",
|
||||||
|
"incognitomail.org",
|
||||||
|
"inboxbear.com",
|
||||||
|
"jetable.org",
|
||||||
|
"jourrapide.com",
|
||||||
|
"kasmail.com",
|
||||||
|
"mail-temp.com",
|
||||||
|
"mailcatch.com",
|
||||||
|
"maildrop.cc",
|
||||||
|
"mailexpire.com",
|
||||||
|
"mailinator.com",
|
||||||
|
"mailinator.net",
|
||||||
|
"mailinator.org",
|
||||||
|
"mailmetrash.com",
|
||||||
|
"mailnesia.com",
|
||||||
|
"mailnull.com",
|
||||||
|
"mailpoof.com",
|
||||||
|
"mailtothis.com",
|
||||||
|
"mail.tm",
|
||||||
|
"mintemail.com",
|
||||||
|
"mohmal.com",
|
||||||
|
"moakt.com",
|
||||||
|
"mytemp.email",
|
||||||
|
"mytrashmail.com",
|
||||||
|
"nada.email",
|
||||||
|
"no-spam.ws",
|
||||||
|
"pookmail.com",
|
||||||
|
"rhyta.com",
|
||||||
|
"sharklasers.com",
|
||||||
|
"sofort-mail.de",
|
||||||
|
"spam4.me",
|
||||||
|
"spambog.com",
|
||||||
|
"spamdecoy.net",
|
||||||
|
"spamfree24.org",
|
||||||
|
"spamgourmet.com",
|
||||||
|
"spamhole.com",
|
||||||
|
"spam.la",
|
||||||
|
"spammotel.com",
|
||||||
|
"superrito.com",
|
||||||
|
"teleworm.us",
|
||||||
|
"tempail.com",
|
||||||
|
"temp-mail.io",
|
||||||
|
"temp-mail.org",
|
||||||
|
"tempmail.com",
|
||||||
|
"tempmail.dev",
|
||||||
|
"tempmail.net",
|
||||||
|
"tempmailo.com",
|
||||||
|
"temporaryemail.net",
|
||||||
|
"temporary-mail.net",
|
||||||
|
"tempr.email",
|
||||||
|
"throwawaymail.com",
|
||||||
|
"trash-mail.com",
|
||||||
|
"trash-mail.de",
|
||||||
|
"trashmail.com",
|
||||||
|
"trashmail.me",
|
||||||
|
"trashmail.net",
|
||||||
|
"trashmailer.com",
|
||||||
|
"trashymail.com",
|
||||||
|
"weg-werf-email.de",
|
||||||
|
"wegwerfmail.de",
|
||||||
|
"wegwerfmail.net",
|
||||||
|
"wegwerfmail.org",
|
||||||
|
"yomail.info",
|
||||||
|
"yopmail.com",
|
||||||
|
"yopmail.fr",
|
||||||
|
"yopmail.net",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
class DBSettings(BaseModel):
|
||||||
@@ -59,9 +181,6 @@ class WebAppSettings(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
primary_color: str
|
primary_color: str
|
||||||
logo_url: Optional[str]
|
logo_url: Optional[str]
|
||||||
logo_use_emoji: bool
|
|
||||||
logo_emoji: str
|
|
||||||
logo_emoji_font: str
|
|
||||||
favicon_use_custom: bool
|
favicon_use_custom: bool
|
||||||
favicon_url: Optional[str]
|
favicon_url: Optional[str]
|
||||||
logo_favicon_url: Optional[str]
|
logo_favicon_url: Optional[str]
|
||||||
@@ -100,14 +219,31 @@ class Settings(BaseSettings):
|
|||||||
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||||
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
|
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||||
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
|
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
|
||||||
|
PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25)
|
||||||
|
PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
|
||||||
|
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
|
||||||
|
PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15)
|
||||||
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
|
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
|
||||||
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
|
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||||
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
|
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)
|
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_INTERVAL_SECONDS: int = Field(default=3600)
|
||||||
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600)
|
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600)
|
||||||
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
|
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
|
||||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30)
|
WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30)
|
||||||
|
TELEGRAM_DROP_NON_PRIVATE_UPDATES: bool = Field(default=True)
|
||||||
|
TELEGRAM_ANTIFLOOD_ENABLED: bool = Field(default=True)
|
||||||
|
TELEGRAM_ANTIFLOOD_WINDOW_SECONDS: int = Field(default=60)
|
||||||
|
TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW: int = Field(default=180)
|
||||||
|
TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW: int = Field(default=120)
|
||||||
|
TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW: int = Field(default=240)
|
||||||
|
TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW: int = Field(default=60)
|
||||||
|
TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW: int = Field(default=30)
|
||||||
|
TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW: int = Field(default=60)
|
||||||
|
TELEGRAM_ACTION_COOLDOWN_ENABLED: bool = Field(default=True)
|
||||||
|
TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS: int = Field(default=20)
|
||||||
|
TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS: int = Field(default=30)
|
||||||
WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events")
|
WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events")
|
||||||
WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4)
|
WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4)
|
||||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
|
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
|
||||||
@@ -147,7 +283,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
|
||||||
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
|
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
|
||||||
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
|
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
|
||||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||||
@@ -186,7 +321,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
TRUSTED_PROXIES: Optional[str] = Field(
|
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
|
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -194,7 +329,7 @@ class Settings(BaseSettings):
|
|||||||
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
||||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
|
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501
|
||||||
)
|
)
|
||||||
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
|
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
@@ -208,6 +343,11 @@ class Settings(BaseSettings):
|
|||||||
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN,
|
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN,
|
||||||
description="English subscription description shown before purchase/renewal options.",
|
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_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||||
@@ -282,10 +422,35 @@ class Settings(BaseSettings):
|
|||||||
default=3,
|
default=3,
|
||||||
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
|
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
|
||||||
)
|
)
|
||||||
|
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"Allow referral welcome bonus grants for users who have not linked Telegram. "
|
||||||
|
"Disposable email domains are still blocked until Telegram is linked."
|
||||||
|
),
|
||||||
|
)
|
||||||
LEGACY_REFS: bool = Field(
|
LEGACY_REFS: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
||||||
)
|
)
|
||||||
|
MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Accept referral links imported from snoups/remnashop via legacy_referral_codes."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Try exact legacy Remnashop promo codes before uppercase normalization.",
|
||||||
|
)
|
||||||
|
MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Timestamp of the latest Remnashop import run, managed by the import script.",
|
||||||
|
)
|
||||||
|
MIGRATION_REMNASHOP_NOTES: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Operator notes for instances migrated from Remnashop.",
|
||||||
|
)
|
||||||
|
|
||||||
APP_RUNTIME_MODE: str = Field(
|
APP_RUNTIME_MODE: str = Field(
|
||||||
default="production",
|
default="production",
|
||||||
@@ -328,6 +493,13 @@ class Settings(BaseSettings):
|
|||||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||||
|
TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description=(
|
||||||
|
"Allow trial activation for users who have not linked Telegram. "
|
||||||
|
"Disposable email domains are still blocked until Telegram is linked."
|
||||||
|
),
|
||||||
|
)
|
||||||
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
|
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
@@ -373,15 +545,6 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||||
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
|
|
||||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
|
||||||
WEBAPP_LOGO_EMOJI_FONT: str = Field(
|
|
||||||
default="system",
|
|
||||||
description=(
|
|
||||||
"Emoji font for logo fallback: system, noto-color, noto-color-animated, "
|
|
||||||
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
|
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
|
||||||
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
|
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
|
||||||
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
|
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
|
||||||
@@ -440,6 +603,13 @@ class Settings(BaseSettings):
|
|||||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||||
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
|
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
|
||||||
SMTP_FROM_NAME: Optional[str] = Field(default=None)
|
SMTP_FROM_NAME: Optional[str] = Field(default=None)
|
||||||
|
DISPOSABLE_EMAIL_DOMAINS: str = Field(
|
||||||
|
default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
|
||||||
|
description=(
|
||||||
|
"Disposable email domains treated as requiring Telegram for trial and "
|
||||||
|
"referral welcome bonus abuse protection. Accepts commas or one domain per line."
|
||||||
|
),
|
||||||
|
)
|
||||||
SMTP_STARTTLS: bool = Field(default=True)
|
SMTP_STARTTLS: bool = Field(default=True)
|
||||||
SMTP_USE_SSL: bool = Field(default=False)
|
SMTP_USE_SSL: bool = Field(default=False)
|
||||||
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
||||||
@@ -544,9 +714,6 @@ class Settings(BaseSettings):
|
|||||||
title=self.WEBAPP_TITLE,
|
title=self.WEBAPP_TITLE,
|
||||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||||
logo_url=self.WEBAPP_LOGO_URL,
|
logo_url=self.WEBAPP_LOGO_URL,
|
||||||
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
|
|
||||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
|
||||||
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
|
|
||||||
favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM,
|
favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM,
|
||||||
favicon_url=self.WEBAPP_FAVICON_URL,
|
favicon_url=self.WEBAPP_FAVICON_URL,
|
||||||
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
|
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
|
||||||
@@ -627,6 +794,16 @@ class Settings(BaseSettings):
|
|||||||
return trial_squads
|
return trial_squads
|
||||||
return self.parsed_user_squad_uuids
|
return self.parsed_user_squad_uuids
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def disposable_email_domains(self) -> List[str]:
|
||||||
|
domains: List[str] = []
|
||||||
|
for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS):
|
||||||
|
normalized = domain.strip().lower().lstrip("@.")
|
||||||
|
if normalized and normalized not in domains:
|
||||||
|
domains.append(normalized)
|
||||||
|
return domains
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||||
@@ -788,21 +965,6 @@ class Settings(BaseSettings):
|
|||||||
def ignore_deprecated_webapp_logo_url_env(cls, _value):
|
def ignore_deprecated_webapp_logo_url_env(cls, _value):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@field_validator("WEBAPP_LOGO_EMOJI", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def ignore_deprecated_webapp_logo_emoji_env(cls, _value):
|
|
||||||
return "🫥"
|
|
||||||
|
|
||||||
@field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
|
|
||||||
return "system"
|
|
||||||
|
|
||||||
@field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before")
|
@field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
|
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
|
||||||
@@ -893,6 +1055,7 @@ class Settings(BaseSettings):
|
|||||||
"stars",
|
"stars",
|
||||||
"cryptopay",
|
"cryptopay",
|
||||||
"heleket",
|
"heleket",
|
||||||
|
"paykilla",
|
||||||
]
|
]
|
||||||
# Make sure default_order itself includes every registered spec.
|
# Make sure default_order itself includes every registered spec.
|
||||||
for sid in spec_ids:
|
for sid in spec_ids:
|
||||||
@@ -1105,9 +1268,10 @@ class Settings(BaseSettings):
|
|||||||
TELEMETRY_ENABLED: bool = Field(
|
TELEMETRY_ENABLED: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description=(
|
description=(
|
||||||
"Send an anonymous daily install heartbeat (version, OS, locale, "
|
"Send an anonymous daily install heartbeat (version, official/custom "
|
||||||
"user-count range). No personal data. Opt out here, via the web "
|
"image provenance, OS, locale, user-count range). No personal data. "
|
||||||
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
"Opt out here, via the web admin, or by clearing "
|
||||||
|
"TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
TELEMETRY_ENDPOINT: str = Field(
|
TELEMETRY_ENDPOINT: str = Field(
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class ThemeTokens(BaseModel):
|
|||||||
font_logo: Optional[str] = None
|
font_logo: Optional[str] = None
|
||||||
font_mono: Optional[str] = None
|
font_mono: Optional[str] = None
|
||||||
home_logo_scale: Optional[int] = None
|
home_logo_scale: Optional[int] = None
|
||||||
|
home_logo_scale_desktop: Optional[int] = None
|
||||||
|
home_logo_scale_mobile: Optional[int] = None
|
||||||
admin_bg: Optional[str] = None
|
admin_bg: Optional[str] = None
|
||||||
admin_surface: Optional[str] = None
|
admin_surface: Optional[str] = None
|
||||||
admin_surface_2: Optional[str] = None
|
admin_surface_2: Optional[str] = None
|
||||||
@@ -80,14 +82,14 @@ class ThemeTokens(BaseModel):
|
|||||||
hex_value = "".join(char * 2 for char in hex_value)
|
hex_value = "".join(char * 2 for char in hex_value)
|
||||||
return f"#{hex_value}"
|
return f"#{hex_value}"
|
||||||
|
|
||||||
@field_validator("home_logo_scale")
|
@field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]:
|
def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
scale = int(value)
|
scale = int(value)
|
||||||
if scale < 50 or scale > 300:
|
if scale < 50 or scale > 300:
|
||||||
raise ValueError("home_logo_scale must be between 50 and 300 percent")
|
raise ValueError("home logo scale must be between 50 and 300 percent")
|
||||||
return scale
|
return scale
|
||||||
|
|
||||||
|
|
||||||
@@ -369,6 +371,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
|||||||
"--success-text" not in style
|
"--success-text" not in style
|
||||||
or ".theme-key-light.app-shell" not in style
|
or ".theme-key-light.app-shell" not in style
|
||||||
or "Install guide theme surfaces" 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":
|
if key == "ascii":
|
||||||
return (
|
return (
|
||||||
@@ -379,6 +383,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
|||||||
or "Console-style tables" not in style
|
or "Console-style tables" not in style
|
||||||
or "New webapp surfaces: support, purchase info, password login" not in style
|
or "New webapp surfaces: support, purchase info, password login" not in style
|
||||||
or "Install guide theme surfaces" 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":
|
if key != "windows95":
|
||||||
return False
|
return False
|
||||||
@@ -402,6 +408,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
|||||||
or "lucide-qr-code" not in style
|
or "lucide-qr-code" not in style
|
||||||
or "New webapp surfaces: support, purchase info, password login" not in style
|
or "New webapp surfaces: support, purchase info, password login" not in style
|
||||||
or "Install guide theme surfaces" 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)
|
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -614,6 +622,38 @@ def merge_primary_accent_into_theme_tokens(
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def effective_webapp_theme_accent(
|
||||||
|
config: WebappThemesConfig,
|
||||||
|
primary_accent: str,
|
||||||
|
*,
|
||||||
|
theme_key: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the accent color users see for the selected/default Web App theme."""
|
||||||
|
try:
|
||||||
|
fallback = ThemeTokens(accent=primary_accent or "#00fe7a").accent or "#00fe7a"
|
||||||
|
except ValueError:
|
||||||
|
fallback = "#00fe7a"
|
||||||
|
theme: Optional[WebappTheme] = None
|
||||||
|
if theme_key:
|
||||||
|
theme = config.theme_by_key(theme_key)
|
||||||
|
if theme is not None and not theme.enabled:
|
||||||
|
theme = None
|
||||||
|
if theme is None:
|
||||||
|
theme = config.theme_by_key(config.default_theme)
|
||||||
|
if theme is None:
|
||||||
|
enabled = config.enabled_themes()
|
||||||
|
theme = enabled[0] if enabled else None
|
||||||
|
if theme is None:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
tokens = (
|
||||||
|
merge_primary_accent_into_theme_tokens(theme, fallback)
|
||||||
|
if theme.use_primary_accent
|
||||||
|
else theme.tokens
|
||||||
|
)
|
||||||
|
return tokens.accent or fallback
|
||||||
|
|
||||||
|
|
||||||
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
|
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
|
||||||
tokens = (
|
tokens = (
|
||||||
merge_primary_accent_into_theme_tokens(theme, primary_accent)
|
merge_primary_accent_into_theme_tokens(theme, primary_accent)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
|
|||||||
description: str,
|
description: str,
|
||||||
provider: str,
|
provider: str,
|
||||||
provider_payment_id: str,
|
provider_payment_id: str,
|
||||||
|
sale_mode: Optional[str] = None,
|
||||||
|
tariff_key: Optional[str] = None,
|
||||||
|
purchased_gb: Optional[float] = None,
|
||||||
|
purchased_hwid_devices: Optional[int] = None,
|
||||||
|
hwid_valid_from: Optional[Any] = None,
|
||||||
|
hwid_valid_until: Optional[Any] = None,
|
||||||
|
hwid_pricing_period_months: Optional[int] = None,
|
||||||
|
hwid_proration_ratio: Optional[float] = None,
|
||||||
|
hwid_full_price: Optional[float] = None,
|
||||||
) -> Payment:
|
) -> Payment:
|
||||||
"""Idempotently create a payment record for a provider event.
|
"""Idempotently create a payment record for a provider event.
|
||||||
|
|
||||||
@@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id(
|
|||||||
"provider_payment_id": provider_payment_id,
|
"provider_payment_id": provider_payment_id,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
}
|
}
|
||||||
|
optional_fields = {
|
||||||
|
"sale_mode": sale_mode,
|
||||||
|
"tariff_key": tariff_key,
|
||||||
|
"purchased_gb": purchased_gb,
|
||||||
|
"purchased_hwid_devices": purchased_hwid_devices,
|
||||||
|
"hwid_valid_from": hwid_valid_from,
|
||||||
|
"hwid_valid_until": hwid_valid_until,
|
||||||
|
"hwid_pricing_period_months": hwid_pricing_period_months,
|
||||||
|
"hwid_proration_ratio": hwid_proration_ratio,
|
||||||
|
"hwid_full_price": hwid_full_price,
|
||||||
|
}
|
||||||
|
payment_payload.update(
|
||||||
|
{field: value for field, value in optional_fields.items() if value is not None}
|
||||||
|
)
|
||||||
return await create_payment_record(session, payment_payload)
|
return await create_payment_record(session, payment_payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,31 +116,41 @@ async def find_recent_pending_provider_payment(
|
|||||||
provider: str,
|
provider: str,
|
||||||
pending_status: str,
|
pending_status: str,
|
||||||
amount: float,
|
amount: float,
|
||||||
|
currency: Optional[str],
|
||||||
sale_mode: Optional[str],
|
sale_mode: Optional[str],
|
||||||
months: Optional[int],
|
months: Optional[int],
|
||||||
purchased_gb: Optional[float],
|
purchased_gb: Optional[float],
|
||||||
purchased_hwid_devices: Optional[int],
|
purchased_hwid_devices: Optional[int],
|
||||||
tariff_key: Optional[str] = None,
|
tariff_key: Optional[str] = None,
|
||||||
since_minutes: int = 60,
|
since_minutes: Optional[int] = None,
|
||||||
) -> Optional[Payment]:
|
) -> Optional[Payment]:
|
||||||
"""Return the most recent pending payment matching the given tariff parameters.
|
"""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
|
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``
|
on repeated user clicks. A generic or provider-specific payment id must be
|
||||||
are returned — without it, there's no link to reuse.
|
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
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
|
|
||||||
|
|
||||||
conditions = [
|
conditions = [
|
||||||
Payment.user_id == user_id,
|
Payment.user_id == user_id,
|
||||||
Payment.provider == provider,
|
Payment.provider == provider,
|
||||||
Payment.status == pending_status,
|
func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})),
|
||||||
|
or_(
|
||||||
Payment.provider_payment_id.isnot(None),
|
Payment.provider_payment_id.isnot(None),
|
||||||
Payment.created_at >= cutoff,
|
Payment.yookassa_payment_id.isnot(None),
|
||||||
|
),
|
||||||
func.abs(Payment.amount - float(amount)) < 0.01,
|
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:
|
if sale_mode is not None:
|
||||||
conditions.append(Payment.sale_mode == sale_mode)
|
conditions.append(Payment.sale_mode == sale_mode)
|
||||||
if tariff_key is not None:
|
if tariff_key is not None:
|
||||||
@@ -215,12 +248,18 @@ async def count_user_succeeded_payments(
|
|||||||
|
|
||||||
|
|
||||||
async def update_provider_payment_and_status(
|
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]:
|
) -> Optional[Payment]:
|
||||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||||
if payment:
|
if payment:
|
||||||
payment.status = new_status
|
payment.status = new_status
|
||||||
payment.provider_payment_id = provider_payment_id
|
payment.provider_payment_id = provider_payment_id
|
||||||
|
if provider_payment_url:
|
||||||
|
payment.provider_payment_url = provider_payment_url
|
||||||
payment.updated_at = func.now()
|
payment.updated_at = func.now()
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(payment)
|
await session.refresh(payment)
|
||||||
|
|||||||
@@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt
|
|||||||
return await session.get(PromoCode, promo_code_id)
|
return await session.get(PromoCode, promo_code_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]:
|
||||||
|
code = str(code_str or "").strip()
|
||||||
|
if not code:
|
||||||
|
return []
|
||||||
|
candidates = [code] if preserve_case else []
|
||||||
|
upper_code = code.upper()
|
||||||
|
if upper_code not in candidates:
|
||||||
|
candidates.append(upper_code)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
async def get_promo_code_by_code(
|
||||||
|
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||||
|
) -> Optional[PromoCode]:
|
||||||
"""Get promo code by code string (regardless of active status)"""
|
"""Get promo code by code string (regardless of active status)"""
|
||||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||||
|
stmt = select(PromoCode).where(PromoCode.code == candidate)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
promo = result.scalar_one_or_none()
|
||||||
|
if promo:
|
||||||
|
return promo
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_active_promo_code_by_code_str(
|
async def get_active_promo_code_by_code_str(
|
||||||
session: AsyncSession, code_str: str
|
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||||
) -> Optional[PromoCode]:
|
) -> Optional[PromoCode]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||||
stmt = select(PromoCode).where(
|
stmt = select(PromoCode).where(
|
||||||
PromoCode.code == code_str.upper(),
|
PromoCode.code == candidate,
|
||||||
PromoCode.is_active == True,
|
PromoCode.is_active == True,
|
||||||
PromoCode.current_activations < PromoCode.max_activations,
|
PromoCode.current_activations < PromoCode.max_activations,
|
||||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
|
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
promo = result.scalar_one_or_none()
|
||||||
|
if promo:
|
||||||
|
return promo
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_promo_codes(
|
async def get_all_active_promo_codes(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import inspect
|
import inspect
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import and_, delete, func, or_, select, update
|
from sqlalchemy import and_, delete, func, or_, select, update
|
||||||
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
|
|||||||
return result.rowcount or 0
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_aware_utc(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def extend_hwid_device_purchases_for_subscription_bonus(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
subscription_id: int,
|
||||||
|
at: Optional[datetime] = None,
|
||||||
|
subscription_end_before: Optional[datetime] = None,
|
||||||
|
delta: timedelta,
|
||||||
|
) -> int:
|
||||||
|
if delta.total_seconds() <= 0:
|
||||||
|
return 0
|
||||||
|
at = _normalize_aware_utc(at or datetime.now(timezone.utc))
|
||||||
|
end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None
|
||||||
|
|
||||||
|
target_records: List[HwidDevicePurchase] = []
|
||||||
|
if end_before:
|
||||||
|
tail_result = await session.execute(
|
||||||
|
select(HwidDevicePurchase).where(
|
||||||
|
and_(
|
||||||
|
HwidDevicePurchase.subscription_id == subscription_id,
|
||||||
|
HwidDevicePurchase.purchased_devices > 0,
|
||||||
|
HwidDevicePurchase.valid_until.is_not(None),
|
||||||
|
HwidDevicePurchase.valid_until >= end_before,
|
||||||
|
HwidDevicePurchase.valid_until > at,
|
||||||
|
or_(
|
||||||
|
HwidDevicePurchase.valid_from.is_(None),
|
||||||
|
HwidDevicePurchase.valid_from < end_before,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_records = list(tail_result.scalars().all())
|
||||||
|
|
||||||
|
if not target_records:
|
||||||
|
active_result = await session.execute(
|
||||||
|
select(HwidDevicePurchase).where(
|
||||||
|
and_(
|
||||||
|
*_hwid_active_conditions(subscription_id, at),
|
||||||
|
HwidDevicePurchase.valid_until.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_records = list(active_result.scalars().all())
|
||||||
|
|
||||||
|
for record in target_records:
|
||||||
|
if record.valid_until is not None:
|
||||||
|
record.valid_until = _normalize_aware_utc(record.valid_until) + delta
|
||||||
|
if target_records:
|
||||||
|
await session.flush()
|
||||||
|
return len(target_records)
|
||||||
|
|
||||||
|
|
||||||
async def create_tariff_change(
|
async def create_tariff_change(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
change_data: Dict[str, Any],
|
change_data: Dict[str, Any],
|
||||||
|
|||||||
+189
-6
@@ -4,7 +4,7 @@ import string
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import and_, case, delete, desc, func, or_, update
|
from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
@@ -14,6 +14,8 @@ from ..models import (
|
|||||||
AdAttribution,
|
AdAttribution,
|
||||||
EmailVerificationCode,
|
EmailVerificationCode,
|
||||||
HwidDevicePurchase,
|
HwidDevicePurchase,
|
||||||
|
LegacyImportMapping,
|
||||||
|
LegacyReferralCode,
|
||||||
MessageLog,
|
MessageLog,
|
||||||
Payment,
|
Payment,
|
||||||
PromoCodeActivation,
|
PromoCodeActivation,
|
||||||
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
|||||||
Returns the existing or newly generated code.
|
Returns the existing or newly generated code.
|
||||||
"""
|
"""
|
||||||
if user.referral_code:
|
if user.referral_code:
|
||||||
normalized = user.referral_code.strip().upper()
|
normalized = user.referral_code.strip()
|
||||||
if normalized != user.referral_code:
|
if normalized != user.referral_code:
|
||||||
user.referral_code = normalized
|
user.referral_code = normalized
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
|||||||
if not user_data.get("referral_code"):
|
if not user_data.get("referral_code"):
|
||||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||||
else:
|
else:
|
||||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
user_data["referral_code"] = user_data["referral_code"].strip()
|
||||||
|
|
||||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -567,6 +569,19 @@ async def merge_users(
|
|||||||
await session.execute(
|
await session.execute(
|
||||||
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
||||||
)
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(LegacyReferralCode)
|
||||||
|
.where(LegacyReferralCode.user_id == source_user_id)
|
||||||
|
.values(user_id=target_user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(LegacyImportMapping)
|
||||||
|
.where(
|
||||||
|
LegacyImportMapping.target_table == "users",
|
||||||
|
LegacyImportMapping.target_id == str(source_user_id),
|
||||||
|
)
|
||||||
|
.values(target_id=str(target_user_id))
|
||||||
|
)
|
||||||
|
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(MessageLog)
|
update(MessageLog)
|
||||||
@@ -590,13 +605,60 @@ async def merge_users(
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
async def get_user_by_referral_code(
|
||||||
normalized = referral_code.strip().upper()
|
session: AsyncSession,
|
||||||
|
referral_code: str,
|
||||||
|
*,
|
||||||
|
include_legacy: bool = False,
|
||||||
|
) -> Optional[User]:
|
||||||
|
normalized = referral_code.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stmt = select(User).where(User.referral_code == normalized)
|
stmt = select(User).where(User.referral_code == normalized)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
upper_normalized = normalized.upper()
|
||||||
|
if upper_normalized != normalized:
|
||||||
|
stmt = select(User).where(User.referral_code == upper_normalized)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
if not include_legacy:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||||
|
.where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
if upper_normalized != normalized:
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||||
|
.where(
|
||||||
|
LegacyReferralCode.code == upper_normalized,
|
||||||
|
LegacyReferralCode.is_active == True,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def update_user(
|
async def update_user(
|
||||||
@@ -653,6 +715,12 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i
|
|||||||
return result.scalars().all()
|
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]:
|
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
|
||||||
stmt = select(User).where(User.panel_user_uuid.is_not(None))
|
stmt = select(User).where(User.panel_user_uuid.is_not(None))
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -828,6 +896,27 @@ async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[i
|
|||||||
return result.scalars().all()
|
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]:
|
async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]:
|
||||||
"""Return non-banned user IDs who do NOT have any active subscription."""
|
"""Return non-banned user IDs who do NOT have any active subscription."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -857,6 +946,61 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
|||||||
return result.scalars().all()
|
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.
|
||||||
|
|
||||||
|
These are users who registered but have no ``Subscription`` rows at all —
|
||||||
|
no active, no expired and no trial history. In other words, accounts that
|
||||||
|
signed up and never did anything.
|
||||||
|
"""
|
||||||
|
any_sub = aliased(Subscription)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(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 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):
|
def _expired_subscription_exists_for_user(now: datetime):
|
||||||
expired_subs = aliased(Subscription)
|
expired_subs = aliased(Subscription)
|
||||||
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
|
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
|
||||||
@@ -903,6 +1047,20 @@ async def count_users_with_expired_subscription(session: AsyncSession) -> int:
|
|||||||
return int(result.scalar_one() or 0)
|
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]:
|
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."""
|
"""Return non-banned user IDs with an expired subscription and no active one."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -997,6 +1155,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
|||||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||||
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
||||||
|
await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(LegacyImportMapping).where(
|
||||||
|
or_(
|
||||||
|
and_(
|
||||||
|
LegacyImportMapping.target_table == "users",
|
||||||
|
LegacyImportMapping.target_id == str(user_id),
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
LegacyImportMapping.target_table == "subscriptions",
|
||||||
|
LegacyImportMapping.target_id.in_(
|
||||||
|
select(cast(Subscription.subscription_id, String)).where(
|
||||||
|
Subscription.user_id == user_id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
LegacyImportMapping.target_table == "payments",
|
||||||
|
LegacyImportMapping.target_id.in_(
|
||||||
|
select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||||
|
|
||||||
|
|||||||
@@ -1070,6 +1070,98 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
table_names = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
if "users" in table_names:
|
||||||
|
columns = {col["name"]: col for col in inspector.get_columns("users")}
|
||||||
|
referral_column = columns.get("referral_code")
|
||||||
|
length = getattr(referral_column.get("type"), "length", None) if referral_column else None
|
||||||
|
if referral_column and (length is None or int(length) < 64):
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)")
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS legacy_referral_codes (
|
||||||
|
legacy_code_id SERIAL PRIMARY KEY,
|
||||||
|
source VARCHAR(64) NOT NULL DEFAULT 'remnashop',
|
||||||
|
code VARCHAR(128) NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for stmt in [
|
||||||
|
(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source "
|
||||||
|
"ON legacy_referral_codes (source)"
|
||||||
|
),
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)",
|
||||||
|
(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id "
|
||||||
|
"ON legacy_referral_codes (user_id)"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active "
|
||||||
|
"ON legacy_referral_codes (is_active)"
|
||||||
|
),
|
||||||
|
]:
|
||||||
|
connection.execute(text(stmt))
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS legacy_import_mappings (
|
||||||
|
source VARCHAR(64) NOT NULL,
|
||||||
|
entity_type VARCHAR(64) NOT NULL,
|
||||||
|
source_id VARCHAR(128) NOT NULL,
|
||||||
|
target_table VARCHAR(128) NOT NULL,
|
||||||
|
target_id VARCHAR(128) NOT NULL,
|
||||||
|
metadata_json TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NULL,
|
||||||
|
PRIMARY KEY (source, entity_type, source_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target
|
||||||
|
ON legacy_import_mappings (target_table, target_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
|
||||||
|
if "suppress_early_expiry_notifications" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications "
|
||||||
|
"BOOLEAN NOT NULL DEFAULT FALSE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -1247,6 +1339,21 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0034_add_legacy_import_compatibility",
|
||||||
|
description="Store legacy import mappings and referral codes for source-bot migrations",
|
||||||
|
upgrade=_migration_0034_add_legacy_import_compatibility,
|
||||||
|
),
|
||||||
|
Migration(
|
||||||
|
id="0035_add_subscription_promo_expiry_flag",
|
||||||
|
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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -43,7 +43,7 @@ class User(Base):
|
|||||||
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
is_banned = Column(Boolean, default=False)
|
is_banned = Column(Boolean, default=False)
|
||||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
referral_code = Column(String(64), nullable=True, unique=True, index=True)
|
||||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -122,6 +122,12 @@ class Subscription(Base):
|
|||||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||||
provider = Column(String, nullable=True)
|
provider = Column(String, nullable=True)
|
||||||
skip_notifications = Column(Boolean, default=False)
|
skip_notifications = Column(Boolean, default=False)
|
||||||
|
# Trial and registration/referral-bonus subscriptions are only a few days
|
||||||
|
# long, so the multi-day "ending soon" reminders would fire almost as soon
|
||||||
|
# as they are granted. While this is set the worker keeps only the
|
||||||
|
# hours-before reminder plus the expiry/after-expiry notices; a real payment
|
||||||
|
# clears it so the full reminder spectrum resumes.
|
||||||
|
suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False)
|
||||||
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||||
tariff_key = Column(String, nullable=True, index=True)
|
tariff_key = Column(String, nullable=True, index=True)
|
||||||
tier_baseline_bytes = Column(BigInteger, nullable=True)
|
tier_baseline_bytes = Column(BigInteger, nullable=True)
|
||||||
@@ -197,6 +203,7 @@ class Payment(Base):
|
|||||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||||
yookassa_payment_id = Column(String, unique=True, index=True, nullable=True)
|
yookassa_payment_id = Column(String, unique=True, index=True, nullable=True)
|
||||||
provider_payment_id = Column(String, unique=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)
|
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||||
idempotence_key = Column(String, unique=True, nullable=True)
|
idempotence_key = Column(String, unique=True, nullable=True)
|
||||||
amount = Column(Float, nullable=False)
|
amount = Column(Float, nullable=False)
|
||||||
@@ -396,6 +403,35 @@ class PromoCodeActivation(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyReferralCode(Base):
|
||||||
|
__tablename__ = "legacy_referral_codes"
|
||||||
|
|
||||||
|
legacy_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
source = Column(String(64), nullable=False, default="remnashop", index=True)
|
||||||
|
code = Column(String(128), nullable=False, index=True)
|
||||||
|
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),)
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyImportMapping(Base):
|
||||||
|
__tablename__ = "legacy_import_mappings"
|
||||||
|
|
||||||
|
source = Column(String(64), primary_key=True)
|
||||||
|
entity_type = Column(String(64), primary_key=True)
|
||||||
|
source_id = Column(String(128), primary_key=True)
|
||||||
|
target_table = Column(String(128), nullable=False)
|
||||||
|
target_id = Column(String(128), nullable=False)
|
||||||
|
metadata_json = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class MessageLog(Base):
|
class MessageLog(Base):
|
||||||
__tablename__ = "message_logs"
|
__tablename__ = "message_logs"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
aiogram==3.28.2
|
# Upstream aiogram 3.28.2 commit with aiohttp<3.15 support.
|
||||||
|
aiogram @ https://github.com/aiogram/aiogram/archive/8c2f78d51f10f3e2189d3828ba80ca3c1f094b5d.zip
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
aiohttp==3.13.5
|
aiohttp>=3.14.0,<4
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
yookassa==3.10.1
|
yookassa==3.10.1
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
@@ -9,6 +10,6 @@ email-validator==2.3.0
|
|||||||
sqlalchemy[asyncio]==2.0.49
|
sqlalchemy[asyncio]==2.0.49
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
aiocryptopay==0.4.8
|
aiocryptopay==0.4.8
|
||||||
PyJWT[crypto]==2.12.1
|
PyJWT[crypto]==2.13.0
|
||||||
Pillow==12.2.0
|
Pillow==12.2.0
|
||||||
redis==6.4.0
|
redis==6.4.0
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Operational one-shot scripts shipped with the backend image."""
|
||||||
File diff suppressed because it is too large
Load Diff
+46
-69
@@ -1,11 +1,12 @@
|
|||||||
# Resolve the application version from .git at build time and emit a tiny
|
# 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
|
# .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
|
# never copied into the runtime image; only the tag + commit version string and
|
||||||
# ships. Non-main builds include the branch name so they are visibly distinct
|
# a low-cardinality build provenance marker ship. Non-main builds include the
|
||||||
# from release builds. This matches the runtime fallback chain in _resolve_app_version
|
# branch name so they are visibly distinct from release builds. This matches the
|
||||||
# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
|
# runtime fallback chain in _resolve_app_version (REMNAWAVE_MINISHOP_VERSION env
|
||||||
# "dev+unknown") so the admin sidebar always shows a tag / sha even though the
|
# > .build-version file > live git > "dev+unknown") so the admin sidebar always
|
||||||
# runtime images have no git tooling and no .git tree.
|
# shows a tag / sha even though the runtime images have no git tooling and no
|
||||||
|
# .git tree.
|
||||||
FROM alpine:3.20 AS version-builder
|
FROM alpine:3.20 AS version-builder
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
@@ -14,6 +15,7 @@ ARG GIT_BRANCH=""
|
|||||||
ARG BRANCH_NAME=""
|
ARG BRANCH_NAME=""
|
||||||
ARG GITHUB_REF_NAME=""
|
ARG GITHUB_REF_NAME=""
|
||||||
ARG CI_COMMIT_REF_NAME=""
|
ARG CI_COMMIT_REF_NAME=""
|
||||||
|
ARG REMNAWAVE_MINISHOP_BUILD_PROVENANCE="custom"
|
||||||
COPY .git ./.git
|
COPY .git ./.git
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
git config --global --add safe.directory /repo; \
|
git config --global --add safe.directory /repo; \
|
||||||
@@ -40,9 +42,17 @@ RUN set -eu; \
|
|||||||
else \
|
else \
|
||||||
version="dev${branch_suffix}+unknown"; \
|
version="dev${branch_suffix}+unknown"; \
|
||||||
fi; \
|
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' "$version" > /build-version; \
|
||||||
printf '%s' "${tag:-unknown}" > /build-tag; \
|
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
|
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-version /app/.build-version
|
||||||
COPY --from=version-builder /build-tag /app/.build-tag
|
COPY --from=version-builder /build-tag /app/.build-tag
|
||||||
COPY --from=version-builder /build-commit /app/.build-commit
|
COPY --from=version-builder /build-commit /app/.build-commit
|
||||||
|
COPY --from=version-builder /build-provenance /app/.build-provenance
|
||||||
|
|
||||||
USER appuser
|
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
|
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" \
|
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||||
org.opencontainers.image.title="remnawave-minishop-backend" \
|
org.opencontainers.image.title="remnawave-minishop-backend" \
|
||||||
org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \
|
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"]
|
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
|
FROM nginx:1.27-alpine AS frontend
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
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/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/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||||
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
|
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; \
|
RUN set -eu; \
|
||||||
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
|
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
|
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-tag /build-tag
|
||||||
COPY --from=version-builder /build-commit /build-commit
|
COPY --from=version-builder /build-commit /build-commit
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
app.example.com {
|
app.example.com {
|
||||||
encode zstd gzip
|
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
|
reverse_proxy backend:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
web.example.com {
|
web.example.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Keep forwarded headers for Web App URL generation and request logging.
|
||||||
reverse_proxy frontend:80
|
reverse_proxy frontend:80
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,16 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-emoji|webapp-theme-css|webapp-theme-assets)/ {
|
location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-theme-css|webapp-theme-assets)/ {
|
||||||
|
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 ~ "^/(?:$|login/password$|home$|install$|trial$|s/[a-f0-9]{32}$|invite$|devices$|settings$|support(?:/[0-9]+)?$|admin(?:/.*)?$)" {
|
||||||
proxy_pass http://backend:8081;
|
proxy_pass http://backend:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -102,7 +111,9 @@ server {
|
|||||||
|
|
||||||
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||||
expires off;
|
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";
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
@@ -115,7 +126,9 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
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";
|
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
|
|
||||||
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||||
|
|
||||||
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
|
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml` в `migrate`, `backend` и `worker`. Для кастомных тем создайте `data/themes`; для ручного каталога тарифов используйте `data/tariffs.json`.
|
||||||
|
|
||||||
| Папка | Документация |
|
| Папка | Документация |
|
||||||
| --- | --- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||||
| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) |
|
| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) |
|
||||||
| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) |
|
| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) |
|
||||||
| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) |
|
| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) |
|
||||||
|
| `mail` | [Развертывание локального SMTP-сервера](../../docs/features/email-login.md#настройка-локального-smtp-сервера) |
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user