Compare commits

..
146 Commits
Author SHA1 Message Date
3252a8andGitHub c459cabaae Merge pull request #29 from 3252a8/dev
PayKilla provider, Telegram anti-flood, install wizard and Remnashop migration (test)
2026-06-10 22:40:11 +03:00
3252a8 0864413e11 fix(security): keep private identifiers and allowlists out of logs 2026-06-10 22:37:11 +03:00
3252a8andGitHub ff2ed5b9f3 Merge pull request #28 from 3252a8/feature/telegram-flood-hardening 2026-06-10 17:24:12 +03:00
3252a8 02cb2f0d27 ci: skip Docker Hub secret check for PR builds 2026-06-10 16:56:27 +03:00
3252a8 ddb372a4e5 style: format action logger test 2026-06-10 16:54:14 +03:00
3252a8 73474f70c8 Merge origin/dev into feature/telegram-flood-hardening 2026-06-10 16:49:53 +03:00
3252a8 6bb2709244 feat: add telemetry build provenance
Stamp official Docker builds with a low-cardinality provenance marker and report build_provenance/image_modified in anonymous telemetry. Local and fork builds default to custom, while official GitHub/GitLab release paths mark images as official.
2026-06-10 15:18:32 +03:00
3252a8 fd931581f5 fix(themes): style admin health alerts
Add custom-theme styles for the admin configuration alerts and bump built-in theme asset versions so existing installations refresh stale theme CSS.
2026-06-10 14:25:24 +03:00
3252a8 217bed3c5d fix(admin): avoid stale Telegram webhook alerts
Only surface Telegram delivery errors while updates are still pending, and register the webhook after the aiohttp webhook site starts listening.
2026-06-10 13:04:04 +03:00
3252a8 ab21253d4f chore(admin): drop emoji-logo cache leftovers
Emoji logos were removed long ago; the only remaining trace was a
purge of stale data/webapp-emoji cache files on theme save. Remove
the purge, the WEBAPP_EMOJI_CACHE_DIR constant and the emoji part of
the prune test.
2026-06-10 12:31:52 +03:00
3252a8 1b2290ea66 feat(admin): surface configuration problems in the admin panel
Add GET /api/admin/health powered by a config health service that
detects common deployment mistakes: missing or read-only data volume,
broken tariffs/locale-override/guides JSON files, payment providers
enabled without credentials, webhook providers without
WEBHOOK_BASE_URL, no enabled payment methods, missing or non-https
mini app URL, missing Redis, partially configured SMTP, untrusted
reverse proxy, invalid bot token, missing/mismatched/failing Telegram
webhook and unreachable Remnawave panel. Network checks (Telegram,
panel) are cached for two minutes; ?refresh=1 forces a re-check.

The admin UI shows the alerts as a banner on the dashboard with
per-section navigation chips and a manual re-check button, and as a
filtered banner inside each affected section. Alerts are localized
via admin_health_* keys with built-in Russian fallbacks.
2026-06-10 12:31:41 +03:00
3252a8 ce6273a652 fix(payments): apply request timeout changes without restart
PAYMENT_REQUEST_TIMEOUT_SECONDS was read once in each provider's
__init__ and baked into the aiohttp session, so admin overrides
(applied in-process) only took effect after a container restart.
Providers now hand HttpClientMixin a timeout source callable; the
mixin builds the session with the current value and swaps in a fresh
session when the value changes, closing the replaced one only after
any in-flight request on it is bound by its own total timeout.

Also:
- check the Heleket payment-info success flag before reading the
  payload so a non-dict provider response cannot raise in the
  pending-payment reuse path
- add PAYMENT_REQUEST_TIMEOUT_SECONDS to the FreeKassa settings stub
  in test_security.py (fixes three tests broken by the new field)
2026-06-10 11:21:15 +03:00
BADtochka 3170b966b5 fix(payments): reuse pending PayKilla invoices 2026-06-10 04:09:46 +03:00
BADtochka 1fca62de75 fix(payments): reuse pending links by provider identity 2026-06-10 03:42:49 +03:00
BADtochka 5c96fcd519 fix(payments): pedning status in transactions 2026-06-09 16:36:04 +03:00
BADtochka ca88c995b8 fix(ci): use configured Docker Hub namespace 2026-06-09 14:27:04 +03:00
BADtochka 234fc69505 feat(payments): reuse pending provider payments 2026-06-09 14:19:50 +03:00
BADtochka 1362edde42 Merge GitHub dev into GitLab dev 2026-06-09 13:21:26 +03:00
3252a8 194bf64ebb fix: serve admin bundle as hashed immutable assets
The lazy-loaded admin CSS/JS resolved to bare runtime names served
no-store, the same scheme that left the main bundle vulnerable to stale
CSS in iOS WebViews after a deploy. The original reason for keeping them
bare (hashed admin files could 404 when nginx fronts aiohttp) no longer
holds: the backend image now carries the same deterministically hashed
assets nginx serves, and the App.svelte loader already falls back to the
bare name if a hashed asset ever 404s.

Resolve the admin assets through the same hashed/version-stable path as
the main bundle so they are emitted as immutable, cache-busting URLs.

Also drop the inert <meta http-equiv="Cache-Control/Pragma/Expires">
tags from the shell: browsers ignore http-equiv caching directives for
the document and use the real HTTP headers, which are already set.
2026-06-08 22:47:44 +03:00
3252a8 a2ce29da45 fix: ship hashed webapp assets in backend image
The backend renders the Mini App shell and rewrites the stylesheet and
script tags to content-hashed names (subscription_webapp.<hash>.css).
Those hashed files are gitignored build artifacts, so a clean checkout
has none of them and the backend image was built without any webapp
assets. The resolver therefore stat()-ed a missing file and fell back to
the bare /subscription_webapp.css URL.

That bare URL never changes between deploys and is served no-store. Most
clients re-fetch it, but iOS WebViews (WKWebView) ignore no-store for
subresources and keep serving a stale cached copy, so after every deploy
the CSS no longer matched the markup and the Mini App looked broken on
iOS only. The earlier no-store / ?v= / Clear-Site-Data attempts could not
help because none of them gave iOS a new URL to fetch.

Copy the freshly built assets from the frontend-builder stage into the
backend image (frontend-builder is reordered ahead of the backend stage
so the copy resolves). The build is deterministic, so the hash matches
the one the nginx image serves; the shell now emits immutable, hashed
URLs that change on every asset change and force iOS to fetch fresh CSS.
2026-06-08 22:34:03 +03:00
3252a8 0db3a68c09 fix: drop Clear-Site-Data reset breaking mini app styles
The once-per-version Clear-Site-Data: "cache" header on the index
navigation raced the page's own CSS/JS subresource loads in the
Telegram WebView, intermittently evicting or aborting the main
stylesheet so the mini app rendered half-styled on mobile.

It also could not fix stale HTML: it only fires when the document
actually reaches the backend, never when the WebView serves a cached
page. The no-store HTML plus immutable content-hashed asset filenames
already guarantee freshness without clearing the cache, so remove the
reset header, its helpers, constants, and tests.
2026-06-08 22:12:16 +03:00
3252a8 c2f0ae0b8b fix: resolve webhook client IP behind proxies 2026-06-08 11:25:02 +03:00
3252a8 23ad893f69 fix: reset stale webapp cache once per asset version 2026-06-08 10:59:52 +03:00
3252a8 d2149357c6 fix: stabilize mini app mobile navigation 2026-06-08 10:32:53 +03:00
3252a8 63192659e4 fix: prevent stale mini app mobile styles 2026-06-08 10:02:46 +03:00
3252a8 3e58e01d53 ci: improve GitLab dev image builds 2026-06-08 09:41:50 +03:00
3252a8 8cbe7e01ec docs: clarify migrator data mount 2026-06-08 09:35:11 +03:00
3252a8 a99aeec0d4 fix: return from bot tariff prices to bot menu 2026-06-08 09:22:24 +03:00
3252a8 2697c30c0f fix: allow docs previews without pillow 2026-06-08 00:00:35 +03:00
3252a8 2077c27252 chore: align local checks 2026-06-07 23:57:06 +03:00
3252a8 a86f5d75e1 build: reduce docker image layer churn 2026-06-07 23:47:36 +03:00
3252a8 24faabc20a fix: improve mobile tariff row editor layout 2026-06-07 23:35:53 +03:00
3252a8 00e1f51abe fix: preserve transparent email logos 2026-06-07 23:21:34 +03:00
3252a8 4b8f939a25 perf: cache broadcast audience counts 2026-06-07 23:15:44 +03:00
3252a8 d1c4a6de80 feat: improve admin pagination controls 2026-06-07 23:03:03 +03:00
3252a8 bddcd16a07 fix: align tariff row delete buttons 2026-06-07 22:52:10 +03:00
3252a8 a8e229d530 fix: remove manual trial squad input 2026-06-07 22:48:23 +03:00
3252a8 5cee619dd0 fix: assign default tariff to referral welcome bonuses 2026-06-07 22:28:30 +03:00
3252a8 d5b23d8306 fix: quote frontend route regex in nginx 2026-06-07 22:25:08 +03:00
3252a8 9de000e78c fix: assign default tariff to promo bonuses 2026-06-07 22:23:42 +03:00
3252a8 2b6f25f0f8 fix: avoid backend theme dependency in docs build 2026-06-07 22:06:47 +03:00
3252a8 3c82f43c84 ci: split dev image publishing by registry 2026-06-06 23:44:24 +03:00
3252a8 724e936660 fix: hide email prompts when auth is disabled 2026-06-06 23:35:38 +03:00
3252a8 d263651b48 fix: route support tickets to configured topic 2026-06-06 23:27:22 +03:00
3252a8 3d5190639f docs: add GitLab links and Docker Hub compose images 2026-06-06 23:20:22 +03:00
BADtochka 9adcbf103a fix(admin): stabilize user modal lifecycle 2026-06-06 20:33:04 +03:00
BADtochka fcb8e51ec0 fix(admin): clear user route on modal close 2026-06-06 20:16:43 +03:00
BADtochka ae3d6a9b99 fix(admin): clear user route on modal close 2026-06-06 20:05:47 +03:00
BADtochka e0b3940b98 fix(admin): reset user modal state consistently 2026-06-06 19:47:57 +03:00
BADtochka 2ec7376981 fix(admin): ignore stale user modal loads 2026-06-06 19:30:29 +03:00
BADtochka 6d0084dd3a fix(admin): close support user modal reliably 2026-06-06 18:20:44 +03:00
BADtochka e3f35a461c fix(payments): restore default provider connections 2026-06-06 17:53:16 +03:00
BADtochka a75d2d7ac0 fix(payments): retry provider connect failures 2026-06-06 17:10:52 +03:00
BADtochka e5b0daf639 fix(payments): avoid stale provider connections 2026-06-06 15:39:34 +03:00
BADtochka f07031f32c fix(security): resolve forwarded client ip chain 2026-06-06 00:45:21 +03:00
BADtochka cc74ddec10 Log trusted forwarded client IPs 2026-06-06 00:37:03 +03:00
BADtochka 766f2a5780 Add global payment request timeout setting 2026-06-05 23:40:02 +03:00
BADtochka 1158a2d835 chore: update action jobs for fork support 2026-06-05 21:50:07 +03:00
BADtochka 1d8ad0f24d fix(payment): increase timeout error on create_transaction 2026-06-05 21:37:37 +03:00
3252a8 6913676420 fix: support nested remnawave activity fields 2026-06-05 16:19:53 +03:00
3252a8 6890b58ced feat: show user vpn connection activity 2026-06-05 16:14:34 +03:00
3252a8 4cbd4dedf5 feat: add unconnected subscriber broadcast audience 2026-06-05 16:08:40 +03:00
3252a8 7700b294b7 fix: render configured webapp title on entry 2026-06-05 15:55:38 +03:00
3252a8 4b2faa87bb fix: refresh current favicon aliases 2026-06-05 15:47:37 +03:00
3252a8 1200e8ff70 fix: prevent devices limit flicker 2026-06-05 15:45:57 +03:00
3252a8 728599c882 feat: expose Telegram anti-flood settings 2026-06-05 11:38:42 +03:00
3252a8 fd1edf38b5 feat: skip action logs for dropped Telegram updates 2026-06-05 11:24:03 +03:00
3252a8 2e4599d068 feat: add Telegram payment callback cooldowns 2026-06-05 11:19:48 +03:00
3252a8 516d699cf3 feat: drop non-private Telegram updates early 2026-06-05 11:16:16 +03:00
3252a8 6dc43182ab feat: add Telegram anti-flood action buckets 2026-06-05 11:14:34 +03:00
3252a8 5e1fe59396 feat: add early Telegram anti-flood guard 2026-06-05 11:12:14 +03:00
3252a8 af1487731a deps: allow patched aiohttp release 2026-06-05 10:39:55 +03:00
3252a8 77b124d61e fix: apply webapp theme accent in emails and deeplinks 2026-06-04 23:22:53 +03:00
3252a8 090c88603e Merge branch 'pr-27' into dev
# Conflicts:
#	docs/features/payments.md
2026-06-04 23:10:21 +03:00
3252a8andGitHub 7bb0a94918 Merge pull request #26 from austnv/main
Добавлена поддержка локального SMTP-сервера на базе Docker Mailserver
2026-06-04 22:56:32 +03:00
BADtochkaandGitHub a456718193 docs(payment): tip and note 2026-06-04 16:35:52 +03:00
3252a8 358ef6ded7 chore: expand default PayKilla payment currencies 2026-06-04 16:33:50 +03:00
3252a8 ae7bfb9621 fix: gate PayKilla by minimum payment amount 2026-06-04 16:21:10 +03:00
BADtochkaandGitHub 976c81c11c docs: restore dev note about webhooks 2026-06-04 16:16:23 +03:00
BADtochkaandGitHub 02e7bb48d6 Merge branch 'dev' into patch-1 2026-06-04 16:11:34 +03:00
BADtochkaandGitHub 81c9f6bfd3 fix(payments): replace default tld in freekassa provider 2026-06-04 15:59:35 +03:00
3252a8 bdf0622be5 fix: convert PayKilla invoices to supported currency 2026-06-04 15:57:55 +03:00
BADtochkaandGitHub cf34d8971d docs: add webhook info to remaining providers 2026-06-04 15:55:33 +03:00
BADtochkaandGitHub 0afe4f0bc5 docs: payments structure
Updated payment methods documentation for clarity and consistency. Adjusted setup instructions and links for better readability.
2026-06-04 15:46:47 +03:00
3252a8 69985e44dd fix: send tariff currency to PayKilla 2026-06-04 15:34:26 +03:00
3252a8 8d6f91b292 fix: honor PayKilla invoice currency 2026-06-04 15:24:23 +03:00
3252a8 cd4b40af6b fix: remove PayKilla redirect fields 2026-06-04 15:14:13 +03:00
3252a8 e108cc0baa fix: omit default PayKilla redirect urls 2026-06-04 15:01:40 +03:00
3252a8 5b37306061 fix: use English PayKilla invoice text 2026-06-04 14:26:32 +03:00
3252a8 5527bf0247 fix: sanitize PayKilla invoice text 2026-06-04 14:18:35 +03:00
3252a8 c46aaad7e3 feat: add PayKilla payment provider 2026-06-04 13:32:15 +03:00
austnv c733d830f6 docs: add to deploy/emaples 2026-06-04 13:07:26 +03:00
austnv bd7361e9dc feature: add docker-compose.yml for docker-mailserver; edit documentation for local SMTP-server installation 2026-06-04 12:39:59 +03:00
3252a8andGitHub 1ccda9ebd1 Merge pull request #25 from 3252a8/dependabot/pip/pip-654a3705d3
chore(deps): bump the pip group across 2 directories with 1 update
2026-06-04 11:38:05 +03:00
3252a8 661d3f7953 fix: format webapp serializer 2026-06-04 11:35:42 +03:00
3252a8 7313f90350 fix: keep aiohttp within aiogram constraints 2026-06-04 11:33:33 +03:00
dependabot[bot]and3252a8 d988812ecf chore(deps): bump the pip group across 2 directories with 1 update
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 11:33:33 +03:00
3252a8 1ee0b0a7cd feat: add email-only telegram demo states 2026-06-04 11:15:28 +03:00
3252a8 2b4a8d6953 fix: load docs demo runtime index directly 2026-06-04 11:02:06 +03:00
3252a8 2ad6b14513 feat: add telegram guardrails for trials and referrals 2026-06-04 10:51:58 +03:00
3252a8 33707b7257 chore: remove terms of service setting
Keep privacy policy and user agreement links as the supported legal documents.

Refresh the admin settings manifest, demo dataset, locales, docs, and bot menu tests.
2026-06-04 00:03:28 +03:00
3252a8 5f22c2081e docs: document payment provider webhook URLs 2026-06-03 23:55:08 +03:00
3252a8 fbb89793cb fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout.

Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows.

Add admin extension controls, docs, demo data, and regression coverage.
2026-06-03 23:51:46 +03:00
3252a8 a06884d816 feat: add admin HWID device limit overrides 2026-06-03 14:47:47 +03:00
3252a8 7b8feee99a feat: smooth sortable row interactions 2026-06-03 12:40:57 +03:00
3252a8 0a5575c1e6 fix: cover admin controls in css themes 2026-06-03 12:36:17 +03:00
3252a8 1055e58d0e docs: refresh admin tariff and appearance docs 2026-06-03 12:29:28 +03:00
3252a8 d8fe88eb01 feat: split logo scale by viewport 2026-06-03 12:24:18 +03:00
3252a8 602cbc7ee6 fix: restore draggable logo scale slider 2026-06-03 11:53:11 +03:00
3252a8 bcb7372500 fix: apply theme logo scale reliably 2026-06-03 11:42:03 +03:00
3252a8 e52c75538f feat: unify tariff package price rows 2026-06-03 11:34:08 +03:00
3252a8 5d1fd3304b docs: refresh branded email previews 2026-06-03 11:20:50 +03:00
3252a8 2d43033f98 fix: embed uploaded logo in emails 2026-06-03 11:06:19 +03:00
3252a8 101119911a refactor(webapp): remove emoji web app logo option
Drop the emoji-logo feature (and its font picker) from the Web App. Only
an uploaded/linked image logo and favicon remain; when no logo is set,
the default project logo is shown. Existing emoji-logo overrides are
ignored — the keys are gone from the manifest, so the override service
skips them and the app falls back to the default logo.

- Remove WEBAPP_LOGO_USE_EMOJI / WEBAPP_LOGO_EMOJI / WEBAPP_LOGO_EMOJI_FONT
  settings, validators, manifest entries and override/runtime plumbing.
- Strip the animated-emoji fetch/cache subsystem, the /webapp-emoji route
  and emoji branches from logo/favicon resolution; leftover emoji cache
  files are now purged on appearance save.
- Simplify BrandMark to an image-only component and drop the emoji UI
  from the admin Appearance section.
- Regenerate the demo settings manifest and clean docs, locales, nginx
  and demo data of emoji-logo references.
2026-06-03 10:55:52 +03:00
3252a8 58de153370 feat(tariffs): configurable purchase order for periods and packages
The order of enabled_periods (period tariffs) and traffic_packages
(traffic tariffs) is now the storefront order everywhere — both the
Telegram keyboard and the web app. Only new tariffs-config tariffs are
affected; legacy subscription/traffic options are untouched.

- Stop sorting periods and traffic packages in the web app plans
  serializer so it follows the configured order, matching the bot
  keyboards that already iterate the lists as-is.
- Preserve the row order through the admin draft (load and save) instead
  of sorting by months.
- Add a reusable Sortable component to the UI library (native HTML5
  drag & drop with a grip handle; bits-ui/shadcn have no such primitive)
  and use it to reorder period rows and traffic package rows in the
  tariff editor.
2026-06-03 10:14:40 +03:00
3252a8 0a294b8bf8 fix(miniapp): strip HTML tags from web app promo error messages
Localized promo errors carry Telegram-style <code> markup, which the
web app rendered as literal text. Strip tags and unescape entities
before returning the message in the JSON error.
2026-06-03 09:53:41 +03:00
3252a8 1605be0dfa deps: drop certifi pin that broke dependency resolution
certifi==2026.5.20 conflicts with aiocryptopay 0.4.8, which hard-caps
certifi<2024.0.0, so pip could not resolve and the Docker image build
failed. Revert to the transitive certifi; PYSEC-2024-230 stays unfixable
until aiocryptopay relaxes its cap. Keeps the PyJWT 2.13.0 bump.
2026-06-02 22:39:13 +03:00
3252a8 6f2bd2c443 deps: patch pyjwt and certifi security advisories
pip-audit flagged 6 advisories: PyJWT 2.12.1 (PYSEC-2026-175/177/178/179,
fixed in 2.13.0) and transitive certifi 2023.11.17 (PYSEC-2024-230, fixed
in 2024.7.4). Bump PyJWT and pin certifi to its latest release to enforce
the resolved version.
2026-06-02 22:34:36 +03:00
3252a8 36e0e4f462 fix: defer expiry reminders for trial and bonus subscriptions
Trial and registration/referral-bonus subscriptions usually last only a
few days, so the multi-day ending-soon reminders fired almost the moment
they were granted and needlessly alarmed newcomers.

Track this with a new subscription flag (suppress_early_expiry_notifications,
migration 0035): trial activation, referral welcome bonus and inviter bonus
grants set it, while a real paid purchase clears it on upsert. While set, the
notification worker skips the day-before stages but still sends the
hours-before reminder and the expiry/after-expiry notices, so users are
warned shortly before access ends. Once they pay for a full subscription the
complete reminder spectrum resumes.
2026-06-02 19:53:13 +03:00
3252a8 e15d6559aa docs: add remnashop migration to sidebar
The migrations/remnashop guide existed in docs/ but was missing from the
docs-site navigation. Add its sidebar entry after remnawave-tg-shop and a
curated sync description so it renders with the rest of the migrations.
2026-06-02 19:36:44 +03:00
3252a8 dc66cf6a06 ci: guard demo settings manifest against drift
Add a PR job that installs the backend deps and runs the
settings-manifest drift guard, so a change to admin_settings_manifest.py
without regenerating settingsManifest.generated.json fails CI instead of
silently leaving the docs demo Settings screen stale.
2026-06-02 19:31:06 +03:00
3252a8 70e6fa382b fix(docs-demo): sync admin settings sections with manifest
The demo Settings screen was fed by a frozen snapshot baked into the
externally generated demoDataset.js, so it drifted from the real
manifest: the Remnawave Panel (plus System and Migrations) sections
were missing and trial/checkout/common still showed as top-level
sections instead of subsections.

Generate frontend/src/lib/webapp/settingsManifest.generated.json from
manifest_payload() (the same source the live /admin/settings endpoint
uses) via scripts/export_settings_manifest.py, and build the demo
section structure from it, overlaying realistic demo values per field
key. A pytest drift guard fails if the Python manifest changes without
regenerating the snapshot, so the demo stays in sync going forward.
2026-06-02 19:18:11 +03:00
3252a8 20f1228218 chore: update custom themes for new webapp surfaces
Bring the ASCII, Windows 95 and light themes up to date with the
webapp changes since the last theme refresh:

- Flatten/bevel the new Telegram notifications banner icon badge.
- Square off the premium-server and referral-tariff dropdown help
  pills and bevel the standalone referral surfaces (win95).
- Fix the selected-language check vanishing on the highlighted row
  (black on ASCII, inverted bitmap on win95).
- Square the login-screen language trigger and render flag emoji
  monochrome in the ASCII console palette.
- Add a soft drop shadow to the banner on the light theme.

Bump assets_version for all three CSS themes and update the theme
config tests accordingly.
2026-06-02 18:44:30 +03:00
3252a8andGitHub e4f523a79d Merge pull request #23 from 3252a8/feature/migration-remnashop
remnashop migration feature, install wizard
2026-06-02 15:59:40 +03:00
3252a8 8e5124e4cf style: apply prettier formatting to UserDetailModal 2026-06-02 15:57:04 +03:00
3252a8 4d86015d25 style: apply ruff formatting 2026-06-02 15:32:19 +03:00
3252a8 c4d2d0a2fe fix: wrap long route contract line 2026-06-02 15:26:37 +03:00
3252a8 2c3f2c32bc Merge branch 'dev' into feature/migration-remnashop 2026-06-02 14:48:11 +03:00
3252a8 21f9ed803d fix(webapp): clarify expiring subscription countdown 2026-06-02 14:33:28 +03:00
3252a8 966a18045d fix(bot): resolve required channel join link 2026-06-02 14:10:49 +03:00
3252a8 4263cb7c99 fix(webapp): show expiring subscription countdown 2026-06-02 14:03:14 +03:00
3252a8 1ee8af338c fix(admin): show trial history and log activations 2026-06-02 12:57:15 +03:00
3252a8 ee840a7e6d fix(admin): keep mobile extend button full height 2026-06-02 12:43:57 +03:00
3252a8 8f009c8caf fix: show email avatars without Telegram 2026-06-02 12:33:59 +03:00
3252a8 4d7577f4ec fix: harden Remnawave panel timeouts 2026-06-02 12:29:43 +03:00
3252a8 e9e028fc00 fix: narrow Remnashop settings import 2026-06-02 11:31:03 +03:00
3252a8 9508eece5f feat: import Remnashop payment settings 2026-06-02 11:21:26 +03:00
3252a8 2ccead9b49 refactor: consolidate migration installer 2026-06-02 10:57:28 +03:00
3252a8 0473829f7a fix: prepare writable data mount in installer 2026-06-02 10:30:43 +03:00
3252a8 162e9e20b5 fix: keep installer from preparing data directories 2026-06-02 10:21:08 +03:00
3252a8 3b40369606 feat: add legacy tg-shop migration to installer 2026-06-02 10:17:50 +03:00
3252a8 d22ed238b4 feat: add shell install wizard 2026-06-02 10:08:59 +03:00
3252a8 5e47a1496b feat: add Remnashop migration import
Add the Remnashop legacy importer, compatibility tables, admin toggles, referral and promo lookup compatibility, and tests for the migration flow.
2026-06-02 00:13:54 +03:00
3252a8 56796d9f22 feat(admin): add never-subscribed broadcast target with audience counts
Add a broadcast audience for users who registered but never had any subscription or trial (no Subscription rows at all), backed by a new get_user_ids_without_any_subscription DAL helper and a 'never' target in the webapp broadcast route.

Add GET /api/admin/broadcast/audience-counts so the audience dropdown shows the recipient count next to each option, with graceful fallback when counts are unavailable.
2026-06-01 23:37:59 +03:00
3252a8 619a13128c fix(admin): enlarge mobile extend control and reset trial button to 46px
On mobile the extend input/button and the reset-trial button rendered at 36px, which felt cramped for touch. Bump them to 46px in the max-width:640px block.
2026-06-01 23:29:50 +03:00
3252a8 023ab375ec fix(admin): hide users filter toggle on desktop
The toggle carried both .admin-btn and .admin-users-filter-toggle, which tied on specificity with the base button display rule and kept the button visible on desktop. Scope the show/hide rules to .admin-btn.admin-users-filter-toggle so it only appears on mobile.
2026-06-01 23:22:22 +03:00
247 changed files with 29604 additions and 3855 deletions
+12
View File
@@ -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
View File
@@ -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).
+27 -5
View File
@@ -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
+26 -2
View File
@@ -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
+2 -1
View File
@@ -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
+1
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
stages:
- docker
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
- when: never
variables:
DOCKER_BUILDKIT: "1"
DOCKER_DRIVER: overlay2
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
GIT_DEPTH: "0"
docker-dev:
stage: docker
image: docker:27.5.1
services:
- name: docker:27.5.1-dind
alias: docker
interruptible: true
parallel:
matrix:
- TARGET: backend
IMAGE: remnawave-minishop-backend
- TARGET: worker
IMAGE: remnawave-minishop-worker
- TARGET: frontend
IMAGE: remnawave-minishop-frontend
before_script:
- apk add --no-cache git
- test -n "$DOCKERHUB_USERNAME"
- test -n "$DOCKERHUB_TOKEN"
- echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin
- docker buildx create --name gitlab-builder --driver docker-container --use
- docker buildx inspect --bootstrap
script:
- git fetch origin dev --tags
- |
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE."
exit 0
fi
- |
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
if [ -z "$provenance" ]; then
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
provenance="official"
else
provenance="custom"
fi
fi
docker buildx build \
--load \
--platform linux/amd64 \
--file deploy/docker/Dockerfile \
--target "$TARGET" \
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
--build-arg "BUILDKIT_INLINE_CACHE=1" \
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
.
- git fetch origin dev --tags
- |
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE."
exit 0
fi
- docker push "$DOCKERHUB_USERNAME/$IMAGE:dev"
+8 -5
View File
@@ -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())
+2
View File
@@ -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,
+188 -2
View File
@@ -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})
+177 -1
View File
@@ -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")
+122 -3
View File
@@ -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",
+260 -31
View File
@@ -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 {
+181 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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"
+46 -2
View File
@@ -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(
+1 -3
View File
@@ -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 = {
+22 -1
View File
@@ -11,9 +11,20 @@ from .common import _invalidate_webapp_user_caches
from .telegram_notifications import _probe_telegram_notifications_for_user_id 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:
+153 -196
View File
@@ -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:
+195 -14
View File
@@ -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,
+118 -25
View File
@@ -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")
+1
View File
@@ -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
+1 -4
View File
@@ -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)
+165 -20
View File
@@ -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
+316 -2
View File
@@ -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,
+81 -27
View File
@@ -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(
+34 -12
View File
@@ -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:
+82 -19
View File
@@ -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
View File
@@ -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
) )
+371
View File
@@ -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
),
),
}
+31
View File
@@ -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)
+8 -1
View File
@@ -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()
+104 -3
View File
@@ -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,
+108 -1
View File
@@ -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
+118 -4
View File
@@ -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 -1
View File
@@ -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,
) )
+85 -1
View File
@@ -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,
) )
+54 -3
View File
@@ -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
) )
+1
View File
@@ -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,
+24 -45
View File
@@ -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,
+208 -40
View File
@@ -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]
+104 -23
View File
@@ -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,
*, *,
+164 -20
View File
@@ -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_",
+45 -13
View File
@@ -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(
+151 -16
View File
@@ -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)
+16 -5
View File
@@ -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,
+3
View File
@@ -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)
+10 -1
View File
@@ -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(),
+1
View File
@@ -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()
+61
View File
@@ -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
+66 -1
View File
@@ -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 = (
+24 -9
View File
@@ -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(
+9
View File
@@ -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
View File
@@ -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(
+42 -2
View File
@@ -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)
+48 -9
View File
@@ -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)
+29 -7
View File
@@ -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(
+58 -1
View File
@@ -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
View File
@@ -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))
+107
View File
@@ -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
View File
@@ -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"
+4 -3
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Operational one-shot scripts shipped with the backend image."""
File diff suppressed because it is too large Load Diff
+46 -69
View File
@@ -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
+3
View File
@@ -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
} }
+16 -3
View File
@@ -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;
+3 -2
View File
@@ -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