Compare commits

..
478 Commits
Author SHA1 Message Date
3252a8 1d43606446 docs: drop manual image build instructions from README 2026-06-10 23:10:27 +03:00
3252a8 5ca1ecfce6 test: parse installer download host instead of substring check
Extract the raw_url() template from install.sh and compare the parsed
hostname to raw.githubusercontent.com. Resolves the CodeQL "incomplete
URL substring sanitization" alert on the old substring assertion.
2026-06-10 23:09:38 +03:00
3252a8 86ac925b19 ci: move dev Docker Hub publishing back to GitHub Actions
Publish dev images to both GHCR and Docker Hub from the dev workflow
and drop the GitLab CI pipeline, so all images are built by GitHub
Actions only.
2026-06-10 22:55:43 +03:00
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
3252a8andGitHub 9393962510 Merge pull request #22 from 3252a8/dev
Multicurrency (future-proof feature), panel dry-run mode (dev feature), install telemetry and admin user tools
2026-06-01 22:55:30 +03:00
3252a8 2c32e81638 fix: build dry-run log entirely from constant labels
CodeQL still traced taint through the previous helpers: the endpoint
regex fallback returned source-derived text and the payload dict keys
flowed verbatim into the log. Resolve both by sourcing every logged
token from literal lookup tables — endpoints map to constant path
labels and payload keys map through a field-name allow-list, with
unknown keys/paths collapsing to <field>/<other>. No source-derived
string (key, value or id) reaches the logger.
2026-06-01 22:53:48 +03:00
3252a8 0032c1804b fix: drop clear-text values and ids from panel dry-run logs
Rebuild logged endpoints from constant path templates and reduce every
payload leaf to a JSON type token, so user/squad UUIDs and PII (email,
telegramId) can never reach the dry-run log as clear text. Resolves
CodeQL py/clear-text-logging-sensitive-data findings.
2026-06-01 22:48:21 +03:00
3252a8andGitHub a98ea65ef4 Merge pull request #21 from 3252a8/feature/ui-improvements
Improve admin UX, trial reset behavior, and channel subscription checks
2026-06-01 22:29:24 +03:00
3252a8 5218ede0f1 fix: normalize required channel checks 2026-06-01 22:13:35 +03:00
3252a8 5b19ba2c2f fix: keep deeplink gateway open during app prompt 2026-06-01 20:34:49 +03:00
3252a8 687fc03e8c fix: reset trial eligibility from web admin 2026-06-01 19:30:16 +03:00
3252a8 74272039c5 feat: add login language selector 2026-06-01 19:06:27 +03:00
3252a8 eadb86faf2 fix: restore mobile admin language select 2026-06-01 18:33:45 +03:00
3252a8 4fa18a1262 fix: enlarge mobile renewal button 2026-06-01 18:17:02 +03:00
3252a8 7d3178bd48 feat: improve admin user filters 2026-06-01 18:04:08 +03:00
3252a8andGitHub 88d99fb578 Merge pull request #20 from 3252a8/feature/users-details
Enhance admin users and subscription workflows
2026-06-01 15:59:52 +03:00
3252a8 8366a73575 style: format admin users api 2026-06-01 15:58:28 +03:00
3252a8 dcdddeb1c5 feat: add sortable admin user columns 2026-06-01 15:53:03 +03:00
3252a8 d7840d3a86 feat: expose admin user list metrics 2026-06-01 15:52:54 +03:00
3252a8 6a40d0c9ce feat: add expired subscription broadcast target 2026-06-01 15:05:10 +03:00
3252a8andGitHub 3d579b12d4 Merge pull request #19 from 3252a8/feature/telemetry
Feature/telemetry
2026-06-01 14:24:28 +03:00
3252a8 0d932b0915 style: ruff format panel dry-run service 2026-06-01 14:22:33 +03:00
3252a8 2b763efef9 fix: redact secrets and ids from panel dry-run logs
CodeQL flagged clear-text logging of sensitive information in the panel
dry-run logger: it dumped the full request payload (which can include
proxy credentials like trojanPassword/ssPassword and PII such as email
and telegramId) and the raw endpoint (embedding user UUIDs).

Recursively redact values under sensitive keys before building the
payload preview, and mask opaque id-like segments in logged endpoints.
2026-06-01 14:20:38 +03:00
3252a8 a0ea2261f4 feat: anonymous opt-out install telemetry beacon
Add a once-a-day anonymous heartbeat (PostHog) so maintainers can see
active installs and version/OS breakdowns. Self-hosted friendly: opt out
via TELEMETRY_ENABLED in .env or the Admin -> System toggle (applied
without a restart), or by clearing the endpoint/key.

- Share version resolution in bot/utils/app_version.py so the admin
  sidebar and the beacon report the same build version
- TelemetryWorker sends an opaque install id plus coarse facts only
  (version, OS/arch, python, locale, enabled providers, user-count
  range); never tokens, domains or user data
- Register the worker in main_worker.py behind a Redis single-flight lock
- Expose TELEMETRY_* settings and an Admin -> System manifest toggle
- Document the payload and opt-out in docs/configuration/telemetry.md
- Cover bucketing, payload shape and anonymity with tests
2026-06-01 14:14:02 +03:00
3252a8 5bb1400917 chore: improve tariff admin layout 2026-06-01 11:55:41 +03:00
3252a8andGitHub 63ec3a6152 Merge pull request #18 from 3252a8/feature/multicurrency
Add ability to use different currency than rub
2026-06-01 11:26:18 +03:00
3252a8 3896c455b8 fix: decouple docs email previews from bot deps 2026-06-01 11:23:45 +03:00
3252a8 d97afbec18 fix: apply ruff formatting 2026-06-01 11:16:28 +03:00
3252a8 21079f78dc fix: satisfy ruff line length 2026-06-01 11:11:15 +03:00
3252a8 3e922d8edb docs: document panel dry-run development mode 2026-06-01 10:35:22 +03:00
3252a8 391487811b feat: add remnawave panel dry-run mode 2026-06-01 10:32:38 +03:00
3252a8 939bc37995 Merge branch 'dev' into feature/multicurrency 2026-06-01 08:18:25 +03:00
3252a8 c2344824dc feat: show referral relationships in admin user cards 2026-06-01 00:36:05 +03:00
3252a8 7cf1d577f3 fix: restore pending email code screens 2026-05-31 23:27:38 +03:00
3252a8 ecf779763c feat: audit outbound user notifications 2026-05-31 23:05:40 +03:00
3252a8 1578a9da36 fix: align traffic top-up flows and unlimited overrides 2026-05-31 23:05:29 +03:00
3252a8 80e5f0c80d feat: add multicurrency tariff payments 2026-05-31 22:17:28 +03:00
3252a8 df8f2636d2 docs: render email previews from templates 2026-05-31 15:08:22 +03:00
3252a8 45543983c2 fix: run migrations after database restore 2026-05-31 14:57:32 +03:00
3252a8 b531d4de3b docs: add email preview demo section 2026-05-31 14:41:40 +03:00
3252a8 eda5d3e633 fix: localize support email templates 2026-05-31 14:41:26 +03:00
3252a8 e4ef52df8b ci: tag dev images only with dev (drop per-commit dev-<sha> tags) 2026-05-31 07:59:18 +03:00
3252a8andGitHub 3e1f1cb787 Merge pull request #15 from 3252a8/dev
Split subscription stats by access type, CI/CD pipelines and notification/docs-demo fixesDev
2026-05-31 00:30:11 +03:00
3252a8 74da8ab98e chore: compact admin subscription stats cards 2026-05-31 00:26:42 +03:00
3252a8 19f7daff6c fix: stabilize docs demo runtime routing 2026-05-31 00:25:23 +03:00
3252a8 cf3af17243 fix: avoid Telegram notification probe messages 2026-05-31 00:17:20 +03:00
3252a8 93353db511 feat: split subscription stats by access type 2026-05-31 00:08:31 +03:00
3252a8andGitHub e7f93a5f47 Merge pull request #14 from 3252a8/dependabot/npm_and_yarn/frontend/npm_and_yarn-de2076f775
chore(deps-dev): bump svelte from 5.55.5 to 5.56.0 in /frontend in the npm_and_yarn group across 1 directory
2026-05-30 23:26:04 +03:00
3252a8andGitHub c2ab881be2 Merge pull request #13 from 3252a8/feature/ci-cd-workflows
ci: add GitHub Actions for image builds, PR checks and security scans
2026-05-30 23:22:51 +03:00
3252a8 4a84bba697 ci: use v-prefixed trivy-action tag (v0.36.0) 2026-05-30 23:20:22 +03:00
3252a8 cda3b741a1 ci: fix workflow failures on PR
- drop permissions block from reusable build workflow so callers set token
  scope (fixes PR-checks startup failure: ci.yml grants only contents:read
  while the reusable demanded packages:write)
- remove codeql.yml: repo already uses CodeQL default setup, which conflicts
  with an advanced configuration
- pin trivy-action to 0.36.0 (0.28.0 tag does not exist; <0.35.0 is the
  compromised supply-chain release flagged by dependency-review)
- make pip-audit and npm audit informational (continue-on-error); they flag
  upstream/transitive advisories, dependency-review stays the PR gate
2026-05-30 23:14:46 +03:00
dependabot[bot]andGitHub 2e82febdcc chore(deps-dev): bump svelte
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).


Updates `svelte` from 5.55.5 to 5.56.0
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 20:09:10 +00:00
3252a8 bd7710d03c ci: add GitHub Actions for image builds, PR checks and security scans
- dev-images: build/push backend, worker, frontend to ghcr.io and Docker
  Hub on every push to dev (tags: dev, dev-<sha>)
- release-images: same images on v* tag push (tags: latest, <version>)
- PR checks (into main/dev): ruff lint+format, eslint+prettier, no-push
  Docker build of all targets
- CodeQL (python, js/ts), dependency-review, pip-audit, npm audit, Trivy fs
- pin .github/workflows/*.yml to LF
2026-05-30 22:57:41 +03:00
3252a8andGitHub 1a66d67e44 Merge pull request #12 from 3252a8/dev
Telegram delivery tracking, subscription lifecycle mirroring and per-tariff referral bonuses
2026-05-30 22:28:57 +03:00
3252a8 c1e2fe2c95 chore: improve admin logs user cards 2026-05-30 22:15:33 +03:00
3252a8 ea4ee4c4a7 fix: restore admin content scrolling 2026-05-30 21:49:40 +03:00
3252a8 fbc3e193bf chore: improve custom scroll areas 2026-05-30 21:41:32 +03:00
3252a8 3541f2f78b feat: block crawlers from production webapp 2026-05-30 21:25:03 +03:00
3252a8 acc222da41 feat: prompt users to start Telegram bot for notifications 2026-05-30 21:15:17 +03:00
3252a8 8c0e778388 chore: standardize web controls 2026-05-30 12:16:02 +03:00
3252a8 c6c5352813 fix: format premium traffic limits from bytes 2026-05-30 12:00:01 +03:00
3252a8 067d6fb59f chore: clarify panel webhook subscription logs 2026-05-30 11:52:07 +03:00
3252a8 c3e55bc853 feature: sync user email notifications 2026-05-30 11:46:07 +03:00
3252a8 09be82aad5 fix: improve subscription email renewal flow 2026-05-30 00:23:53 +03:00
3252a8 49781af921 feat: surface remnawave panel webhook settings 2026-05-29 23:55:12 +03:00
3252a8 604ba873dc docs: clarify remnawave panel webhook setup 2026-05-29 23:54:35 +03:00
3252a8 923ff9b208 fix: hide inactive devices usage summary 2026-05-29 23:23:55 +03:00
3252a8 7e7a2e58a2 fix: show telegram login without email auth 2026-05-29 23:09:49 +03:00
3252a8 e27886e15d docs: update referral bonus tariff docs 2026-05-29 23:00:18 +03:00
3252a8 7fe8e676cd feat: group referral bonus display by tariff 2026-05-29 22:55:01 +03:00
3252a8 6803c7801f fix: configure referral bonuses per tariff period 2026-05-29 22:30:31 +03:00
3252a8 cab963dcdc fix: prevent subscription worker deadlocks 2026-05-29 22:07:16 +03:00
3252a8 001e54cfe2 fix: preserve subscriptions on panel lookup failures 2026-05-29 21:46:42 +03:00
3252a8 5e257c0d3a chore: enforce frontend formatting line endings 2026-05-29 18:14:35 +03:00
3252a8 19f0f27a3b feat: mirror subscription lifecycle notifications 2026-05-29 18:08:28 +03:00
3252a8 6fbb8eebec fix: serve docs demo runtime in dev 2026-05-29 18:04:00 +03:00
3252a8 32616c80ab docs: add notifications table 2026-05-29 17:50:30 +03:00
3252a8 3186be1e3e docs: update deployment examples 2026-05-29 15:33:09 +03:00
3252a8 1329eb4fe2 fix: avoid docs demo redirect loop 2026-05-28 23:44:52 +03:00
3252a8 913b2d428e fix: handle docs demo app route 2026-05-28 23:33:40 +03:00
3252a8andGitHub 5892a72575 Merge pull request #7 from 3252a8/dev
Backups, docs demo, and Mini App improvements
2026-05-28 22:57:40 +03:00
3252a8 d299a3c524 feature: Add local subscription notification worker 2026-05-28 22:48:48 +03:00
3252a8 1c1ef06e29 fix: Fix trial settings persistence 2026-05-28 22:48:48 +03:00
3252a8 bea62d75a9 fix: persist account language changes 2026-05-28 22:07:43 +03:00
3252a8 e998b9ddd1 chore: compact traffic cards 2026-05-28 21:52:38 +03:00
3252a8 2f192447ce fix: hide premium traffic when regular limit is depleted 2026-05-28 21:00:06 +03:00
3252a8 e89ed870dc fix: compact mobile subscription status card 2026-05-28 19:44:50 +03:00
3252a8 6a722bfa17 refactor: share email code confirmation screen 2026-05-28 17:11:15 +03:00
3252a8 a96aaa763f feat: use admin identity in demo auth flow 2026-05-28 17:02:22 +03:00
3252a8 bbe05d7f57 feat: support telegram auth in demo flow 2026-05-28 16:49:23 +03:00
3252a8 da28b69461 feat: add demo auth flow 2026-05-28 16:40:16 +03:00
3252a8 cd469ae2bb feat: add demo device top-up flow 2026-05-28 15:54:35 +03:00
3252a8 9af49453f8 fix: refresh devices after billing updates 2026-05-28 15:54:30 +03:00
3252a8 e543665704 fix: add favicon to docs demo shell 2026-05-28 15:32:45 +03:00
3252a8 39c0696e53 fix: show activation dialog for forced actions 2026-05-28 15:29:58 +03:00
3252a8 c6cc4ab963 fix: use standard purchase copy in demo 2026-05-28 15:25:18 +03:00
3252a8 4dfe71f392 fix: align demo state flows 2026-05-28 15:21:55 +03:00
3252a8 cf05c6580d fix: keep demo mock switch on app shell 2026-05-28 15:05:48 +03:00
3252a8 d544ebd879 feat: add fullscreen docs demo routes 2026-05-28 14:59:54 +03:00
3252a8 7e0e8ff319 feat: enrich docs demo mock data 2026-05-28 14:59:43 +03:00
3252a8 613a9860a0 refactor: support prefixed webapp routes 2026-05-28 14:59:34 +03:00
3252a8 92f0277dad docs: refresh docs demo data 2026-05-28 13:10:07 +03:00
3252a8 749693078b fix: use app favicon fallback in admin appearance 2026-05-28 13:10:01 +03:00
3252a8 b566a34725 fix: mark depleted traffic on webapp home 2026-05-28 13:09:55 +03:00
3252a8 3ab6c18a76 fix: preserve account language on telegram auth 2026-05-28 11:31:45 +03:00
3252a8 52458eb223 fix: align tariff action in status card 2026-05-28 11:31:41 +03:00
3252a8 790946d89e fix: label docs demo admin version 2026-05-28 10:09:26 +03:00
3252a8 30c5d9ae11 fix: refine home tariff actions 2026-05-28 10:06:10 +03:00
3252a8 12e60629fe fix: preserve admin view on language change 2026-05-28 09:54:27 +03:00
3252a8 6c0d2932c3 docs: make demo bar collapsible on mobile 2026-05-28 09:12:58 +03:00
3252a8 ce19c7e2d1 docs: add mobile docs navigation menu 2026-05-28 08:21:58 +03:00
3252a8 93c9dde572 chore: harden docs demo config fetch 2026-05-28 08:21:54 +03:00
3252a8 d46324cb6a docs: fix mobile homepage navigation 2026-05-28 08:07:09 +03:00
3252a8 f153ca5bf4 fix: complete english localization refresh 2026-05-28 00:55:22 +03:00
3252a8 62e950f5c4 docs: refine static demo experience 2026-05-28 00:55:11 +03:00
3252a8 2b8bcd10b8 docs: make demo open fullscreen 2026-05-28 00:19:22 +03:00
3252a8 4c2ee19957 docs: add interactive demo page 2026-05-27 23:56:52 +03:00
3252a8 8217d13cd6 feat: add docs demo runtime build 2026-05-27 23:56:48 +03:00
3252a8 a2a887b898 fix: recognize backups admin route 2026-05-27 23:56:41 +03:00
3252a8 1620de9a01 refactor: isolate mock runtime from production webapp 2026-05-27 23:56:37 +03:00
3252a8 b6c6887842 chore: improve backup admin controls 2026-05-27 23:22:20 +03:00
3252a8 ef4b493e65 chore: change backup archive name style 2026-05-27 23:01:42 +03:00
3252a8 50dd1951c4 fix: prevent support message row stretching 2026-05-27 22:37:50 +03:00
3252a8 b3894e53e4 fix: tighten support chat message spacing 2026-05-27 22:21:44 +03:00
3252a8 ded044b4c0 fix: make support replies finish promptly 2026-05-27 22:21:24 +03:00
3252a8 bce78c4f28 chore: backup warning details 2026-05-27 19:08:49 +03:00
3252a8 75586d8883 fix: harden hwid provider payment edge cases 2026-05-27 18:31:14 +03:00
3252a8 f2fc335221 fix: anchor hwid pricing to paid period 2026-05-27 18:13:27 +03:00
3252a8 1c9e55d797 fix: wire subscription service for yookassa hwid payments 2026-05-27 18:13:22 +03:00
3252a8 25056602d8 fix: serialize webapp datetime payloads 2026-05-27 14:55:05 +03:00
3252a8 bd2e67059f chore: optimize docker layer caching 2026-05-27 14:44:58 +03:00
3252a8 fe34edfe73 chore: verify formatting checks 2026-05-27 14:29:45 +03:00
3252a8 4706be53ab feat: manual backup button 2026-05-27 14:25:37 +03:00
3252a8 4bd547f06a chore: tune backup function and related docs 2026-05-27 14:11:22 +03:00
3252a8 0250264fa0 fix: serialize HWID top-up validity dates 2026-05-27 13:59:03 +03:00
3252a8 3aede8fe95 feat: add backups feature 2026-05-27 13:53:30 +03:00
3252a8 e90988ea5c feat: persist support ticket drafts 2026-05-27 08:10:08 +03:00
3252a8 69d3400310 fix: stack admin badges on mobile 2026-05-27 07:55:05 +03:00
3252a8 6a44da1f31 fix: refresh trial settings in admin tariffs 2026-05-27 07:52:20 +03:00
3252a8 77c8785ec0 chore: set default webapp title to minishop 2026-05-27 07:22:34 +03:00
3252a8 f2c0a9f6d5 fix: preserve home logo scale after admin navigation 2026-05-27 07:13:53 +03:00
3252a8 f77a6ea46d feat: add default webapp brand assets 2026-05-27 07:07:31 +03:00
3252a8 a92ad32b23 docs: update docs 2026-05-27 00:11:24 +03:00
3252a8 a6af8f8415 docs: tune docs visual 2026-05-26 23:55:07 +03:00
3252a8andGitHub da9db7b7f8 Merge pull request #5 from 3252a8/dev
Update and structurize docs
2026-05-26 23:42:59 +03:00
3252a8 11048a6ed8 docs: refactor docs structure 2026-05-26 23:31:04 +03:00
3252a8 c3381bdd31 chore: add image publish and mirror sync scripts 2026-05-26 22:30:10 +03:00
3252a8 53f1cec401 docs: point repository links to GitHub 2026-05-26 22:21:00 +03:00
3252a8 bf46a15446 docs: update GitLab repository links 2026-05-26 21:57:49 +03:00
3252a8 0df52d0235 docs: refactor docs structure 2026-05-26 21:45:44 +03:00
3252a8 0c167c8f09 docs: refactor docs structure 2026-05-26 17:26:46 +03:00
3252a8 804ccdabec docs: use nova starlight theme 2026-05-26 16:18:15 +03:00
3252a8 5833b18052 docs: docs-site initial 2026-05-26 16:04:04 +03:00
3252a8 f30e729f1f Merge branch 'dev' into 'main'
Install guides, payment fixes, and runtime translations

See merge request 3252a8/remnawave-minshop!1
2026-05-26 10:39:29 +00:00
3252a8 1e63431ae3 chore: merge main into dev 2026-05-26 12:51:00 +03:00
3252a8 072e7273c4 docs: standardize MIT license file 2026-05-26 12:48:58 +03:00
3252a8 df079138ee docs: standardize MIT license file 2026-05-26 12:44:27 +03:00
3252a8 87f32114d7 docs: update license file attribution 2026-05-26 12:29:58 +03:00
3252a8 b330e604f6 chore: use Docker Hub images in compose files 2026-05-26 11:15:10 +03:00
3252a8 827d69231c feat: add Docker Hub image script 2026-05-26 08:58:09 +03:00
3252a8 0623850c3f fix: open Telegram Stars invoices inside Mini App 2026-05-25 22:13:11 +03:00
3252a8 4c77d129e7 fix: update support page content without page refresh 2026-05-25 22:03:30 +03:00
3252a8 c68cb97964 fix: bind HWID top-ups to subscription periods 2026-05-25 19:33:50 +03:00
3252a8 59fa301344 feat: add runtime locale overrides 2026-05-25 17:47:45 +03:00
3252a8 23784d00fe chore: update custom themes styles 2026-05-25 13:52:25 +03:00
3252a8 17cff74b7c chore: tune sub activate modal view 2026-05-25 13:11:48 +03:00
3252a8 2a3a3c21ba chore: adjust webapp payment layout 2026-05-25 12:42:17 +03:00
3252a8 ba7811621e fix: preserve premium topup squad access 2026-05-25 12:18:58 +03:00
3252a8 340afe1b80 fix: stop premium squad sync churn 2026-05-25 11:54:03 +03:00
3252a8 11b7823188 fix: expire Wata payment links sooner 2026-05-25 11:34:10 +03:00
3252a8 de1763cfe1 feat: enable payment provider for admin only 2026-05-25 11:19:54 +03:00
3252a8 d30b069876 fix: reuse pending Wata payment links on retry 2026-05-25 10:49:57 +03:00
3252a8 0d68da9624 fix: handle Wata payment links 2026-05-25 09:17:39 +03:00
3252a8 4d857b386b fix: remove inline startup panel sync 2026-05-25 00:40:24 +03:00
3252a8 be7a3bc153 fix: queue bot panel sync requests 2026-05-25 00:36:15 +03:00
3252a8 5c71fc0de2 fix: hide links from payment success message 2026-05-25 00:23:48 +03:00
3252a8 82cc33587c fix: stop panel description churn 2026-05-25 00:06:29 +03:00
3252a8 121f3c6ddf feat: add compact panel sync update diagnostics 2026-05-24 23:44:29 +03:00
3252a8 3410eeaec1 fix(admin): delete Remnawave user with bot account 2026-05-24 23:35:19 +03:00
3252a8 85ecd644b8 fix: refresh webapp profile after activation 2026-05-24 23:25:21 +03:00
3252a8 7cffb4667f fix: expose iOS home screen icons for web app 2026-05-24 23:13:15 +03:00
3252a8 0449ded505 feat: render webapp preview metadata 2026-05-24 22:54:01 +03:00
3252a8 e15cbffdb1 feat: expose webapp title in admin settings 2026-05-24 22:53:50 +03:00
3252a8 9674c674a2 feat: resume activation handoff after payments 2026-05-24 22:45:54 +03:00
3252a8 bafc5f4709 feat: show linked emails in telegram logs 2026-05-24 22:32:35 +03:00
3252a8 a60de46173 feat: guide users after subscription activation 2026-05-24 22:24:02 +03:00
3252a8 1165dc3fa6 chore: verify formatting checks 2026-05-24 22:04:12 +03:00
3252a8 f5ab18a67b feat: redesign webapp trial offer 2026-05-24 22:02:22 +03:00
3252a8 65db4aaff6 fix: clean legacy emails from panel descriptions 2026-05-24 21:45:38 +03:00
3252a8 d9a23235e5 fix: stop syncing email in panel descriptions 2026-05-24 21:36:00 +03:00
3252a8 00b54e1f15 feat: improve admin trial settings controls 2026-05-24 19:19:39 +03:00
3252a8 3c4ff66150 feat: streamline admin tariff settings 2026-05-24 19:10:36 +03:00
3252a8 2b3078fcce chore: fix formatting checks 2026-05-24 18:51:37 +03:00
3252a8 6f4074e8d6 feat: surface trial activation in mini app 2026-05-24 18:48:01 +03:00
3252a8 235ee25d9f fix: refresh YooKassa webapp payments 2026-05-24 18:47:37 +03:00
3252a8 d1b9990bac feat: manage trial settings on tariffs page 2026-05-24 18:46:23 +03:00
3252a8 60d1ba4efc fix: handle Wata prepayment webhooks 2026-05-24 14:04:21 +03:00
3252a8 29b6e6eb24 fix: keep payment user cards in payments 2026-05-24 09:32:47 +03:00
3252a8 aa65972483 feat: add admin payment detail view 2026-05-24 09:30:58 +03:00
3252a8 ad275a7c83 feat: polish admin settings layout 2026-05-24 09:11:43 +03:00
3252a8 02b57ead00 feat: separate legacy tariff settings 2026-05-24 09:11:34 +03:00
3252a8 7f484fa7b3 feat: warn about legacy tariff settings 2026-05-24 00:05:16 +03:00
3252a8 3ea906a74d feat: group Platega admin settings 2026-05-24 00:01:58 +03:00
3252a8 939c40ccbf feat: collapse admin settings by default 2026-05-24 00:01:18 +03:00
3252a8 f0eb291f56 feat: tune visual of install instructions page 2026-05-23 23:55:06 +03:00
3252a8 46391b10e2 feat: tune deeplink fallback page 2026-05-23 23:26:10 +03:00
3252a8 5918a6cc71 fix: open install guide deeplinks via external app gateway 2026-05-23 23:07:42 +03:00
3252a8 0eeabc7b3a fix: process yookassa hwid device topups 2026-05-23 22:28:05 +03:00
3252a8 31eb5c06ad fix: repair linked panel email during sync 2026-05-23 22:27:51 +03:00
3252a8 60d8c297f9 docs: update deploy examples 2026-05-23 22:01:40 +03:00
3252a8 dab70d5a97 docs: update install guides documentation 2026-05-23 16:22:57 +03:00
3252a8 520a6289a8 Merge branch 'feature/install-page' into dev 2026-05-23 16:16:46 +03:00
3252a8 7adca53116 feat: enable bot install guides by default 2026-05-23 16:05:41 +03:00
3252a8 fbc3e6c084 chore: fix frontend formatting checks 2026-05-23 16:01:07 +03:00
3252a8 19ba5c8f11 feat: support install guide app deeplinks 2026-05-23 09:52:26 +03:00
3252a8 ce0d4dccf7 feat: open bot install guides in mini app 2026-05-23 09:48:30 +03:00
3252a8 7c874cd4aa feat: support install guides in custom themes 2026-05-22 23:39:17 +03:00
3252a8 17224b4f74 feat: harden public install guide loading 2026-05-22 23:29:58 +03:00
3252a8 40414264be feat: polish install guide loading state 2026-05-22 23:24:59 +03:00
3252a8 d9a4a007e2 feat: add install guide share tokens and animations 2026-05-22 23:20:25 +03:00
3252a8 d3925cad22 Merge branch 'dev' into feature/install-page
# Conflicts:
#	backend/bot/app/web/admin_api_impl/settings.py
#	backend/bot/app/web/webapp/cache_helpers.py
#	frontend/src/admin/sections/SettingsSection.svelte
#	tests/test_admin_settings_manifest_i18n.py
2026-05-22 22:57:24 +03:00
3252a8 443e2e62db fix: auto-merge duplicate panel identities 2026-05-22 22:39:45 +03:00
3252a8andGitHub 4b7c58a6c9 Merge pull request #1 from 3252a8/dev
Stabilize account linking, auth, and admin settings
2026-05-22 22:24:07 +03:00
3252a8 19aa2ec9c9 fix: log account merge notifications 2026-05-22 22:02:28 +03:00
3252a8 c4c5b8e3a0 fix: merge active sub email account with expired sub telegram account 2026-05-22 18:23:38 +03:00
3252a8 33a7dbc0e6 fix: app version display pattern in admin panel sidebar 2026-05-22 18:23:11 +03:00
3252a8 72921b9a8f fix: telegram oauth in logged in email account 2026-05-22 17:48:17 +03:00
3252a8 835436fa1a fix: ensure web app pay button is spawning when enable payment provider 2026-05-22 16:29:59 +03:00
3252a8 648f4ba4bc feat: show payment provider webhook urls 2026-05-22 15:57:49 +03:00
3252a8 f5023dc46b fix: repair missing panel user references 2026-05-22 15:39:51 +03:00
3252a8 4c0a798050 fix: load admin assets from stable paths 2026-05-22 15:39:45 +03:00
3252a8 cfe7c4ec5f fix: style mobile admin button 2026-05-22 15:11:28 +03:00
3252a8 c82779e15b fix: restore telegram mini app auth 2026-05-22 14:47:01 +03:00
3252a8 9e56715e77 fix: cover email telegram account linking 2026-05-22 14:41:56 +03:00
3252a8 2254b9ad19 fix: avoid repeated panel identity syncs 2026-05-22 14:11:38 +03:00
3252a8 81707d9c7c fix: clean merged panel identities 2026-05-22 14:11:31 +03:00
3252a8 9e61a3d8a8 feat: install instruction inside web app 2026-05-22 13:58:34 +03:00
3252a8 8a38524774 docs: add url to remnawave 2026-05-21 23:09:22 +03:00
3252a8 87d3f1b410 docs: add nginx example 2026-05-21 22:40:53 +03:00
3252a8 1094a65852 Merge branch 'dev' 2026-05-21 14:24:44 +03:00
3252a8 0ab4b11f97 fix: windows 95 theme support tickets list 2026-05-21 14:05:54 +03:00
3252a8 6d4fb5888f refactor: apply custom theme without cache reload 2026-05-21 13:56:05 +03:00
3252a8 e7301e2c48 refactor: optimize support polling 2026-05-21 13:41:01 +03:00
3252a8 f69f6546f0 docs: add details to env.example 2026-05-21 11:39:07 +03:00
3252a8 7fe53993aa docs: update docs and minimize env.example 2026-05-21 11:26:59 +03:00
3252a8 028f0680c6 feat: allow to edit remnawave panel settings in admin panel, ensure using i18n 2026-05-21 11:26:03 +03:00
3252a8 b3f67a5398 fix: admin panel loading 2026-05-21 10:43:55 +03:00
3252a8 db3611487e perf: optimize webapp asset delivery, split admin webapp bundle 2026-05-21 10:17:23 +03:00
3252a8 f2c722bdfc perf: reduce webapp and admin cache stampedes 2026-05-21 09:44:50 +03:00
3252a8 ccb125ab9d fix: retry Telegram startup setup until reachable 2026-05-21 08:28:27 +03:00
3252a8 a300a4a9c0 feat: add new sections to custom themes 2026-05-21 08:22:40 +03:00
3252a8 044cb4de7e refactor(redis): strengthen shared cache invalidation 2026-05-21 06:26:48 +03:00
3252a8 3405d12696 refactor: improve admin stats cache usage and etc 2026-05-20 23:35:45 +03:00
3252a8 11cd35373e fix: remove unnecessary caption in premium squad servers list in web app 2026-05-20 23:25:18 +03:00
3252a8 5001185bf8 refactor: improve panel sync performance 2026-05-20 23:18:03 +03:00
3252a8 a7f298743d refactor: improve startup and sync performance 2026-05-20 23:04:29 +03:00
3252a8 8192eaf55b refactor: improve premium squads feature performance, add benchmarks 2026-05-20 22:31:37 +03:00
3252a8 5a0e0033ec chore: rename tg support button contact 2026-05-20 22:05:33 +03:00
3252a8 7521f89ffd feat: show optional serivce description before payment 2026-05-20 21:55:22 +03:00
3252a8 d8a1da1f13 feat: add support tickets and imrpove web app loading 2026-05-20 16:38:24 +03:00
3252a8 3b846f0d44 fix: tune visual of setup password button 2026-05-19 15:38:04 +03:00
3252a8 f5006af6c0 feat: email and password login 2026-05-19 15:21:02 +03:00
3252a8 3152631911 fix: preserve Telegram usernames with underscores 2026-05-19 00:07:32 +03:00
3252a8 1d9f069f45 feat: add admin telegram profile links and avatar preview 2026-05-18 23:46:46 +03:00
3252a8 707f569f62 fix: heleket signature verify 2026-05-18 22:56:56 +03:00
3252a8 e3643ee9a0 fix: handle Caddy-proxied payment webhooks and method selection 2026-05-18 22:45:42 +03:00
3252a8 49cf5ebad8 fix: auto-append new providers to PAYMENT_METHODS_ORDER 2026-05-18 22:03:40 +03:00
3252a8 b79f5f4d7d fix: make provider services reactive to runtime config changes 2026-05-18 21:53:53 +03:00
3252a8 d870915dc0 fix: apply persisted provider overrides on startup 2026-05-18 21:40:45 +03:00
3252a8 fcd494f815 fix: restore settings binding in platega webapp payment creator 2026-05-18 21:23:57 +03:00
3252a8 a7728f80d0 fix: resolve yookassa webhook path via provider spec in startup log 2026-05-18 21:13:30 +03:00
3252a8 9eb6387973 feat: expose provider button defaults in admin settings manifest 2026-05-18 20:59:26 +03:00
3252a8 7be5510208 refactor: move yookassa env-config into module 2026-05-18 20:46:54 +03:00
3252a8 e0269bbf86 refactor: move platega env-config into module 2026-05-18 20:37:15 +03:00
3252a8 e76558d68b refactor: move stars presentation into module 2026-05-18 20:30:30 +03:00
3252a8 cceba2e98e refactor: move cryptopay env-config into module 2026-05-18 16:32:19 +03:00
3252a8 1e93d25a40 refactor: move freekassa env-config into module 2026-05-18 16:22:49 +03:00
3252a8 74ef9d31b4 refactor: move wata env-config into module 2026-05-18 16:18:04 +03:00
3252a8 15b3f9c084 refactor: move severpay env-config into module 2026-05-18 16:13:58 +03:00
3252a8 87efa6c77b refactor: provider env-config lives in modules (heleket poc) 2026-05-18 16:03:01 +03:00
3252a8 07b35036e7 refactor: keep provider presentation self-contained in spec 2026-05-18 15:35:55 +03:00
3252a8 c9c12b9ed6 feat: add heleket payment provider 2026-05-18 15:26:07 +03:00
3252a8 960754c54e fix: fix menus and modal views after payment providers refactor 2026-05-18 11:01:32 +03:00
3252a8 51c9c8b4f0 refactor: payments providers 2026-05-18 10:29:00 +03:00
3252a8 fff7e90e14 feat: add wata payment provider 2026-05-17 23:09:04 +03:00
3252a8 11429e887e Merge branch 'dev' 2026-05-17 22:23:14 +03:00
3252a8 820548cb2d fix: include git tag context in startup banners 2026-05-17 22:20:45 +03:00
3252a8 e2038085ca fix: show commit SHA in startup banners 2026-05-17 22:11:17 +03:00
3252a8 3676bf1ae7 Merge branch 'feature/arch-refactoring' into dev 2026-05-17 21:41:55 +03:00
3252a8 57f766dda2 docs: add themes screenshot 2026-05-17 21:41:36 +03:00
3252a8 da8f2d08df docs: caddy update 2026-05-17 21:36:32 +03:00
3252a8 9579f019d4 chore: lock migration doc and script in sync with current compose layout 2026-05-17 18:08:08 +03:00
3252a8 7c27337e0a chore: extend KNOWN_CONTAINERS to cover split-arch containers 2026-05-17 18:08:08 +03:00
3252a8 2dffce4244 docs: update migration guide for v2.7.0 to v3.4+ split-architecture upgrade 2026-05-17 18:08:08 +03:00
3252a8 27a9f0aff8 chore: cover env, baked file and live git paths of _resolve_app_version 2026-05-17 17:38:35 +03:00
3252a8 67920040ea fix: bake app version into image via throwaway version-builder stage 2026-05-17 17:38:30 +03:00
3252a8 fd1b910236 chore: cover can_topup_devices flag derivation 2026-05-17 17:26:49 +03:00
3252a8 09ba53d185 fix: stop rendering duplicate period grid and pay button in legacy mode 2026-05-17 17:26:42 +03:00
3252a8 91e79388d1 fix: hide device topup button when no HWID packages or unlimited devices 2026-05-17 17:26:37 +03:00
3252a8 cb5d59571e chore: cover referral bonus skip-paths, inviter and referee award flows 2026-05-17 10:23:01 +03:00
3252a8 365c6c7858 chore: cover HWID device topup gating, audit and panel failure paths 2026-05-17 10:22:57 +03:00
3252a8 6886a90ee4 chore: cover topup, premium-topup and tariff-switch panel failure paths 2026-05-17 10:22:53 +03:00
3252a8 7d1c9ee373 fix: surface panel update failures in topup, premium-topup and tariff switch 2026-05-17 10:22:48 +03:00
3252a8 dc05595b9b chore: attach orphaned type:ignore comment to its statement 2026-05-17 10:09:41 +03:00
3252a8 d2581b1c52 chore: pin yookassa and subscription wiring in build_core_services 2026-05-17 10:03:09 +03:00
3252a8 eb37ed4c74 refactor: stop swallowing critical service wiring failures 2026-05-17 10:03:05 +03:00
3252a8 6800734136 chore: cover auto-renew wiring contract end-to-end 2026-05-17 09:58:45 +03:00
3252a8 d4b7da3a54 fix: read yookassa_service directly in auto-renew instead of broken local import 2026-05-17 09:58:39 +03:00
3252a8 8382fa9232 chore: cover provider label regression in payment success email path 2026-05-17 09:46:52 +03:00
3252a8 071b9f2b25 fix: add missing _PROVIDER_LABELS to PaymentContextMixin 2026-05-17 09:46:48 +03:00
3252a8 1e8ffa15d0 chore: add tests for redis infra, webhook queue and split-arch entrypoints 2026-05-17 00:31:12 +03:00
3252a8 fc7f97c136 refactor: drop unused legacy run_bot flags and dead backend/main.py 2026-05-17 00:31:08 +03:00
3252a8 99732211b7 docs: correct backend path prefix for admin manifest references 2026-05-17 00:30:59 +03:00
3252a8 aaa8e957f6 fix: serve hashed minified webapp bundle from frontend image 2026-05-17 00:30:54 +03:00
3252a8 30fb774d93 refactor: project architecture refactor, container splitting 2026-05-17 00:01:28 +03:00
3252a8 e0b5218037 chore: print remnawave-minishop ascii banner on startup 2026-05-16 00:16:21 +03:00
3252a8 387bdb6abc fix: stop logging full panel response bodies by default to avoid secret leakage 2026-05-16 00:09:37 +03:00
3252a8 3298dc3e77 test: align CSP expectation with current img-src that allows blob 2026-05-16 00:04:42 +03:00
3252a8 55dce99d34 refactor: ack panel webhooks immediately and run event handling in bounded background task 2026-05-16 00:02:22 +03:00
3252a8 87f9e23bae refactor: cache panel squad and host lookups in-memory with TTL 2026-05-16 00:01:40 +03:00
3252a8 1e97dd9fe5 refactor: add per-chat throttle in message queue to avoid 429 on rapid same-chat sends 2026-05-15 23:59:49 +03:00
3252a8 a05c54fc66 refactor: tighten panel API timeouts and retry safe requests once on transient errors 2026-05-15 23:59:05 +03:00
3252a8 aeb51b9ccc refactor: add indexes on payments(status, created_at) and message_logs(timestamp) 2026-05-15 23:57:39 +03:00
3252a8 b72a23f1e1 refactor: parallelize tariff worker panel calls and unblock startup sync 2026-05-15 23:18:24 +03:00
3252a8 4d96a3e646 Merge branch 'feature/custom-themes' into dev 2026-05-15 23:10:27 +03:00
3252a8 fc8e573243 feat: tune light theme, reduce accent color usage overall 2026-05-15 23:01:23 +03:00
3252a8 f80e336e1f feat: tune visual of windows 95 theme 2026-05-15 21:46:10 +03:00
3252a8 2a1ff8e45e feat: separate webapp favicon configuration in appearance admin panel section 2026-05-15 19:27:33 +03:00
3252a8 e9ad259957 docs: update web app theme specific docs 2026-05-15 15:46:08 +03:00
3252a8 86ddceb646 feat: custom themes polishing 2026-05-15 14:38:39 +03:00
3252a8 a1725a6872 feat: custom themes logo size tuning 2026-05-15 13:54:47 +03:00
3252a8 af7cee5014 feat: custom themes polishing 2026-05-15 11:17:27 +03:00
3252a8 b6ee5e8790 feat: custom themes initial 2026-05-14 16:33:50 +03:00
698 changed files with 276802 additions and 23871 deletions
+49 -3
View File
@@ -12,9 +12,17 @@ scratch/
.claude/
*.local.*
node_modules/
docker-compose-dev.yml
frontend/node_modules/
docs-site/node_modules/
docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
frontend-nginx-dist/
deploy/compose/docker-compose-dev.yml
data/*
!data/tariffs.example.json
!data/locales-overrides.example.json
# CI
@@ -24,8 +32,7 @@ data/*
# Docker
docker-compose.yml
Dockerfile
.docker
deploy/compose/*.yml
.dockerignore
tmp/
@@ -33,6 +40,45 @@ tmp/
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css
bot/app/web/templates/subscription_webapp.min.*.js.br
bot/app/web/templates/subscription_webapp.min.*.js.gz
bot/app/web/templates/subscription_webapp.*.css.br
bot/app/web/templates/subscription_webapp.*.css.gz
bot/app/web/templates/subscription_webapp_admin.css
bot/app/web/templates/subscription_webapp_admin.js
bot/app/web/templates/subscription_webapp_admin.min.*.js
bot/app/web/templates/subscription_webapp_admin.*.css
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
bot/app/web/templates/subscription_webapp_docs_demo.css
bot/app/web/templates/subscription_webapp_docs_demo.js
bot/app/web/templates/subscription_webapp_docs_demo.*.css
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp.*.css.br
backend/bot/app/web/templates/subscription_webapp.*.css.gz
backend/bot/app/web/templates/subscription_webapp_admin.css
backend/bot/app/web/templates/subscription_webapp_admin.js
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
backend/bot/app/web/templates/subscription_webapp_admin.*.css
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
# Byte-compiled / optimized / DLL files
**/__pycache__/
+70 -204
View File
@@ -1,223 +1,89 @@
# Telegram Bot Token and Admin IDs
BOT_TOKEN=your_bot_token_here # Telegram bot token
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
# Minimal bootstrap env.
# Most product settings are configured later in Web App admin:
# Admin -> System -> Settings, Admin -> System -> Tariffs, Admin -> Appearance.
# Full reference: docs/env-vars.md
# PostgreSQL Database Connection Settings
POSTGRES_USER= # Required: database user name
POSTGRES_PASSWORD= # Required: database password
POSTGRES_HOST=remnawave-minishop-db # Database container name
POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name
# Telegram bot token from @BotFather.
# Example: 1234567890:AA...
BOT_TOKEN=your_bot_token_here
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# Telegram numeric user IDs allowed to open the admin panel.
# Use commas for several admins, for example: 123456789,987654321
ADMIN_IDS=123456789
# External Links
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
# Required channel subscription
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
# Webhook Base URL (used for Telegram and payment providers)
# Public HTTPS base URL of the backend webhook server.
# Telegram, payment providers and Remnawave call webhook endpoints under this domain.
# This is usually the backend/API domain, not the Mini App frontend domain.
# Example: https://bot.yourdomain.tld
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
# Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
TELEGRAM_OAUTH_CLIENT_ID= # Telegram Web Login Client ID from BotFather; defaults to bot ID from BOT_TOKEN
TELEGRAM_OAUTH_CLIENT_SECRET= # Optional Telegram Web Login Client Secret; reserved for full OIDC code flow
TELEGRAM_OAUTH_REQUEST_ACCESS=write # Optional comma-separated permissions: write,phone; empty = OpenID profile only
# PostgreSQL user created by Docker Compose and used by the backend.
POSTGRES_USER=remnawave_minishop
# Email login and account linking via SMTP (Brevo SMTP relay defaults)
SMTP_HOST=smtp-relay.brevo.com # SMTP server
SMTP_PORT=587 # Brevo recommends 587 with STARTTLS
SMTP_FALLBACK_PORTS=2525,465 # Tried after SMTP_PORT; 465 uses SSL automatically
SMTP_TIMEOUT_SECONDS=30 # Per SMTP connection/send attempt timeout
SMTP_USERNAME= # Brevo SMTP login
SMTP_PASSWORD= # Brevo SMTP key/password
SMTP_FROM_EMAIL= # Verified sender email
SMTP_FROM_NAME= # Optional sender name
SMTP_STARTTLS=True # Use STARTTLS on SMTP_PORT
SMTP_USE_SSL=False # Use SSL wrapper, usually only for port 465
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
# PostgreSQL password. Change it before production deploy.
POSTGRES_PASSWORD=change_me
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
PLATEGA_ENABLED=False # Turn on PLATEGA
SEVERPAY_ENABLED=False # Turn on SeverPay
# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,stars
# PostgreSQL database name created by Docker Compose.
POSTGRES_DB=remnawave_minishop
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
YOOKASSA_VAT_CODE=1 # VAT code
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
# Enables the Web App and the Web App admin panel.
# Keep True for the first setup. If set to False, the admin UI is unavailable
# until you change it back to True in .env and restart the app.
WEBAPP_ENABLED=True
# Nalogo (self-employed receipts)
NALOGO_INN=your_inn # INN for nalog.ru
NALOGO_PASSWORD=your_nalogo_password # Password for nalog.ru
NALOGO_RECEIPT_NAME_SUBSCRIPTION=subscription {months} months # Receipt name for time-based subscriptions ({months} = duration)
NALOGO_RECEIPT_NAME_TRAFFIC=traffic package {gb} GB # Receipt name for traffic packages ({gb} = traffic amount)
# Stable secret for Web App sessions.
# Generate with: openssl rand -hex 32
# If empty, sessions are invalidated on every restart.
WEBAPP_SESSION_SECRET=
# FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
# Stable Telegram webhook secret_token.
# Generate with: openssl rand -hex 32
# If empty, a new token can be generated on process start.
WEBHOOK_SECRET_TOKEN=
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
# Public HTTPS URL of the Mini App frontend, with trailing slash.
# This URL is opened by Telegram buttons and BotFather Mini App settings.
# Do not put /api or webhook paths here.
# Example: https://app.yourdomain.tld/
SUBSCRIPTION_MINI_APP_URL=https://app.yourdomain.tld/
# Platega Payment Gateway Configuration
PLATEGA_BASE_URL=https://app.platega.io # Base API URL
PLATEGA_MERCHANT_ID= # Your MerchantId from Platega
PLATEGA_SECRET= # API secret from Platega
PLATEGA_PAYMENT_METHOD=2 # Legacy method ID; fallback for the SBP button when PLATEGA_SBP_METHOD stays default
PLATEGA_SBP_ENABLED=False # Show a separate "Pay via SBP" Platega button
PLATEGA_CRYPTO_ENABLED=False # Show a separate "Pay with crypto" Platega button
PLATEGA_SBP_METHOD=2 # Platega method ID for SBP QR (default 2)
PLATEGA_CRYPTO_METHOD=13 # Platega method ID for crypto (default 13)
PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link)
PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL)
# Remnawave panel API URL. Usually the panel domain plus /api.
# Example: https://panel.yourdomain.tld/api
PANEL_API_URL=https://panel.yourdomain.tld/api
# SeverPay Payment Gateway Configuration
SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL
SEVERPAY_MID= # Your MID from SeverPay
SEVERPAY_TOKEN= # API token/secret for signing requests
SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link)
SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default)
# Remnawave API key with permissions to manage users, subscriptions and squads.
# Keep this secret. It can be overridden later in the admin panel if needed.
PANEL_API_KEY=
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
# Shared secret for validating incoming Remnawave webhooks.
# Create or set this secret in Remnawave Panel, then paste the same value here
# or into Admin -> System -> Settings -> Remnawave Panel.
# In Remnawave Panel, set WEBHOOK_URL to WEBHOOK_BASE_URL + /webhook/panel,
# for example: https://app.example.com/webhook/panel
PANEL_WEBHOOK_SECRET=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
# Traffic Packages (enables traffic sale mode when set)
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days
# Bonus for the inviting user
REFERRAL_BONUS_DAYS_1_MONTH=3
REFERRAL_BONUS_DAYS_3_MONTHS=7
REFERRAL_BONUS_DAYS_6_MONTHS=15
REFERRAL_BONUS_DAYS_12_MONTHS=30
# Invited User Bonus
REFEREE_BONUS_DAYS_1_MONTH=1
REFEREE_BONUS_DAYS_3_MONTHS=3
REFEREE_BONUS_DAYS_6_MONTHS=7
REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# User traffic limits (applied for all users)
# 0 means unlimited
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Default External Squad for Users (Optional, single UUID)
USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users
# Trial Settings
TRIAL_ENABLED=True # Enable the trial period
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
# Connection link handling (happ crypt4)
CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs
CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url=
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
# Host port that publishes the backend webhook server from Docker Compose.
# Your reverse proxy should route WEBHOOK_BASE_URL traffic to this port.
WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10 # Number of events in the log
LOG_LEVEL=INFO # Global log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
# Host port that publishes the frontend nginx from Docker Compose.
# Your reverse proxy should route SUBSCRIPTION_MINI_APP_URL traffic to this port.
FRONTEND_PORT=8082
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
# Keep loopback and private network ranges so payment provider IP allowlists
# see the original webhook sender behind Docker/LAN/Kubernetes proxies.
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
# Once a day the worker sends a single anonymous "heartbeat" so the project
# maintainer can see how many installs are active and which versions/OSes are
# used. It contains an opaque random install id and coarse facts only:
# version, official/custom image provenance, OS/arch, Python version, language,
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
# token, domain, user data or any personal information is ever sent.
# Full details: docs/configuration/telemetry.md
#
# Set to False to disable, or toggle it any time in Admin -> System ->
# "Anonymous install analytics" (applies without a restart).
TELEMETRY_ENABLED=True
+9
View File
@@ -0,0 +1,9 @@
.gitattributes text eol=lf
*.sh text eol=lf
.github/workflows/*.yml text eol=lf
deploy/docker/frontend/*.sh text eol=lf
frontend/src/*.js text eol=lf
frontend/src/**/*.js text eol=lf
frontend/src/**/*.svelte text eol=lf
frontend/scripts/*.mjs text eol=lf
frontend/scripts/**/*.mjs text eol=lf
+138
View File
@@ -0,0 +1,138 @@
name: Docker build & push (reusable)
# Reusable workflow that builds the three image targets defined in
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
# them to the selected registries under the repository owner's namespace.
#
# Called by:
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
# - docker-release.yml (tag_mode: release, push: true) on release tags
# - ci.yml (tag_mode: dev, push: false) on pull requests
on:
workflow_call:
inputs:
push:
description: "Push the built images to the registries"
type: boolean
default: true
tag_mode:
description: "Tagging strategy: 'dev' or 'release'"
type: string
required: true
publish_dockerhub:
description: "Include Docker Hub tags and login when pushing"
type: boolean
default: true
# No permissions block here on purpose: a reusable workflow cannot request more
# than its caller grants, so the token scope is set by each caller
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
# only needs contents: read for a no-push build).
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: backend
image: remnawave-minishop-backend
- target: worker
image: remnawave-minishop-worker
- target: frontend
image: remnawave-minishop-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: the Dockerfile's version-builder stage runs
# `git describe --tags` against the copied .git tree.
fetch-depth: 0
- name: Resolve release version
id: version
if: inputs.tag_mode == 'release'
run: |
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
# manual workflow_dispatch on a branch, fall back to the latest tag.
if [ "${{ github.ref_type }}" = "tag" ]; then
raw="${{ github.ref_name }}"
else
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
fi
version="${raw#v}"
if [ -z "$version" ]; then
echo "::error::No git tag found to derive the release version from"
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Release version: ${version}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: inputs.push
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: inputs.push && inputs.publish_dockerhub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Resolve image namespaces
id: image_namespaces
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
github_owner="${{ github.repository_owner }}"
echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT"
dockerhub_owner="${github_owner,,}"
if [ "${{ inputs.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
id: meta
uses: docker/metadata-action@v5
with:
images: |
name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }}
name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true
tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
# internal version string for non-main builds. Force "main" on release
# (the ref is the tag, not a branch) so release images stay un-suffixed.
build-args: |
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }}
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
provenance: false
+88
View File
@@ -0,0 +1,88 @@
name: PR checks
# Runs on pull requests into main (typically from dev) and into dev (typically
# from feature/* branches): lint + format checks, a demo settings-manifest
# drift guard, and a no-push image build to prove the Docker images still build.
on:
pull_request:
branches: [main, dev]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint & format
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"
- name: Install ruff
run: pip install "ruff>=0.8.0"
- name: Ruff lint (Python)
run: ruff check .
- name: Ruff format check (Python)
run: ruff format --check .
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: ESLint (frontend)
run: npm run lint
working-directory: frontend
- name: Prettier check (frontend)
run: npm run format:check
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:
name: Docker build
uses: ./.github/workflows/_docker-build-push.yml
with:
push: false
tag_mode: dev
+27
View File
@@ -0,0 +1,27 @@
name: Dependency review
# On PRs into main/dev, flag any newly added dependency that has a known
# vulnerability or an incompatible license before it gets merged.
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Dependency review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
+25
View File
@@ -0,0 +1,25 @@
name: Dev images
# On every push to the dev branch, build all three images and push them to
# GHCR and Docker Hub tagged `dev`.
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: docker-dev-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
build-push:
uses: ./.github/workflows/_docker-build-push.yml
with:
push: true
tag_mode: dev
secrets: inherit
+28
View File
@@ -0,0 +1,28 @@
name: Release images
# Build all three images and push them to ghcr.io and Docker Hub tagged
# `latest` and the release version (the pushed tag with its leading `v`
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
# so images are built once per release rather than on every commit to main.
on:
push:
tags:
- "v*"
workflow_dispatch:
concurrency:
group: docker-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
jobs:
build-push:
uses: ./.github/workflows/_docker-build-push.yml
with:
push: true
tag_mode: release
secrets: inherit
+96
View File
@@ -0,0 +1,96 @@
name: Security
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
# uploaded to the Security -> Code scanning tab.
#
# pip-audit / npm audit are informational (continue-on-error): they surface
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
# so they report in the logs without blocking merges. The PR gate for newly
# introduced vulnerable deps is dependency-review.yml.
on:
pull_request:
branches: [main, dev]
push:
branches: [main, dev]
schedule:
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
workflow_dispatch:
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
python-audit:
name: pip-audit
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"
- name: Install pip-audit
run: pip install pip-audit
- name: Audit Python dependencies
continue-on-error: true
run: pip-audit -r backend/requirements.txt
npm-audit:
name: npm audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: Audit npm dependencies
continue-on-error: true
run: npm audit --audit-level=high
working-directory: frontend
trivy:
name: Trivy filesystem scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Trivy
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy-fs
+49 -1
View File
@@ -5,15 +5,63 @@ bot_database.sqlite3
.env
.env.*
!.env.example
docker-compose-dev.yml
scratch_*.py
scratch/
*.local.*
node_modules/
.git/
# Documentation site build artifacts
docs-site/.astro/
docs-site/dist/
docs-site/public/demo/runtime/
docs-site/src/content/docs/
frontend-nginx-dist/
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css
bot/app/web/templates/subscription_webapp.min.*.js.br
bot/app/web/templates/subscription_webapp.min.*.js.gz
bot/app/web/templates/subscription_webapp.*.css.br
bot/app/web/templates/subscription_webapp.*.css.gz
bot/app/web/templates/subscription_webapp_admin.css
bot/app/web/templates/subscription_webapp_admin.js
bot/app/web/templates/subscription_webapp_admin.min.*.js
bot/app/web/templates/subscription_webapp_admin.*.css
bot/app/web/templates/subscription_webapp_admin.min.*.js.br
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
bot/app/web/templates/subscription_webapp_admin.*.css.br
bot/app/web/templates/subscription_webapp_admin.*.css.gz
bot/app/web/templates/subscription_webapp_docs_demo.css
bot/app/web/templates/subscription_webapp_docs_demo.js
bot/app/web/templates/subscription_webapp_docs_demo.*.css
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.min.*.js.br
backend/bot/app/web/templates/subscription_webapp.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp.*.css.br
backend/bot/app/web/templates/subscription_webapp.*.css.gz
backend/bot/app/web/templates/subscription_webapp_admin.css
backend/bot/app/web/templates/subscription_webapp_admin.js
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js
backend/bot/app/web/templates/subscription_webapp_admin.*.css
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
tmp
.claude
@@ -32,4 +80,4 @@ locales/en_backup.json
db/models_old.py
data/*
!data/tariffs.example.json
docker-compose-dev.yml
!data/locales-overrides.example.json
-10
View File
@@ -1,10 +0,0 @@
# Replace the example domains below with your real webhook and Mini App hostnames.
webhook.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEB_SERVER_PORT:8080}
}
app.domain.com {
encode zstd gzip
reverse_proxy remnawave-minishop:{$WEBAPP_SERVER_PORT:8081}
}
-96
View File
@@ -1,96 +0,0 @@
FROM python:3.12-slim AS python-builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM node:22-slim AS webapp-builder
WORKDIR /webapp
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY bot/app/web/frontend ./bot/app/web/frontend
COPY bot/app/web/templates ./bot/app/web/templates
COPY scripts/build_subscription_webapp_js.mjs ./scripts/build_subscription_webapp_js.mjs
RUN npm run build:webapp
FROM python:3.12-slim
WORKDIR /app
ARG APP_VERSION=""
ARG APP_REVISION=""
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${APP_REVISION}"
RUN useradd -u 10001 -m appuser
COPY --from=python-builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends git
COPY . .
# Replace template assets with freshly built ones
RUN rm -f bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.min.*.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.css \
bot/app/web/templates/subscription_webapp.css
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.js \
bot/app/web/templates/subscription_webapp.js
COPY --from=webapp-builder /webapp/bot/app/web/templates/subscription_webapp.min.*.js \
bot/app/web/templates/
RUN set -eux; \
if [ -n "$APP_VERSION" ]; then \
printf '%s\n' "$APP_VERSION" > .build-version; \
elif [ -d .git ]; then \
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"; \
sha="$(git rev-parse --short HEAD 2>/dev/null || true)"; \
dirty=""; \
if ! git diff --quiet --ignore-submodules HEAD 2>/dev/null; then dirty="-dirty"; fi; \
if [ -n "$tag" ] && [ -n "$sha" ]; then \
count="$(git rev-list "${tag}..HEAD" --count 2>/dev/null || true)"; \
if [ -n "$count" ] && [ "$count" != "0" ]; then \
printf '%s+%s.g%s%s\n' "$tag" "$count" "$sha" "$dirty" > .build-version; \
else \
printf '%s%s\n' "$tag" "$dirty" > .build-version; \
fi; \
elif [ -n "$sha" ]; then \
printf 'dev+g%s%s\n' "$sha" "$dirty" > .build-version; \
else \
printf 'dev+container\n' > .build-version; \
fi; \
else \
printf 'dev+container\n' > .build-version; \
fi; \
if [ -n "$APP_REVISION" ]; then \
printf '%s\n' "$APP_REVISION" > .build-revision; \
elif [ -d .git ]; then \
git rev-parse HEAD > .build-revision 2>/dev/null || printf 'unknown\n' > .build-revision; \
else \
printf 'unknown\n' > .build-revision; \
fi; \
apt-get purge -y --auto-remove git; \
rm -rf .git /root/.cache
RUN mkdir -p /app/logs /app/data && chown -R appuser:appuser /app/logs /app/data
USER appuser
CMD ["python", "main.py"]
+18 -4
View File
@@ -1,7 +1,21 @@
Copyright 2025 machka-pasla
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
Copyright (c) 2025-2026 machka-pasla, 3252a8 and other contributors
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+61 -29
View File
@@ -1,8 +1,10 @@
# Remnawave Minishop
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками Remnawave. Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
![Remnawave Minishop](docs/remnawave-minishop.webp)
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека используйте [инструкцию по миграции](docs/migration-to-minishop.md).
Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи и управления подписками панели [Remnawave](https://docs.rw/). Бот обрабатывает регистрацию, оплату, продление, пробный период, промокоды, рефералов и поддержку в чате. Web App показывает ссылку подключения, срок действия, трафик, оплату, устройства и вход по Telegram Mini Apps `initData`, Telegram OAuth / OpenID Connect и одноразовому email-коду.
Проект является переработанным форком [kavore/remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop). Для переноса данных из прежнего стека и других ботов используйте [раздел миграций](docs/migrations/index.md).
## Возможности
@@ -12,8 +14,10 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- просмотр статуса подписки, даты окончания, ссылки подключения и трафика;
- покупка подписок, пакетов трафика, обычная и premium-докупка трафика, докупка устройств по настроенному каталогу тарифов;
- Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, CryptoPay и Telegram Stars;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
Для администраторов:
@@ -21,18 +25,26 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- админ-панель для пользователей из `ADMIN_IDS` (только при входе через Telegram, не для аккаунтов только с email);
- статистика пользователей, подписок, платежей и синхронизации с Remnawave;
- список пользователей с поиском, фильтрами и колонкой premium-трафика;
- блокировка пользователей, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с period/traffic-моделями, Internal Squads, premium-сквадами и HWID-пакетами;
- блокировка пользователей, поддержка через тикеты, рассылки, промокоды, логи действий и настройка разрешенных параметров приложения поверх `.env`;
- редактор JSON-каталога тарифов с моделями на срок/по трафику, Internal Squads, premium-сквадами и HWID-пакетами;
- настройки инструкций подключения: чтение конфига Subscription Page из Remnawave Panel, опциональное JSON-переопределение и переключатель поведения кнопок бота;
- ручная синхронизация пользователей и подписок с панелью.
## Документация
- [Настройка окружения](docs/configuration.md) - основные переменные `.env`, платежи, Remnawave, пробный период, SMTP для email-входа и секреты.
- [Тарифы](docs/tariffs.md) - каталог тарифов, period- и traffic-модели, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/admin.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Web App / Mini App](docs/webapp.md) - отдельный порт, домен, Telegram OAuth, email-вход и реферальные ссылки.
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки, запуск из образа и обновление версии (`IMAGE_TAG`).
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
- [Входная страница документации](docs/index.md) - маршрут по установке, настройке, платежам, админке и диагностике.
- [Развертывание](docs/getting-started/deployment.md) - Docker Compose, Caddy, Nginx, Pangolin/Newt и запуск без обратного прокси.
- [Настройка окружения](docs/getting-started/configuration.md) - bootstrap `.env` и рекомендуемая настройка через Web App админку.
- [Переменные `.env`](docs/configuration/env-vars.md) - полный справочник всех env-ключей по разделам.
- [Бэкапы и восстановление](docs/features/backups.md) - автоматические архивы, Telegram-отправка и restore через админку.
- [Тарифы](docs/features/tariffs.md) - каталог тарифов, модели на срок и по трафику, обычные и premium-докупки, premium-сквады, смена тарифа, HWID-лимиты и обработка трафика.
- [Админ-панель](docs/features/admin-panel.md) - права доступа, настройки, редактор тарифов, premium-сквады и сохранение JSON-каталога.
- [Веб-приложение / Mini App](docs/features/web-app.md) - отдельный порт, домен, инструкции установки и реферальные ссылки.
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
## Совместимость
@@ -40,13 +52,13 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
## Стек
Сборка и runtime задаются **Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **requirements.txt** и **package.json**.
Сборка и runtime задаются **deploy/docker/Dockerfile** и **docker-compose.yml**; точные версии пакетов — в **backend/requirements.txt** и **frontend/package.json**.
| Слой | Технологии |
| --- | --- |
| Backend | Python **3.12**, [aiogram](https://docs.aiogram.dev/) 3.x (Telegram), **aiohttp** (HTTP и Web App), **SQLAlchemy** 2 async, **asyncpg**, **Pydantic** / pydantic-settings, **httpx**, платёжные SDK (в т.ч. YooKassa, aiocryptopay), **PyJWT** |
| Данные | **PostgreSQL** **17** (сервис `remnawave-minishop-db` в Compose) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `bot/app/web/templates/` |
| Данные | **PostgreSQL** **17** (сервис `postgres` в Compose) и **Redis** **7** (сервис `redis`) |
| Сборка Web App | **Node.js** **22**, **Svelte** **5**, **Vite**, **Tailwind CSS** 4; артефакты попадают в шаблоны `backend/bot/app/web/templates/` |
Локальная разработка без Docker возможна при установленных Python 3.12, PostgreSQL и (для пересборки фронта) Node 22; типичный сценарий — всё через Compose.
@@ -57,7 +69,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- Docker и Docker Compose;
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
- токен Telegram-бота;
- параметры хотя бы одного платежного провайдера.
- публичные домены для webhook и Mini App.
```bash
git clone https://github.com/3252a8/remnawave-minishop
@@ -65,7 +77,7 @@ cd remnawave-minishop
cp .env.example .env
nano .env
docker compose up -d --build
docker compose logs -f remnawave-minishop
docker compose logs -f backend worker frontend
```
Минимально заполните в `.env`:
@@ -73,17 +85,27 @@ docker compose logs -f remnawave-minishop
- `BOT_TOKEN` - токен Telegram-бота;
- `ADMIN_IDS` - Telegram ID администраторов через запятую;
- `WEBHOOK_BASE_URL` - публичный URL вебхуков;
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - доступы PostgreSQL;
- `WEBAPP_ENABLED=True` - включает Web App и админку для первого входа;
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
- `USER_SQUAD_UUIDS` - Internal Squads для пользователей;
- настройки платежного провайдера;
- `SUBSCRIPTION_MINI_APP_URL`, если используется Web App.
- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера;
- остальные настройки удобнее задать в Web App админке.
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
После первого входа в админку настройте тарифы, платежные провайдеры, внешний вид, поддержку, уведомления и инструкции подключения через UI. Инструкции установки включены по умолчанию, читают Subscription Page config из Remnawave Panel и при проблемах с конфигом откатываются к обычной ссылке подключения. Полный справочник env-переменных: [docs/configuration/env-vars.md](docs/configuration/env-vars.md).
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
В Docker этот файл должен быть доступен не только `backend` и `worker`, но и одноразовому сервису `migrate`: мигратор читает каталог тарифов при привязке существующих подписок к тарифу по умолчанию. В текущих compose-файлах весь `/app/data` уже смонтирован в `migrate`, `backend` и `worker`; если переносите compose вручную, сохраните одинаковый mount для всех трех сервисов.
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
mkdir -p data/themes data/webapp-logo data/tariffs
touch data/locales-overrides.json
chown -R 10001:10001 data
chmod -R u+rwX data
```
@@ -95,15 +117,25 @@ chmod -R u+rwX data
docker compose up -d --build
# Логи приложения
docker compose logs -f remnawave-minishop
docker compose logs -f backend worker frontend
# Запуск с Caddy
docker compose -f docker-compose-caddy.yml up -d --build
# Рекомендуемый продакшен-вариант с Caddy
cd deploy/examples/caddy # или nginx, newt, no-proxy
cp .env.example .env
nano .env
docker compose up -d
# Запуск из готового образа
IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
# Запуск из готового образа с конкретным тегом
IMAGE_TAG=3.1.0 docker compose up -d
```
## Поддержка
Для продакшен-запуска удобнее брать готовые папки из [`deploy/examples`](deploy/examples), а читать каноничные инструкции в [docs/getting-started/deployment.md](docs/getting-started/deployment.md). Предпочтительный вариант для обычного публичного сервера - Caddy: он сам выпускает и продлевает HTTPS-сертификаты. В папках рядом с compose лежат только конфиги и короткие ссылки на документацию.
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
Имена образов для релизов:
- `ghcr.io/3252a8/remnawave-minishop-backend`
- `ghcr.io/3252a8/remnawave-minishop-worker`
- `ghcr.io/3252a8/remnawave-minishop-frontend`
- `docker.io/3252a8/remnawave-minishop-backend`
- `docker.io/3252a8/remnawave-minishop-worker`
- `docker.io/3252a8/remnawave-minishop-frontend`
+12
View File
@@ -0,0 +1,12 @@
import logging
import os
import sys
def configure_logging() -> None:
level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
logging.basicConfig(
level=level,
stream=sys.stdout,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
@@ -6,19 +6,29 @@ from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from sqlalchemy.orm import sessionmaker
try:
from aiogram.fsm.storage.redis import RedisStorage
except ModuleNotFoundError: # pragma: no cover - dependency is installed in Docker image
RedisStorage = None # type: ignore[assignment]
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
from bot.middlewares.profile_sync import ProfileSyncMiddleware
from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware
from config.settings import Settings
def build_dispatcher(
settings: Settings, async_session_factory: sessionmaker
) -> tuple[Dispatcher, Bot, Dict]:
storage = MemoryStorage()
storage = (
RedisStorage.from_url(settings.REDIS_URL)
if settings.REDIS_URL and RedisStorage is not None
else MemoryStorage()
)
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
@@ -29,6 +39,7 @@ def build_dispatcher(
dp["i18n_instance"] = i18n_instance
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(I18nMiddleware(i18n=i18n_instance, settings=settings))
dp.update.outer_middleware(ProfileSyncMiddleware())
@@ -0,0 +1,93 @@
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import (
ServiceFactoryContext,
build_provider_configs,
build_provider_services,
)
from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_dry_run_api_service import PanelDryRunApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.support_service import SupportService
from config.settings import Settings
def build_core_services(
settings: Settings,
bot: Bot,
async_session_factory: sessionmaker,
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = (
PanelDryRunApiService(settings)
if bool(getattr(settings, "panel_dry_run_enabled", False))
else PanelApiService(settings)
)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
email_auth_service = EmailAuthService(settings, i18n)
notification_service = NotificationService(
bot,
settings,
i18n,
session_factory=async_session_factory,
email_auth_service=email_auth_service,
bot_username=bot_username_for_default_return,
)
support_service = SupportService(
async_session_factory,
settings,
bot,
i18n,
notification_service,
email_auth_service,
)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
provider_configs = build_provider_configs()
payment_services = build_provider_services(
ServiceFactoryContext(
settings=settings,
bot=bot,
async_session_factory=async_session_factory,
i18n=i18n,
bot_username_for_default_return=bot_username_for_default_return,
subscription_service=subscription_service,
referral_service=referral_service,
provider_configs=provider_configs,
)
)
lknpd_service = LknpdService(
settings.LKNPD_INN,
settings.LKNPD_PASSWORD,
api_url=settings.LKNPD_API_URL,
)
# These attachments are critical for auto-renew and panel pre-expiry hooks.
subscription_service.yookassa_service = payment_services.get("yookassa_service")
panel_webhook_service.subscription_service = subscription_service
services = {
"panel_service": panel_service,
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"notification_service": notification_service,
"email_auth_service": email_auth_service,
"support_service": support_service,
"panel_webhook_service": panel_webhook_service,
"lknpd_service": lknpd_service,
}
services.update(payment_services)
return services
@@ -6,8 +6,10 @@ from bot.app.web.admin_api_impl import (
_runtime as _runtime,
ads as _ads,
auth as _auth,
backups as _backups,
broadcast as _broadcast,
common as _common,
health as _health,
logs as _logs,
panel as _panel,
payments as _payments,
@@ -15,8 +17,11 @@ from bot.app.web.admin_api_impl import (
routes as _routes,
settings as _settings,
stats as _stats,
support as _support,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
translations as _translations,
users as _users,
)
@@ -24,16 +29,21 @@ _MODULES = (
_runtime,
_auth,
_common,
_health,
_stats,
_users,
_payments,
_promos,
_logs,
_support,
_broadcast,
_sync,
_ads,
_backups,
_settings,
_tariffs,
_themes,
_translations,
_panel,
_routes,
)
@@ -28,6 +28,7 @@ from sqlalchemy.orm import sessionmaker
from bot.app.web.admin_settings_manifest import (
manifest_payload,
)
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.services.referral_service import ReferralService
from bot.services.settings_override_service import (
current_value,
@@ -36,10 +37,11 @@ from bot.services.settings_override_service import (
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from config.tariffs_config import TariffsConfig
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
from db.dal import (
ad_dal,
app_settings_dal,
locale_overrides_dal,
message_log_dal,
panel_sync_dal,
payment_dal,
@@ -0,0 +1,181 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
import secrets
import subprocess
from bot.infra.redis import redis_lock
from bot.services.backup_restore_service import (
BACKUP_UPLOAD_MAX_BYTES,
BackupArchiveError,
BackupArchiveInfo,
BackupRestoreError,
BackupRestoreService,
)
from bot.services.backup_worker import BackupWorker
def _backup_archive_payload(archive) -> Dict[str, Any]:
return archive.to_payload()
async def _read_uploaded_backup_file(request: web.Request) -> BackupArchiveInfo:
settings: Settings = request.app["settings"]
service = BackupRestoreService(settings)
backup_dir = service.backup_dir()
temp_path: Optional[Path] = None
reader = await request.multipart()
try:
async for part in reader:
if part.name != "file":
continue
original_filename = part.filename or "backup.zip"
temp_path = backup_dir / f".upload-{secrets.token_urlsafe(12)}.zip.tmp"
size = 0
with temp_path.open("wb") as handle:
while True:
chunk = await part.read_chunk(size=1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > BACKUP_UPLOAD_MAX_BYTES:
raise BackupArchiveError("Backup archive is too large")
handle.write(chunk)
if size <= 0:
raise BackupArchiveError("Uploaded archive is empty")
archive = service.import_uploaded_archive(temp_path, original_filename)
temp_path = None
return archive
finally:
if temp_path is not None and temp_path.exists():
try:
temp_path.unlink()
except OSError:
logger.warning("Failed to remove temporary backup upload %s", temp_path)
raise BackupArchiveError("file field is required")
async def admin_backups_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
try:
service = BackupRestoreService(settings)
archives = service.list_archives()
except OSError as exc:
logger.exception("Failed to list backup archives")
return _error(500, "backup_list_failed", str(exc))
return _ok(
{
"backup_dir": str(service.backup_dir()),
"archives": [_backup_archive_payload(archive) for archive in archives],
}
)
async def admin_backups_upload_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
if not content_type.startswith("multipart/form-data"):
return _error(400, "invalid_backup_archive", "multipart file upload is required")
try:
archive = await _read_uploaded_backup_file(request)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded backup archive")
return _error(500, "backup_upload_failed", str(exc))
return _ok({"archive": _backup_archive_payload(archive)})
async def admin_backups_create_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
bot = request.app["bot"]
session_factory = request.app.get("async_session_factory")
worker = BackupWorker(settings, bot, session_factory=session_factory)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_DUMP_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_create_busy", "Backup or restore is already running")
await worker.refresh_settings()
result = await worker.create_and_send_backup(backup_type="manual")
archive = BackupRestoreService(settings).inspect_archive(result.archive_path)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except (OSError, RuntimeError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
except Exception as exc:
logger.exception("Manual backup creation failed")
return _error(500, "backup_create_failed", str(exc))
return _ok(
{
"result": result.to_payload(),
"archive": _backup_archive_payload(archive),
}
)
async def admin_backups_restore_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
archive_name = str(payload.get("archive_name") or "").strip()
restore_database = bool(payload.get("restore_database"))
restore_compose = bool(payload.get("restore_compose"))
confirm = bool(payload.get("confirm"))
if not confirm:
return _error(400, "restore_confirmation_required")
service = BackupRestoreService(settings)
ttl_seconds = max(
60,
int(
max(
getattr(settings, "BACKUP_LOCK_TTL_SECONDS", 7200) or 7200,
getattr(settings, "BACKUP_PG_RESTORE_TIMEOUT_SECONDS", 1800) or 1800,
)
),
)
try:
async with redis_lock(settings, "backup-worker", ttl_seconds=ttl_seconds) as acquired:
if not acquired:
return _error(409, "backup_restore_busy", "Backup or restore is already running")
result = await service.restore_archive(
archive_name,
restore_database=restore_database,
restore_compose=restore_compose,
)
except BackupArchiveError as exc:
return _error(400, "invalid_backup_archive", str(exc))
except BackupRestoreError as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
except (OSError, subprocess.SubprocessError, TimeoutError) as exc:
logger.exception("Backup restore failed")
return _error(500, "backup_restore_failed", str(exc))
finally:
try:
from db import database_setup
if restore_database and database_setup.async_engine is not None:
await database_setup.async_engine.dispose()
except Exception:
logger.exception("Failed to dispose DB engine after backup restore")
return _ok({"result": result.to_payload()})
@@ -0,0 +1,242 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .common import _panel_user_connection_activity
import asyncio
from collections import defaultdict
from bot.utils.ttl_cache import AsyncTTLCache
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
BROADCAST_TARGETS = {
"all",
"active",
"inactive",
"expired",
"never",
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED,
}
PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
def _resolve_panel_service(request: web.Request) -> Any:
subscription_service = request.app.get("subscription_service")
return getattr(subscription_service, "panel_service", None)
async def _active_subscription_panel_uuids_by_user(
session: AsyncSession,
) -> Dict[int, List[str]]:
now = datetime.now(timezone.utc)
stmt = (
select(Subscription.user_id, Subscription.panel_user_uuid)
.join(User, Subscription.user_id == User.user_id)
.where(
User.is_banned == False,
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.panel_user_uuid.is_not(None),
Subscription.panel_user_uuid != "",
)
.order_by(Subscription.user_id.asc(), Subscription.end_date.desc())
)
result = await session.execute(stmt)
grouped: Dict[int, List[str]] = defaultdict(list)
seen: Dict[int, set[str]] = defaultdict(set)
for user_id, panel_uuid in result.all():
user_id_int = int(user_id)
panel_uuid_str = str(panel_uuid or "").strip()
if panel_uuid_str and panel_uuid_str not in seen[user_id_int]:
grouped[user_id_int].append(panel_uuid_str)
seen[user_id_int].add(panel_uuid_str)
return dict(grouped)
async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str:
try:
panel_user = await panel_service.get_user_by_uuid(panel_uuid)
except Exception as exc:
logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc)
return "unknown"
activity = _panel_user_connection_activity(panel_user)
return str(activity.get("status") or "unknown")
async def _user_ids_with_active_subscription_never_connected(
session: AsyncSession,
panel_service: Any,
) -> List[int]:
panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session)
semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY)
async def lookup(panel_uuid: str) -> str:
async with semaphore:
return await _panel_connection_status(panel_service, panel_uuid)
panel_uuids = list(
dict.fromkeys(
panel_uuid
for user_panel_uuids in panel_uuids_by_user.values()
for panel_uuid in user_panel_uuids
)
)
statuses_by_uuid = dict(
zip(
panel_uuids,
await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)),
)
)
user_ids: List[int] = []
for user_id, panel_uuids in panel_uuids_by_user.items():
statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids]
if statuses and all(status == "never" for status in statuses):
user_ids.append(user_id)
return user_ids
def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(
getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0
)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:broadcast_audience_counts",
)
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache
return cache
async def _load_broadcast_audience_counts(
settings: Settings,
async_session_factory: sessionmaker,
panel_service: Any,
) -> Dict[str, Optional[int]]:
cache = _admin_broadcast_audience_counts_cache(settings)
if cache is None:
return await _load_broadcast_audience_counts_uncached(
async_session_factory,
panel_service,
)
cache_key = "with-panel" if panel_service is not None else "without-panel"
return await cache.get_or_load(
cache_key,
lambda: _load_broadcast_audience_counts_uncached(
async_session_factory,
panel_service,
),
)
async def _load_broadcast_audience_counts_uncached(
async_session_factory: sessionmaker,
panel_service: Any,
) -> Dict[str, Optional[int]]:
async with async_session_factory() as session:
counts: Dict[str, Optional[int]] = {
"all": await user_dal.count_all_active_users_for_broadcast(session),
"active": await user_dal.count_users_with_active_subscription_for_broadcast(session),
"inactive": await user_dal.count_users_without_active_subscription_for_broadcast(
session
),
"expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session),
"never": await user_dal.count_users_without_any_subscription_for_broadcast(session),
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None,
}
if panel_service is not None:
counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len(
await _user_ids_with_active_subscription_never_connected(
session,
panel_service,
)
)
return counts
async def admin_broadcast_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
payload = await _read_json(request)
text = str(payload.get("text") or "").strip()
target = str(payload.get("target") or "all").strip().lower()
if not text:
return _error(400, "empty_text")
if target not in BROADCAST_TARGETS:
target = "all"
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED:
panel_service = _resolve_panel_service(request)
if panel_service is None:
return _error(503, "panel_service_unavailable")
user_ids = await _user_ids_with_active_subscription_never_connected(
session,
panel_service,
)
elif target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
elif target == "expired":
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:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent = 0
failed = 0
for uid in user_ids:
try:
await send_message_via_queue(
queue_manager,
int(uid),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
)
sent += 1
except Exception as exc:
failed += 1
logger.debug("Broadcast queue failed for %s: %s", uid, exc)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_broadcast_webapp",
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
"is_admin_event": True,
},
)
return _ok({"queued": sent, "failed": failed, "target": target})
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
"""Return how many users each broadcast audience currently resolves to."""
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
panel_service = _resolve_panel_service(request)
counts = await _load_broadcast_audience_counts(
settings,
async_session_factory,
panel_service,
)
return _ok({"counts": counts})
@@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]:
return {}
_PANEL_LAST_CONNECTED_KEYS = (
"onlineAt",
"online_at",
"lastSeenAt",
"last_seen_at",
"lastConnectedAt",
"last_connected_at",
"lastConnectionAt",
"last_connection_at",
)
_PANEL_CONNECTION_MARKER_KEYS = (
*_PANEL_LAST_CONNECTED_KEYS,
"firstConnectedAt",
"first_connected_at",
"lastConnectedNodeUuid",
"last_connected_node_uuid",
)
_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node")
_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats")
_PANEL_TRAFFIC_USED_KEYS = (
"lifetimeUsedTrafficBytes",
"lifetime_used_traffic_bytes",
"usedTrafficBytes",
"used_traffic_bytes",
"trafficUsedBytes",
"traffic_used_bytes",
"downloadBytes",
"download_bytes",
"uploadBytes",
"upload_bytes",
)
def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]:
if not isinstance(panel_user_data, dict):
return {}
response = panel_user_data.get("response")
if isinstance(response, dict) and not any(
key in panel_user_data
for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status")
):
return response
return panel_user_data
def _coerce_panel_datetime(value: Any) -> Optional[str]:
if value is None or value is False:
return None
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, (int, float)):
if value <= 0:
return None
seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value)
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
except (OSError, OverflowError, ValueError):
return None
text = str(value).strip()
if not text or text.lower() in {"0", "null", "none", "never"}:
return None
if text.isdigit():
return _coerce_panel_datetime(int(text))
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
return parsed.isoformat()
def _coerce_panel_int(value: Any) -> Optional[int]:
try:
if value is None or value == "":
return None
return int(float(value))
except (TypeError, ValueError):
return None
def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for key in keys:
value = panel_user.get(key)
if isinstance(value, dict):
out.append(value)
return out
def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]:
traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)
marker_containers = _panel_nested_dicts(
panel_user,
_PANEL_CONNECTION_MARKER_OBJECT_KEYS,
)
for traffic_container in traffic_containers:
marker_containers.extend(
_panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS)
)
return [panel_user, *traffic_containers, *marker_containers]
def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]:
panel_user = _panel_user_payload(panel_user_data)
if not panel_user:
return None
for container in _panel_user_connection_containers(panel_user):
for key in _PANEL_LAST_CONNECTED_KEYS:
connected_at = _coerce_panel_datetime(container.get(key))
if connected_at:
return connected_at
return None
def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool:
containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]
for container in containers:
for key in _PANEL_TRAFFIC_USED_KEYS:
value = _coerce_panel_int(container.get(key))
if value is not None and value > 0:
return True
return False
def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool:
for container in _panel_user_connection_containers(panel_user):
for key in _PANEL_CONNECTION_MARKER_KEYS:
if key in container:
return True
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
if key in container:
return True
return False
def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool:
for container in _panel_user_connection_containers(panel_user):
for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"):
if _coerce_panel_datetime(container.get(key)):
return True
for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"):
if str(container.get(key) or "").strip():
return True
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
marker = container.get(key)
if isinstance(marker, dict) and any(
str(value or "").strip() for value in marker.values()
):
return True
if marker and not isinstance(marker, dict):
return True
return False
def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]:
panel_user = _panel_user_payload(panel_user_data)
last_connected_at = _panel_user_last_connected_at(panel_user)
if not panel_user:
return {"status": "unknown", "last_connected_at": None}
if last_connected_at or _panel_user_positive_traffic_bytes(panel_user):
return {"status": "connected", "last_connected_at": last_connected_at}
if _panel_user_has_connected_marker_value(panel_user):
return {"status": "connected", "last_connected_at": last_connected_at}
if _panel_user_has_connection_marker(panel_user):
return {"status": "never", "last_connected_at": None}
return {"status": "unknown", "last_connected_at": None}
def _serialize_user(user: User) -> Dict[str, Any]:
return {
"user_id": int(user.user_id),
@@ -94,6 +263,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
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 {
"subscription_id": int(sub.subscription_id),
"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,
"premium_unlimited_override": premium_unlimited_override,
"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,
"display_label": display_label,
"is_trial": is_trial,
"auto_renew_enabled": bool(sub.auto_renew_enabled),
"provider": sub.provider,
"provider": provider,
"is_throttled": bool(sub.is_throttled),
}
@@ -143,12 +319,18 @@ def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Option
return None, None
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
"""Human-facing name for payments tables: TG profile name, else email, else user id."""
if loaded_user is None:
return str(payment_user_id)
def _user_display_label(
loaded_user: Any,
fallback_user_id: Optional[int],
*,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
) -> Optional[str]:
"""Human-facing name: TG profile name, else email, else user id."""
tid = getattr(loaded_user, "telegram_id", None)
if tid is not None:
if loaded_user is not None and tid is not None:
fn = (getattr(loaded_user, "first_name", None) or "").strip()
ln = (getattr(loaded_user, "last_name", None) or "").strip()
full = f"{fn} {ln}".strip()
@@ -157,10 +339,30 @@ def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
un = (getattr(loaded_user, "username", None) or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
return str(payment_user_id)
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
elif loaded_user is not None:
email = (getattr(loaded_user, "email", None) or "").strip()
if email:
return email
fn = (first_name or "").strip()
ln = (last_name or "").strip()
full = f"{fn} {ln}".strip()
if full:
return full
un = (username or "").strip()
if un:
return un if un.startswith("@") else f"@{un}"
email_value = (email or "").strip()
if email_value:
return email_value
if fallback_user_id is None:
return None
return str(fallback_user_id)
def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str:
label = _user_display_label(loaded_user, payment_user_id)
if label:
return label
return str(payment_user_id)
@@ -229,15 +431,27 @@ def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None)
def _serialize_log(entry: MessageLog) -> Dict[str, Any]:
author_user = entry.__dict__.get("author_user")
target_user = entry.__dict__.get("target_user")
user_id = int(entry.user_id) if entry.user_id is not None else None
target_user_id = int(entry.target_user_id) if entry.target_user_id is not None else None
return {
"log_id": int(entry.log_id),
"user_id": int(entry.user_id) if entry.user_id else None,
"user_id": user_id,
"user_label": _user_display_label(
author_user,
user_id,
first_name=entry.telegram_first_name,
username=entry.telegram_username,
),
"telegram_username": entry.telegram_username,
"telegram_first_name": entry.telegram_first_name,
"email": getattr(author_user, "email", None),
"event_type": entry.event_type,
"content": entry.content,
"is_admin_event": bool(entry.is_admin_event),
"target_user_id": int(entry.target_user_id) if entry.target_user_id else None,
"target_user_id": target_user_id,
"target_user_label": _user_display_label(target_user, target_user_id),
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
}
@@ -270,6 +484,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
path.write_text(payload, encoding="utf-8")
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
@@ -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(),
}
)
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
)
async def admin_payment_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
try:
payment_id = int(request.match_info["payment_id"])
except (TypeError, ValueError):
return _error(400, "invalid_payment", "Invalid payment id")
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment:
return _error(404, "not_found", "Payment not found")
payload = _serialize_payment(payment)
payload.update(
{
"yookassa_payment_id": payment.yookassa_payment_id,
"idempotence_key": payment.idempotence_key,
"promo_code": (
payment.promo_code_used.code if payment.promo_code_used is not None else None
),
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
}
)
return _ok({"payment": payload})
async def admin_payments_export_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
@@ -6,15 +6,21 @@ def setup_admin_routes(app: web.Application) -> None:
router = app.router
router.add_get("/api/admin/me", admin_me_route)
router.add_get("/api/admin/stats", admin_stats_route)
router.add_get("/api/admin/health", admin_health_route)
router.add_get("/api/admin/users", admin_users_list_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/referrals", admin_user_referrals_route)
router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route
)
router.add_post(
"/api/admin/users/{user_id:-?\\d+}/telegram-profile-link",
admin_user_telegram_profile_link_route,
)
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
router.add_post(
@@ -25,6 +31,10 @@ def setup_admin_routes(app: web.Application) -> None:
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
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(
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
admin_user_traffic_grant_route,
@@ -32,6 +42,7 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
router.add_get("/api/admin/payments", admin_payments_list_route)
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
router.add_get("/api/admin/promos", admin_promos_list_route)
@@ -41,6 +52,17 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/logs", admin_logs_route)
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
router.add_post(
"/api/admin/support/tickets/{id:\\d+}/messages",
admin_support_ticket_reply_route,
)
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_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/broadcast/audience-counts", admin_broadcast_audience_counts_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
@@ -51,7 +73,17 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/settings", admin_settings_get_route)
router.add_patch("/api/admin/settings", admin_settings_patch_route)
router.add_get("/api/admin/translations", admin_translations_get_route)
router.add_patch("/api/admin/translations", admin_translations_patch_route)
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/themes", admin_themes_get_route)
router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_post("/api/admin/appearance/logo", admin_appearance_logo_upload_route)
router.add_post("/api/admin/appearance/favicon", admin_appearance_favicon_upload_route)
router.add_get("/api/admin/backups", admin_backups_list_route)
router.add_post("/api/admin/backups/create", admin_backups_create_route)
router.add_post("/api/admin/backups/upload", admin_backups_upload_route)
router.add_post("/api/admin/backups/restore", admin_backups_restore_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
@@ -1,5 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
from config.subscription_guides_config import (
SubscriptionGuidesConfigError,
subscription_guides_admin_config_json,
)
async def admin_settings_get_route(request: web.Request) -> web.Response:
@@ -13,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload()
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
@@ -26,14 +33,35 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
override = overrides_by_key.get(key)
value = current_value(settings, key)
is_secret = bool(field.get("secret"))
overridden = bool(override)
source = None
read_error = None
if key == "SUBSCRIPTION_PAGE_CONFIG_JSON":
try:
value, source = subscription_guides_admin_config_json(settings)
overridden = source == "admin_json"
except SubscriptionGuidesConfigError as exc:
read_error = str(exc)
response_field = {
**field,
"value": "" if is_secret else value,
"overridden": bool(override),
"overridden": overridden,
"updated_at": override.get("updated_at") if override else None,
}
if source:
response_field["source"] = source
if read_error:
response_field["read_error"] = read_error
if is_secret:
response_field["has_value"] = bool(value)
webhook_path = str(response_field.get("webhook_path") or "").strip()
if webhook_path:
if not webhook_path.startswith("/"):
webhook_path = f"/{webhook_path}"
response_field["webhook_path"] = webhook_path
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
if webhook_base_url:
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
@@ -51,6 +79,13 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
if (
"SUBSCRIPTION_PAGE_CONFIG_JSON" in updates
and not str(updates.get("SUBSCRIPTION_PAGE_CONFIG_JSON") or "").strip()
):
updates = dict(updates)
updates.pop("SUBSCRIPTION_PAGE_CONFIG_JSON", None)
deletes = [*deletes, "SUBSCRIPTION_PAGE_CONFIG_JSON"]
result = await update_overrides(
settings,
@@ -65,10 +100,6 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
status=400,
)
# Bust the public webapp settings cache so users see new values immediately.
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=deletes)
return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)})
+168
View File
@@ -0,0 +1,168 @@
# ruff: noqa: F401,F403,F405,I001
import asyncio
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import _ok, _serialize_payment
from bot.utils.ttl_cache import AsyncTTLCache
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
_ADMIN_DB_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
async def admin_me_route(request: web.Request) -> web.Response:
user_id = _require_admin_user_id(request)
settings: Settings = request.app["settings"]
return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or []))
async def admin_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = dict(await _load_admin_db_stats(settings, async_session_factory))
panel_service = request.app.get("panel_service")
if panel_service is not None:
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
queue_manager = get_queue_manager()
if queue_manager:
try:
payload["queue"] = queue_manager.get_queue_stats()
except Exception: # pragma: no cover - defensive
payload["queue"] = None
payload["currency_symbol"] = default_payment_currency_code_for_settings(settings)
return _ok(payload)
async def _load_admin_db_stats(
settings: Settings,
async_session_factory: sessionmaker,
) -> Dict[str, Any]:
cache = _admin_db_stats_cache(settings)
if cache is None:
return await _load_admin_db_stats_uncached(async_session_factory)
return await cache.get_or_load(
"db",
lambda: _load_admin_db_stats_uncached(async_session_factory),
)
async def _load_admin_db_stats_uncached(async_session_factory: sessionmaker) -> Dict[str, Any]:
async with async_session_factory() as session:
user_stats = await user_dal.get_enhanced_user_statistics(session)
financial_stats = await payment_dal.get_financial_statistics(session)
sync_status = await panel_sync_dal.get_panel_sync_status(session)
recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10)
return {
"users": user_stats,
"financial": financial_stats,
"panel_sync": {
"status": sync_status.status if sync_status else "never_run",
"last_sync_time": sync_status.last_sync_time.isoformat()
if sync_status and sync_status.last_sync_time
else None,
"details": sync_status.details if sync_status else None,
"users_processed": sync_status.users_processed_from_panel if sync_status else 0,
"subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0,
},
"recent_payments": [_serialize_payment(p) for p in recent_payments],
}
def _admin_db_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_DB_STATS_CACHE_TTL_SECONDS", 5) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_DB_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:db_stats",
)
_ADMIN_DB_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats(
request: web.Request,
settings: Settings,
panel_service,
) -> Dict[str, Any]:
cache = _admin_panel_stats_cache(settings)
if cache is None:
return await _load_admin_panel_stats_uncached(panel_service)
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:panel_stats",
)
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
return cache
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
_safe_panel_call(
panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
),
"nodes bandwidth range",
),
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
)
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
"nodes": nodes or {},
"nodes_bandwidth": nodes_bw or {},
}
if isinstance(lookups, dict):
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
return panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
return {"error": "unavailable"}
async def _safe_panel_call(awaitable, label: str) -> Any:
try:
return await awaitable
except Exception as exc: # pragma: no cover - optional panel endpoints
logger.debug("Panel %s unavailable: %s", label, exc)
return None
@@ -0,0 +1,251 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, constr, field_validator
from bot.services.support_service import TicketNotFound
from db.dal import support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage
class AdminTicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
is_internal_note: bool = False
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
None
)
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
category: Optional[Literal["billing", "technical", "account", "other"]] = None
assigned_admin_id: Optional[int] = None
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
try:
return model_cls.model_validate(payload), None
except ValidationError:
return None, _error(400, "invalid_request", "Invalid request")
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
return {
"ticket_id": ticket.ticket_id,
"user_id": ticket.user_id,
"subject": ticket.subject,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"assigned_admin_id": ticket.assigned_admin_id,
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
"last_message_role": ticket.last_message_role,
"unread_user_count": int(ticket.unread_user_count or 0),
"unread_admin_count": int(ticket.unread_admin_count or 0),
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
}
def _user_display_name(user) -> Optional[str]:
if not user:
return None
name = " ".join(
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
).strip()
return name or user.username or user.email or str(user.user_id)
def _support_message_payload(
message: SupportTicketMessage,
*,
authors: Optional[Dict[int, Any]] = None,
) -> Dict[str, Any]:
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
return {
"message_id": message.message_id,
"ticket_id": message.ticket_id,
"author_role": message.author_role,
"author_user_id": message.author_user_id,
"author_name": _user_display_name(author),
"body": message.body,
"is_internal_note": bool(message.is_internal_note),
"created_at": message.created_at.isoformat() if message.created_at else None,
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
"read_by_admin_at": message.read_by_admin_at.isoformat()
if message.read_by_admin_at
else None,
}
def _admin_support_user_payload(user) -> Dict[str, Any]:
if not user:
return {}
return {
"user_id": user.user_id,
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"telegram_photo_url": user.telegram_photo_url,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
}
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
offset = max(0, int(request.query.get("offset", 0) or 0))
return limit, offset
async def admin_support_tickets_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
limit, offset = _support_limit_offset(request)
assigned_raw = request.query.get("assigned")
assigned_admin_id = None
if assigned_raw and assigned_raw not in {"all", "any"}:
assigned_admin_id = int(assigned_raw)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
tickets = await support_dal.list_admin_tickets(
session,
status=request.query.get("status") or None,
priority=request.query.get("priority") or None,
category=request.query.get("category") or None,
assigned_admin_id=assigned_admin_id,
search=request.query.get("search") or None,
sort=request.query.get("sort") or "updated_desc",
limit=limit,
offset=offset,
)
return web.json_response(
{
"ok": True,
"tickets": [
{
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
}
for ticket in tickets
],
}
)
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
service = request.app["support_service"]
async with async_session_factory() as session:
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
if not ticket:
return _error(404, "not_found", "Ticket not found")
user = await user_dal.get_user_by_id(session, ticket.user_id)
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
authors = {}
for author_id in author_ids:
author = await user_dal.get_user_by_id(session, author_id)
if author:
authors[author_id] = author
return web.json_response(
{
"ok": True,
"ticket": {
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(user),
},
"messages": [_support_message_payload(m, authors=authors) for m in messages],
"user_snapshot": snapshot,
}
)
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
if error:
return error
try:
ticket, message = await request.app["support_service"].reply_as_admin(
admin_id,
ticket_id,
payload.body,
is_internal_note=payload.is_internal_note,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
admin = await user_dal.get_user_by_id(session, admin_id)
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"message": _support_message_payload(
message, authors={admin_id: admin} if admin else {}
),
}
)
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
if error:
return error
updates = payload.model_dump(exclude_unset=True)
try:
if updates.get("status") == "closed":
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
updates.pop("status", None)
if updates:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
else:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
await request.app["support_service"].mark_read_as_admin(ticket_id)
return web.json_response({"ok": True})
async def admin_support_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
stats = await support_dal.admin_stats(session)
return web.json_response({"ok": True, "stats": stats})
@@ -0,0 +1,16 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
async def admin_sync_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
queued = await enqueue_webhook_event(
settings,
"panel_sync",
{"requested_by": _require_admin_user_id(request)},
event_id=None,
)
if queued:
return _ok({"result": {"status": "queued"}})
return _error(503, "queue_unavailable")
@@ -0,0 +1,114 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
async def admin_tariffs_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
path = _tariffs_config_path(settings)
try:
config = settings.tariffs_config
except Exception as exc:
logger.warning("Invalid tariffs config requested from admin UI: %s", exc)
return _error(400, "invalid_tariffs_config", str(exc))
if config is None:
return _ok(
{
"exists": path.exists(),
"path": str(path),
"catalog": {
"default_tariff": "",
"default_currency": "rub",
"topup_packages_default": {"rub": [], "stars": []},
"tariffs": [],
},
"provider_currency_support": _provider_currency_support_payload(
settings,
request.app,
),
}
)
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
async def admin_tariffs_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = TariffsConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_tariffs_config", str(exc))
path = _tariffs_config_path(settings)
try:
_write_tariffs_config_file(path, config)
except OSError as exc:
logger.exception("Failed to write tariffs config to %s", path)
return _error(500, "write_failed", str(exc))
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok(
{
"exists": True,
"path": str(path),
"catalog": _tariffs_config_payload(config),
"provider_currency_support": _provider_currency_support_payload(settings, request.app),
}
)
def _provider_currency_support_payload(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
from bot.payment_providers import iter_provider_specs, resolve_provider_presentation
default_currency = default_payment_currency_code_for_settings(settings)
providers: List[Dict[str, Any]] = []
for spec in iter_provider_specs():
presentation = resolve_provider_presentation(spec, settings)
supported = spec.supported_currency_codes(settings)
providers.append(
{
"id": spec.id,
"provider_key": spec.provider_key,
"label": presentation.webapp_label or spec.label,
"telegram_label": presentation.telegram_label,
"icon": presentation.webapp_icon,
"enabled": spec.is_effectively_enabled(settings),
"configured": spec.is_service_configured(app),
"admin_only": spec.is_admin_only_enabled(settings),
"price_source": spec.price_source,
"currencies": list(supported) if supported is not None else None,
"accepts_any_currency": supported is None,
"supports_default_currency": spec.is_usable_for_payment_currency(
settings,
default_currency,
),
"directly_supports_default_currency": spec.supports_currency(
settings,
default_currency,
),
"default_currency": default_currency,
"note": spec.currency_support_note,
"docs_url": spec.currency_support_url,
}
)
return providers
@@ -0,0 +1,465 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .webapp_runtime import refresh_webapp_runtime_after_settings_change
import asyncio
import hashlib
import ipaddress
import shutil
import re
import socket
from aiohttp import ClientSession, ClientTimeout
from PIL import Image, ImageOps, UnidentifiedImageError
from config.webapp_themes_config import (
WebappThemesConfig,
ensure_webapp_core_themes,
resolved_webapp_themes_catalog,
write_webapp_theme_dir,
)
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
WEBAPP_LOGO_UPLOAD_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",
}
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
if hasattr(theme, "model_dump"):
data = theme.model_dump(mode="json", exclude_none=True)
elif isinstance(theme, dict):
data = dict(theme)
else:
data = {}
data.pop("assets_version", None)
data.pop("default", None)
return data
def _bump_theme_asset_versions(
config: WebappThemesConfig,
previous: WebappThemesConfig,
) -> WebappThemesConfig:
previous_by_key = {theme.key: theme for theme in previous.themes}
default_changed = config.default_theme != previous.default_theme
data = config.model_dump(mode="json", exclude_none=True)
for theme in data.get("themes", []):
if not isinstance(theme, dict):
continue
if not str(theme.get("css_file") or "").strip():
continue
key = str(theme.get("key") or "")
previous_theme = previous_by_key.get(key)
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
current_version = int(theme.get("assets_version") or 1)
theme_changed = previous_theme is None or _theme_payload_for_version_compare(
theme
) != _theme_payload_for_version_compare(previous_theme)
if theme_changed or (default_changed and key == config.default_theme):
theme["assets_version"] = max(previous_version + 1, current_version, 1)
elif previous_version > current_version:
theme["assets_version"] = previous_version
return WebappThemesConfig.model_validate(data)
def _detect_logo_extension(
body: bytes, content_type: str = "", filename: str = ""
) -> Optional[str]:
content_type = (content_type or "").split(";", 1)[0].strip().lower()
suffix = Path(filename or "").suffix.lower()
if content_type == "image/png" or body.startswith(b"\x89PNG\r\n\x1a\n"):
return ".png"
if content_type == "image/jpeg" or body.startswith(b"\xff\xd8\xff"):
return ".jpg"
if content_type == "image/gif" or body.startswith((b"GIF87a", b"GIF89a")):
return ".gif"
if content_type == "image/webp" or (
len(body) > 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP"
):
return ".webp"
if content_type in {"image/svg+xml", "image/svg"} or suffix == ".svg":
head = body[:512].lstrip().lower()
if head.startswith(b"<svg") or b"<svg" in head:
return ".svg"
if content_type == "image/x-icon" or suffix == ".ico":
if body.startswith(b"\x00\x00\x01\x00"):
return ".ico"
return suffix if suffix in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES else None
def _write_uploaded_logo(body: bytes, content_type: str = "", filename: str = "") -> str:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
if ext not in WEBAPP_LOGO_UPLOAD_CONTENT_TYPES:
raise ValueError("unsupported image type")
digest = hashlib.sha256(body).hexdigest()[:16]
safe_name = f"logo-{digest}{ext}"
WEBAPP_UPLOADED_LOGO_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_UPLOADED_LOGO_DIR / safe_name).write_bytes(body)
return f"{WEBAPP_UPLOADED_LOGO_PATH}/{safe_name}"
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)
if re.fullmatch(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)", filename):
return filename
return None
def _favicon_digest(url: str) -> Optional[str]:
parsed = urlsplit(str(url or ""))
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
match = re.fullmatch(
rf"{re.escape(WEBAPP_FAVICON_PATH)}/([0-9a-f]{{16}})/(?:[A-Za-z0-9_.-]+)",
path,
)
return match.group(1) if match else None
def prune_unused_appearance_assets(settings: Settings) -> None:
keep_logos = {
filename
for filename in [
_uploaded_logo_filename(getattr(settings, "WEBAPP_LOGO_URL", "")),
]
if filename
}
keep_favicons = {
digest
for digest in [
_favicon_digest(getattr(settings, "WEBAPP_FAVICON_URL", "")),
_favicon_digest(getattr(settings, "WEBAPP_LOGO_FAVICON_URL", "")),
]
if digest
}
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
if path.is_file() and path.name not in keep_logos:
try:
path.unlink()
except OSError:
logger.warning("Failed to remove unused webapp logo %s", path, exc_info=True)
for path in WEBAPP_FAVICON_DIR.glob("*"):
if (
path.is_dir()
and re.fullmatch(r"[0-9a-f]{16}", path.name)
and path.name not in keep_favicons
):
try:
shutil.rmtree(path)
except OSError:
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
async def _persist_appearance_upload(
request: web.Request,
updates: Dict[str, Any],
actor_id: int,
) -> bool:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
result = await update_overrides(
settings,
async_session_factory,
updates=updates,
deletes=[],
actor_id=actor_id,
)
if not result.get("ok"):
logger.warning("Failed to persist uploaded appearance asset settings: %s", result)
return False
await refresh_webapp_runtime_after_settings_change(request, updates=updates, deletes=[])
return True
def _image_to_square_icon(source: Image.Image, size: int) -> Image.Image:
fitted = source.copy()
fitted.thumbnail((size, size), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
left = (size - fitted.width) // 2
top = (size - fitted.height) // 2
canvas.alpha_composite(fitted, (left, top))
return canvas
def _write_favicon_set(body: bytes, content_type: str = "", filename: str = "") -> Dict[str, Any]:
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("favicon source must be a non-empty image up to 2 MiB")
ext = _detect_logo_extension(body, content_type, filename)
digest = hashlib.sha256(body).hexdigest()[:16]
target_dir = WEBAPP_FAVICON_DIR / digest
target_dir.mkdir(parents=True, exist_ok=True)
if ext == ".svg":
safe_name = "favicon.svg"
(target_dir / safe_name).write_bytes(body)
return {
"favicon_url": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}",
"variants": {"svg": f"{WEBAPP_FAVICON_PATH}/{digest}/{safe_name}"},
}
try:
with Image.open(io.BytesIO(body)) as image:
image.seek(0)
source = ImageOps.exif_transpose(image).convert("RGBA")
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise ValueError("favicon source must be a raster image") from exc
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
raise ValueError("favicon source dimensions are not supported")
variants: Dict[str, str] = {}
png_icons: Dict[int, Image.Image] = {}
for size in WEBAPP_FAVICON_SIZES:
icon = _image_to_square_icon(source, size)
png_icons[size] = icon
filename = f"icon-{size}.png"
icon.save(target_dir / filename, format="PNG", optimize=True)
variants[f"{size}"] = f"{WEBAPP_FAVICON_PATH}/{digest}/{filename}"
png_icons[180].save(target_dir / "apple-touch-icon.png", format="PNG", optimize=True)
variants["apple_touch"] = f"{WEBAPP_FAVICON_PATH}/{digest}/apple-touch-icon.png"
png_icons[32].save(
target_dir / "favicon.ico",
format="ICO",
sizes=[(16, 16), (32, 32), (48, 48)],
)
variants["ico"] = f"{WEBAPP_FAVICON_PATH}/{digest}/favicon.ico"
return {
"favicon_url": variants["180"],
"variants": variants,
}
async def _read_uploaded_logo_file(request: web.Request) -> tuple[bytes, str, str]:
reader = await request.multipart()
async for part in reader:
if part.name != "file":
continue
body = bytearray()
while True:
chunk = await part.read_chunk(size=64 * 1024)
if not chunk:
break
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), part.headers.get("Content-Type", ""), part.filename or ""
raise ValueError("file field is required")
async def _hostname_resolves_to_public_address(hostname: str) -> bool:
if not hostname:
return False
try:
ip_obj = ipaddress.ip_address(hostname)
return not (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
)
except ValueError:
pass
loop = asyncio.get_running_loop()
try:
resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except Exception:
return False
found_public_ip = False
for entry in resolved:
sockaddr = entry[4]
candidate = sockaddr[0] if sockaddr else ""
try:
ip_obj = ipaddress.ip_address(candidate)
except ValueError:
continue
if (
ip_obj.is_private
or ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_unspecified
or ip_obj.is_reserved
):
return False
found_public_ip = True
return found_public_ip
async def _fetch_logo_from_url(url: str) -> tuple[bytes, str, str]:
parsed = urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError("only https image URLs are supported")
if not await _hostname_resolves_to_public_address(parsed.hostname):
raise ValueError("logo URL must resolve to a public address")
timeout = ClientTimeout(total=5)
async with ClientSession(timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}) as session:
async with session.get(
url,
allow_redirects=False,
headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"},
) as response:
if response.status != 200:
raise ValueError(f"logo URL returned HTTP {response.status}")
content_type = (
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
)
if content_type and not content_type.startswith("image/"):
raise ValueError("logo URL returned non-image content")
body = bytearray()
async for chunk in response.content.iter_chunked(64 * 1024):
body.extend(chunk)
if len(body) > WEBAPP_LOGO_MAX_BYTES:
raise ValueError("logo must be up to 2 MiB")
return bytes(body), content_type, Path(parsed.path).name
async def admin_appearance_logo_upload_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
logo_url = _write_uploaded_logo(body, detected_content_type, filename)
try:
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
except ValueError:
favicon_payload = {}
except ValueError as exc:
return _error(400, "invalid_logo", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp logo")
return _error(500, "write_failed", str(exc))
persisted = await _persist_appearance_upload(
request,
{
"WEBAPP_LOGO_URL": logo_url,
**(
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
if favicon_payload.get("favicon_url")
else {}
),
},
actor_id,
)
return _ok({"logo_url": logo_url, "persisted": persisted, **favicon_payload})
async def admin_appearance_favicon_upload_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
content_type = (request.headers.get("Content-Type") or "").lower()
try:
if content_type.startswith("multipart/form-data"):
body, detected_content_type, filename = await _read_uploaded_logo_file(request)
else:
payload = await _read_json(request)
source_url = str(payload.get("url") or "").strip()
if not source_url:
return _error(400, "invalid_payload", "url or file is required")
body, detected_content_type, filename = await _fetch_logo_from_url(source_url)
favicon_payload = _write_favicon_set(body, detected_content_type, filename)
except ValueError as exc:
return _error(400, "invalid_favicon", str(exc))
except OSError as exc:
logger.exception("Failed to save uploaded webapp favicon")
return _error(500, "write_failed", str(exc))
persisted = await _persist_appearance_upload(
request,
{
"WEBAPP_FAVICON_URL": favicon_payload["favicon_url"],
"WEBAPP_FAVICON_USE_CUSTOM": True,
},
actor_id,
)
return _ok({"persisted": persisted, **favicon_payload})
async def admin_themes_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
catalog = resolved_webapp_themes_catalog(
primary_accent=primary,
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
return _ok(
{
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(catalog),
}
)
async def admin_themes_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
previous_config = resolved_webapp_themes_catalog(
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = WebappThemesConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_webapp_themes_config", str(exc))
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
config = _bump_theme_asset_versions(config, previous_config)
try:
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
except OSError as exc:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
await refresh_webapp_runtime_after_settings_change(request, updates={}, deletes=[])
return _ok(
{
"exists": True,
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(config),
}
)
@@ -0,0 +1,146 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.middlewares.i18n import JsonI18n, locale_language_options, resolve_locale_key
from bot.services.locale_override_service import (
LOCALE_OVERRIDES_PATH,
audience_for_locale_key,
group_id_for_locale_key,
locale_group_catalog,
load_locale_overrides,
update_locale_overrides,
)
def _locale_languages(
i18n: JsonI18n,
overrides: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
base_languages = set((i18n.base_locales_data or {}).keys())
override_languages = {str(entry.get("lang") or "") for entry in overrides or []}
override_languages.update((i18n.locale_overrides or {}).keys())
return locale_language_options(
base_languages | override_languages,
base_languages=base_languages,
)
def _locale_override_meta_map(overrides: List[Dict[str, Any]]) -> Dict[Tuple[str, str], Dict]:
result: Dict[Tuple[str, str], Dict] = {}
for entry in overrides:
lang = str(entry.get("lang") or "")
raw_key = str(entry.get("key") or "")
key = resolve_locale_key(raw_key)
if lang and key:
if raw_key != key and (lang, key) in result:
continue
result[(lang, key)] = entry
return result
def _admin_translations_payload(
i18n: JsonI18n,
overrides: List[Dict[str, Any]],
) -> Dict[str, Any]:
base_data = i18n.base_locales_data or i18n.locales_data or {}
effective_data = i18n.locales_data or {}
override_meta = _locale_override_meta_map(overrides)
language_items = _locale_languages(i18n, overrides)
languages = [item["code"] for item in language_items]
all_keys = sorted(
{key for messages in base_data.values() for key in messages.keys()}
| {key for _, key in override_meta.keys()}
)
groups_by_id = {
group["id"]: {
**group,
"items": [],
}
for group in locale_group_catalog()
}
for key in all_keys:
values: Dict[str, Dict[str, Any]] = {}
for lang in languages:
meta = override_meta.get((lang, key))
fallback_base = base_data.get(i18n.default_lang, {}).get(key, "")
values[lang] = {
"base": base_data.get(lang, {}).get(key, ""),
"fallback": fallback_base,
"effective": effective_data.get(lang, {}).get(key, ""),
"override": meta.get("value") if meta else "",
"overridden": bool(meta),
"updated_at": meta.get("updated_at") if meta else None,
"updated_by": meta.get("updated_by") if meta else None,
}
group_id = group_id_for_locale_key(key)
groups_by_id.setdefault(
group_id,
{"id": group_id, "title": group_id, "description": "", "items": []},
)
groups_by_id[group_id]["items"].append(
{
"key": key,
"audience": audience_for_locale_key(key),
"values": values,
}
)
groups = [group for group in groups_by_id.values() if group["items"]]
return {
"languages": language_items,
"groups": groups,
"path": str(LOCALE_OVERRIDES_PATH),
"override_count": len(overrides),
}
async def admin_translations_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
await load_locale_overrides(i18n, async_session_factory)
async with async_session_factory() as session:
overrides = await locale_overrides_dal.get_overrides_with_meta(session)
return _ok(_admin_translations_payload(i18n, overrides))
async def admin_translations_patch_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
i18n: Optional[JsonI18n] = request.app.get("i18n")
if i18n is None:
return _error(503, "i18n_unavailable")
async_session_factory: sessionmaker = request.app["async_session_factory"]
payload = await _read_json(request)
updates = payload.get("updates") or {}
deletes = payload.get("deletes") or []
if not isinstance(updates, dict):
return _error(400, "invalid_updates")
if not isinstance(deletes, list):
return _error(400, "invalid_deletes")
result = await update_locale_overrides(
i18n,
async_session_factory,
updates=updates,
deletes=deletes,
actor_id=actor_id,
)
if not result.get("ok"):
return web.json_response(
{"ok": False, "error": "validation_failed", "errors": result.get("errors", {})},
status=400,
)
return _ok(
{
"applied": result.get("applied", 0),
"reverted": result.get("reverted", 0),
"file_written": result.get("file_written", False),
}
)
@@ -1,9 +1,34 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from .auth import _require_admin_user_id
from .common import (
_build_admin_webapp_referral_link,
_error,
_ok,
_panel_user_connection_activity,
_premium_traffic_list_payload,
_read_json,
_serialize_payment,
_serialize_subscription,
_serialize_user,
)
import hashlib
from html import escape as html_escape
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.orm import aliased
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from bot.infra.redis import cache_delete_pattern, redis_key
from bot.utils.ttl_cache import AsyncTTLCache
_ADMIN_USERS_LIST_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
async def admin_users_list_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
page = max(0, int(request.query.get("page", 0) or 0))
@@ -14,6 +39,79 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
premium_traffic = (request.query.get("premium_traffic") or "all").lower()
sort_value = (request.query.get("sort") or "registered_desc").lower()
payload = await _load_admin_users_list_payload(
settings,
async_session_factory,
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
)
return _ok(payload)
async def _load_admin_users_list_payload(
settings: Settings,
async_session_factory: sessionmaker,
*,
page: int,
page_size: int,
query: str,
filter_value: str,
panel_status: str,
premium_traffic: str,
sort_value: str,
) -> Dict[str, Any]:
cache = _admin_users_list_cache(settings)
cache_key = _admin_users_list_cache_key(
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
)
if cache is None:
return await _load_admin_users_list_payload_uncached(
async_session_factory,
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
)
return await cache.get_or_load(
cache_key,
lambda: _load_admin_users_list_payload_uncached(
async_session_factory,
page=page,
page_size=page_size,
query=query,
filter_value=filter_value,
panel_status=panel_status,
premium_traffic=premium_traffic,
sort_value=sort_value,
),
)
async def _load_admin_users_list_payload_uncached(
async_session_factory: sessionmaker,
*,
page: int,
page_size: int,
query: str,
filter_value: str,
panel_status: str,
premium_traffic: str,
sort_value: str,
) -> Dict[str, Any]:
async with async_session_factory() as session:
users, total = await _filter_and_sort_users(
session,
@@ -31,12 +129,15 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
serialized = []
for user in users:
payload = _serialize_user(user)
status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None}
payload["panel_status"] = status_payload.get("status")
payload["subscription_expires_at"] = status_payload.get("end_date")
if status_payload.get("status") == "expired" and status_payload.get("end_date"):
payload["panel_status_expired_at"] = status_payload["end_date"]
payload["avatar_url"] = (
@@ -45,16 +146,65 @@ async def admin_users_list_route(request: web.Request) -> web.Response:
else None
)
payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id))
payment_summary = payment_summaries.get(user.user_id) or {}
payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0)
payload["payments_count"] = int(payment_summary.get("count") or 0)
payload["payments_currency"] = payment_summary.get("currency")
payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0)
serialized.append(payload)
return _ok(
{
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
)
return {
"users": serialized,
"page": page,
"page_size": page_size,
"total": total,
}
def _admin_users_list_cache(settings: Settings) -> Optional[AsyncTTLCache]:
ttl_seconds = int(getattr(settings, "ADMIN_USERS_LIST_CACHE_TTL_SECONDS", 3) or 0)
if ttl_seconds <= 0:
return None
cache_key = (id(settings), ttl_seconds)
cache = _ADMIN_USERS_LIST_CACHES.get(cache_key)
if cache is None:
cache = AsyncTTLCache(
ttl_seconds=ttl_seconds,
settings=settings,
namespace="admin:users_list",
)
_ADMIN_USERS_LIST_CACHES[cache_key] = cache
return cache
def _admin_users_list_cache_key(**params: Any) -> str:
raw = json.dumps(params, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _invalidate_admin_users_list_cache(settings: Settings) -> None:
for settings_id, _ttl in tuple(_ADMIN_USERS_LIST_CACHES):
if settings_id == id(settings):
_ADMIN_USERS_LIST_CACHES[(settings_id, _ttl)].invalidate()
try:
await cache_delete_pattern(settings, redis_key(settings, "cache", "admin:users_list", "*"))
except Exception:
return
async def _invalidate_after_admin_user_mutation(
settings: Settings,
user_id: Optional[int] = None,
*,
include_devices: bool = True,
) -> None:
await _invalidate_admin_users_list_cache(settings)
if user_id is not None:
await invalidate_webapp_user_caches(
settings,
user_id,
include_devices=include_devices,
)
async def _bulk_user_statuses(
@@ -115,6 +265,17 @@ async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) ->
return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows}
def _serialize_admin_user_with_avatar(user: User, avatar_keys: Dict[int, str]) -> Dict[str, Any]:
payload = _serialize_user(user)
user_id = int(user.user_id)
payload["avatar_url"] = (
f"/api/admin/users/{user_id}/avatar?v={avatar_keys[user_id]}"
if user_id in avatar_keys
else None
)
return payload
async def admin_user_avatar_route(request: web.Request) -> web.Response:
"""Serve the cached Telegram avatar for any user (admin-only).
@@ -213,6 +374,88 @@ async def _bulk_active_subscriptions_for_users(
return out
def _user_payment_summary_sq():
return (
select(
Payment.user_id.label("user_id"),
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"),
sa_func.count(Payment.payment_id).label("payments_count"),
)
.where(Payment.status == "succeeded")
.group_by(Payment.user_id)
.subquery(name="user_payment_summary")
)
def _user_referral_count_sq():
referred_user = aliased(User)
return (
select(
referred_user.referred_by_id.label("user_id"),
sa_func.count(referred_user.user_id).label("invited_users_count"),
)
.where(referred_user.referred_by_id.is_not(None))
.group_by(referred_user.referred_by_id)
.subquery(name="user_referral_count")
)
def _user_subscription_expiry_sq():
return (
select(
Subscription.user_id.label("user_id"),
sa_func.max(Subscription.end_date).label("subscription_expires_at"),
)
.group_by(Subscription.user_id)
.subquery(name="user_subscription_expiry")
)
async def _bulk_user_payment_summaries(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, Dict[str, Any]]:
if not user_ids:
return {}
stmt = (
select(
Payment.user_id,
sa_func.coalesce(sa_func.sum(Payment.amount), 0.0),
sa_func.count(Payment.payment_id),
sa_func.max(Payment.currency),
)
.where(Payment.user_id.in_(user_ids), Payment.status == "succeeded")
.group_by(Payment.user_id)
)
rows = (await session.execute(stmt)).all()
return {
int(user_id): {
"total_amount": float(total_amount or 0),
"count": int(payments_count or 0),
"currency": currency,
}
for user_id, total_amount, payments_count, currency in rows
}
async def _bulk_user_referral_counts(
session: AsyncSession,
user_ids: List[int],
) -> Dict[int, int]:
if not user_ids:
return {}
referred_user = aliased(User)
stmt = (
select(referred_user.referred_by_id, sa_func.count(referred_user.user_id))
.where(referred_user.referred_by_id.in_(user_ids))
.group_by(referred_user.referred_by_id)
)
rows = (await session.execute(stmt)).all()
return {int(user_id): int(count or 0) for user_id, count in rows}
async def _filter_and_sort_users(
session: AsyncSession,
*,
@@ -241,6 +484,13 @@ async def _filter_and_sort_users(
ratio_expr = None
plim_expr = None
pu_expr = None
payment_summary_sq = None
payment_total_expr = None
payment_count_expr = None
referral_count_sq = None
referral_count_expr = None
subscription_expiry_sq = None
subscription_expires_expr = None
if needs_premium_sq:
sq = _ranked_active_subscriptions_sq(now)
@@ -261,6 +511,42 @@ async def _filter_and_sort_users(
else_=cast(pu_expr, Float) / cast(plim_expr, Float),
)
if sort_key in {
"payments_total_asc",
"payments_total_desc",
"payments_count_asc",
"payments_count_desc",
}:
payment_summary_sq = _user_payment_summary_sq()
stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
payment_summary_sq,
User.user_id == payment_summary_sq.c.user_id,
)
payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0)
payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0)
if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}:
referral_count_sq = _user_referral_count_sq()
stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id)
count_stmt = count_stmt.outerjoin(
referral_count_sq,
User.user_id == referral_count_sq.c.user_id,
)
referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0)
if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}:
subscription_expiry_sq = _user_subscription_expiry_sq()
stmt = stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
count_stmt = count_stmt.outerjoin(
subscription_expiry_sq,
User.user_id == subscription_expiry_sq.c.user_id,
)
subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at
search_cond = _user_search_condition(query)
if search_cond is not None:
stmt = stmt.where(search_cond)
@@ -359,6 +645,22 @@ async def _filter_and_sort_users(
stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc())
elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc":
stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc())
elif payment_total_expr is not None and sort_key == "payments_total_asc":
stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc())
elif payment_total_expr is not None and sort_key == "payments_total_desc":
stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc())
elif payment_count_expr is not None and sort_key == "payments_count_asc":
stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc())
elif payment_count_expr is not None and sort_key == "payments_count_desc":
stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc())
elif referral_count_expr is not None and sort_key == "invited_users_count_asc":
stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc())
elif referral_count_expr is not None and sort_key == "invited_users_count_desc":
stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc":
stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc())
elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc":
stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc())
else:
order = sort_map.get(sort_key, sort_map["registered_desc"])
if isinstance(order, tuple):
@@ -387,9 +689,34 @@ def _user_panel_status_condition(panel_status: str):
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
)
elif status == "expired":
status_cond = or_(
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
now = datetime.now(timezone.utc)
expired_subs = aliased(Subscription)
active_subs = aliased(Subscription)
expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, ""))
expired_blank_status = or_(
expired_subs.status_from_panel.is_(None),
expired_subs.status_from_panel == "",
)
expired_condition = or_(
expired_status == "expired",
expired_blank_status & expired_subs.is_active.is_(False),
expired_subs.end_date <= now,
)
expired_exists = (
select(expired_subs.subscription_id)
.where(expired_subs.user_id == User.user_id, expired_condition)
.exists()
)
active_exists = (
select(active_subs.subscription_id)
.where(
active_subs.user_id == User.user_id,
active_subs.is_active.is_(True),
active_subs.end_date > now,
)
.exists()
)
return and_(expired_exists, ~active_exists)
else:
status_cond = normalized_status == "limited"
@@ -419,6 +746,24 @@ def _user_search_condition(query: str):
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:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
@@ -438,6 +783,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
.limit(20)
)
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)
recent_payments_stmt = (
select(Payment)
@@ -447,7 +801,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
)
recent_payments = (await session.execute(recent_payments_stmt)).scalars().all()
log_count = await message_log_dal.count_user_message_logs(session, target_id)
avatar_keys = await _bulk_user_avatar_keys(session, [target_id])
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
avatar_user_ids = [target_id]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
# Referral links — both the bot deep-link and the webapp deep-link.
referral_code: Optional[str] = None
@@ -478,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
# imports into their VPN client. May be missing if the user has never
# been provisioned on the panel.
subscription_url: Optional[str] = None
panel_uuid = getattr(user, "panel_user_uuid", None)
last_vpn_connected_at: Optional[str] = None
vpn_connection_status = "unknown"
panel_uuid = getattr(user, "panel_user_uuid", None) or getattr(
active_sub,
"panel_user_uuid",
None,
)
if panel_uuid:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
@@ -487,45 +852,94 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
if panel_data:
subscription_url = panel_data.get("subscriptionUrl") or None
vpn_activity = _panel_user_connection_activity(panel_data)
vpn_connection_status = str(vpn_activity.get("status") or "unknown")
last_vpn_connected_at = vpn_activity.get("last_connected_at")
except Exception as exc_panel: # pragma: no cover
logger.warning(
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
"Failed to fetch panel details for user %s (uuid=%s): %s",
target_id,
panel_uuid,
exc_panel,
)
serialized_user = _serialize_user(user)
serialized_user["avatar_url"] = (
f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}"
if target_id in avatar_keys
else None
serialized_user = _serialize_admin_user_with_avatar(user, avatar_keys)
serialized_inviter = (
_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(
{
"user": serialized_user,
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
"trial": trial_payload,
"total_paid": float(total_paid),
"recent_payments": [_serialize_payment(p) for p in recent_payments],
"log_count": int(log_count or 0),
"subscription_url": subscription_url,
"last_vpn_connected_at": last_vpn_connected_at,
"vpn_connection_status": vpn_connection_status,
"referral": {
"code": referral_code,
"bot_link": referral_bot_link,
"webapp_link": referral_webapp_link,
"inviter": serialized_inviter,
"invitees_total": int(invitees_total or 0),
},
}
)
async def admin_user_referrals_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
page = max(0, int(request.query.get("page", 0) or 0))
page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25)))
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found", "User not found")
inviter = await user_dal.get_referrer_for_user(session, user)
invitees_total = await user_dal.count_users_referred_by(session, target_id)
invitees = await user_dal.get_users_referred_by(
session,
target_id,
limit=page_size,
offset=page * page_size,
)
avatar_user_ids = [target_id, *(int(u.user_id) for u in invitees)]
if inviter is not None:
avatar_user_ids.append(int(inviter.user_id))
avatar_keys = await _bulk_user_avatar_keys(session, avatar_user_ids)
return _ok(
{
"user": _serialize_admin_user_with_avatar(user, avatar_keys),
"inviter": _serialize_admin_user_with_avatar(inviter, avatar_keys)
if inviter is not None
else None,
"invitees": [
_serialize_admin_user_with_avatar(invitee, avatar_keys) for invitee in invitees
],
"total": int(invitees_total or 0),
"page": page,
"page_size": page_size,
}
)
async def admin_user_ban_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
payload = await _read_json(request)
desired = bool(payload.get("banned"))
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
@@ -534,6 +948,7 @@ async def admin_user_ban_route(request: web.Request) -> web.Response:
user.is_banned = bool(desired)
await session.commit()
await session.refresh(user)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({"user": _serialize_user(user)})
@@ -624,36 +1039,180 @@ async def admin_user_message_preview_route(request: web.Request) -> web.Response
return _ok({})
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
def _admin_user_display_name_for_message(user: User) -> str:
full = " ".join(
part
for part in [getattr(user, "first_name", None), getattr(user, "last_name", None)]
if part
).strip()
return (
full
or (f"@{user.username}" if getattr(user, "username", None) else None)
or getattr(user, "email", None)
or f"User #{user.user_id}"
)
async def admin_user_telegram_profile_link_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
admin_telegram_id = request.get("admin_telegram_id")
if not admin_telegram_id:
return _error(403, "admin_telegram_unavailable")
queue_manager = get_queue_manager()
if not queue_manager:
return _error(503, "queue_unavailable")
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
target_user = await user_dal.get_user_by_id(session, target_id)
if not target_user:
return _error(404, "not_found")
if not target_user.telegram_id:
return _error(404, "no_telegram_account")
admin_user = await user_dal.get_user_by_id(session, actor_id)
lang = (
getattr(admin_user, "language_code", None)
or getattr(settings, "DEFAULT_LANGUAGE", None)
or "ru"
)
await message_log_dal.create_message_log(
session,
{
"user_id": actor_id,
"event_type": "admin_profile_link_webapp",
"content": f"Requested Telegram profile link for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
i18n_instance = request.app.get("i18n")
translate = (
(lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs))
if i18n_instance is not None
else (lambda key, **kwargs: key.format(**kwargs) if kwargs else key)
)
target_name = _admin_user_display_name_for_message(target_user)
telegram_id = int(target_user.telegram_id)
profile_url = f"tg://user?id={telegram_id}"
message_text = translate(
"admin_user_profile_link_message",
name=html_escape(target_name),
user_id=target_user.user_id,
telegram_id=telegram_id,
)
if message_text == "admin_user_profile_link_message":
message_text = (
f"Профиль пользователя: <b>{html_escape(target_name)}</b>\n"
f"User ID: <code>{target_user.user_id}</code>\n"
f"Telegram ID: <code>{telegram_id}</code>\n\n"
"Нажмите кнопку ниже, чтобы открыть профиль в Telegram."
)
button_text = translate("user_card_open_profile_button")
if button_text == "user_card_open_profile_button":
button_text = "👤 Открыть профиль"
markup = InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=button_text, url=profile_url)]]
)
try:
await send_message_via_queue(
queue_manager,
int(admin_telegram_id),
MessageContent(content_type="text", text=message_text),
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=markup,
)
except Exception as exc:
logger.warning("Admin profile link message enqueue failed: %s", exc)
return _error(502, "send_failed", str(exc))
return _ok({"queued": True})
async def admin_user_delete_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
settings: Settings = request.app["settings"]
panel_service = request.app.get("panel_service")
if panel_service is None:
subscription_service = request.app.get("subscription_service")
panel_service = getattr(subscription_service, "panel_service", None)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, target_id)
if not user:
return _error(404, "not_found")
panel_user_uuids = await user_dal.get_panel_user_uuids_for_user(
session,
target_id,
user=user,
)
if panel_user_uuids and panel_service is None:
await session.rollback()
return _error(503, "panel_service_unavailable")
for panel_uuid in panel_user_uuids:
try:
panel_deleted = await panel_service.delete_user_from_panel(
panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Admin webapp failed to delete panel user %s for user %s: %s",
panel_uuid,
target_id,
exc,
)
await session.rollback()
return _error(502, "panel_delete_failed", str(exc))
if not panel_deleted:
await session.rollback()
return _error(
502,
"panel_delete_failed",
f"Failed to delete panel user {panel_uuid}",
)
ok = await user_dal.delete_user_and_relations(session, target_id)
if not ok:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id if actor_id != target_id else None,
"event_type": "admin_delete_user_webapp",
"content": f"Deleted user_id={target_id}",
"content": (
f"Deleted user_id={target_id}; "
f"panel_uuids={','.join(panel_user_uuids) or 'none'}"
),
"is_admin_event": True,
},
)
await session.commit()
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({})
async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
actor_id = _require_admin_user_id(request)
target_id = int(request.match_info["user_id"])
panel_service = request.app.get("panel_service")
subscription_service = request.app.get("subscription_service")
if panel_service is None or subscription_service is None:
return _error(503, "service_unavailable")
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -661,21 +1220,23 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
if not user:
return _error(404, "not_found")
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
if active:
await session.delete(active)
reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id)
if reset_at is None:
await session.rollback()
return _error(404, "not_found")
await message_log_dal.create_message_log(
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": actor_id,
"event_type": "admin_reset_trial_webapp",
"content": f"Reset trial for user_id={target_id}",
"content": f"Reset trial eligibility for user_id={target_id}",
"is_admin_event": True,
"target_user_id": target_id,
},
)
await session.commit()
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok({})
@@ -683,6 +1244,7 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
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)
subscription_service = request.app.get("subscription_service")
@@ -734,13 +1296,15 @@ async def admin_user_premium_override_route(request: web.Request) -> web.Respons
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_regular_traffic_override_route(request: web.Request) -> web.Response:
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
"""Main (regular) traffic: native unlimited panel limit + admin bonus GB."""
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"))
@@ -791,6 +1355,79 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
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_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)})
@@ -805,6 +1442,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
"""
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)
kind = str(payload.get("kind") or "regular").strip().lower()
@@ -865,6 +1503,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
@@ -880,6 +1519,7 @@ async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
async def admin_user_extend_route(request: web.Request) -> web.Response:
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)
try:
days = int(payload.get("days") or 0)
@@ -887,6 +1527,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
return _error(400, "invalid_days")
if days <= 0:
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")
if subscription_service is None:
@@ -899,6 +1541,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
target_id,
days,
"admin_extend_subscription_webapp",
extend_hwid_devices=extend_hwid_devices,
)
if not new_end:
await session.rollback()
@@ -909,7 +1552,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
{
"user_id": actor_id,
"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,
"target_user_id": target_id,
},
@@ -918,6 +1564,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
await _invalidate_after_admin_user_mutation(settings, target_id)
return _ok(
{
"subscription": _serialize_subscription(refreshed) if refreshed else None,
@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from bot.app.web.webapp.cache_helpers import (
invalidate_all_webapp_user_payloads,
reset_subscription_guides_cache,
reset_webapp_settings_cache,
)
WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
{
"WEBAPP_TITLE",
"WEBAPP_LOGO_URL",
"WEBAPP_FAVICON_URL",
"WEBAPP_FAVICON_USE_CUSTOM",
"WEBAPP_LOGO_FAVICON_URL",
}
)
WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS = frozenset(
{
"MY_DEVICES_SECTION_ENABLED",
"USER_HWID_DEVICE_LIMIT",
"USER_TRAFFIC_LIMIT_GB",
"USER_TRAFFIC_STRATEGY",
}
)
def changed_setting_keys(
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
) -> set[str]:
keys = {str(key) for key in (updates or {}).keys()}
keys.update(str(key) for key in (deletes or []) if key is not None)
return keys
async def refresh_webapp_runtime_after_settings_change(
request: Any,
*,
updates: Mapping[str, Any] | None = None,
deletes: Sequence[Any] | None = None,
include_user_payloads: bool = True,
) -> None:
settings = request.app["settings"]
keys = changed_setting_keys(updates, deletes)
reset_webapp_settings_cache(request.app)
reset_subscription_guides_cache(request.app)
if include_user_payloads:
await invalidate_all_webapp_user_payloads(
settings,
include_devices=bool(keys & WEBAPP_DEVICE_PAYLOAD_SETTING_KEYS),
)
if keys & WEBAPP_APPEARANCE_SETTING_KEYS:
request.app["webapp_logo_cache"] = None
from bot.app.web.admin_api_impl.themes import prune_unused_appearance_assets
prune_unused_appearance_assets(settings)
File diff suppressed because it is too large Load Diff
@@ -11,9 +11,12 @@ from bot.app.web.webapp import (
billing as _billing,
common as _common,
devices as _devices,
guides as _guides,
payloads as _payloads,
routes as _routes,
serializers as _serializers,
support as _support,
telegram_notifications as _telegram_notifications,
)
_MODULES = (
@@ -23,9 +26,12 @@ _MODULES = (
_assets,
_auth,
_account,
_telegram_notifications,
_serializers,
_billing,
_devices,
_guides,
_support,
_routes,
_application,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,230 @@
<!doctype html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>__PAGE_TITLE__</title>
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
--accent: #14b86f;
--accent-contrast: #03120b;
--bg: #0b1017;
--panel-3: #344052;
--border: #2d3847;
--text: #f7fafc;
--muted: #aeb8c5;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
body {
min-height: 100dvh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
main {
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
}
.actions {
display: grid;
gap: 10px;
margin-top: 4px;
}
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: var(--accent);
color: var(--accent-contrast);
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: var(--border);
background: transparent;
color: var(--text);
}
.button[aria-disabled="true"] {
pointer-events: none;
background: var(--panel-3);
color: var(--muted);
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="status"></p>
<div class="actions">
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
<button id="close-button" class="button secondary" type="button" hidden></button>
</div>
</main>
<script nonce="__NONCE__">
(() => {
const messages = __MESSAGES_JSON__;
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const closeButton = document.getElementById("close-button");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
let attempted = false;
let pageLeft = false;
let state = "opening";
let closeAttemptTimer = null;
const CLOSE_ATTEMPT_DELAY_MS = 2500;
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function text(key, fallback) {
const value = messages && messages[key];
return typeof value === "string" && value ? value : fallback;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function render(nextState) {
state = nextState;
if (nextState === "unavailable") {
titleEl.textContent = text("unavailableTitle", "App link unavailable");
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
openLink.textContent = text("button", "Open app");
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
closeButton.hidden = true;
return;
}
if (nextState === "done") {
titleEl.textContent = text("doneTitle", "Settings added");
statusEl.textContent = text("doneHint", "You can close this window.");
openLink.textContent = text("retryButton", "Open again");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.textContent = text("closeButton", "Close window");
closeButton.hidden = false;
return;
}
titleEl.textContent = text("title", "Opening app");
statusEl.textContent =
nextState === "manual"
? text("manualHint", "If the app did not open automatically, tap the button below.")
: text("hint", "Opening the app on this device...");
openLink.textContent = text("button", "Open app");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.hidden = true;
}
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(() => {
if (pageLeft || document.hidden) tryCloseWindow();
}, CLOSE_ATTEMPT_DELAY_MS);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
window.setTimeout(markDone, 900);
}
function openTarget() {
if (isUnsafe) return;
attempted = true;
pageLeft = false;
render("opening");
window.location.href = target;
window.setTimeout(() => {
if (state === "opening" && !pageLeft) render("manual");
}, 1600);
}
if (isUnsafe) {
render("unavailable");
return;
}
openLink.addEventListener("click", (event) => {
event.preventDefault();
openTarget();
});
closeButton.addEventListener("click", () => {
tryCloseWindow();
render("done");
});
window.addEventListener("pagehide", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<link id="app-favicon" rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<link
id="app-apple-touch-icon"
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="180x180"
href="/apple-touch-icon-precomposed.png"
/>
<title>Subscription</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
.app-boot-fallback {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background: #03070b;
}
.app-boot-fallback__spinner {
width: 28px;
height: 28px;
border: 2px solid rgba(242, 247, 244, 0.18);
border-top-color: #00fe7a;
border-radius: 999px;
animation: appBootSpin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.app-boot-fallback__spinner {
animation: none;
}
}
@keyframes appBootSpin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<main id="app">
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
</div>
</main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"key": "ascii",
"names": {
"ru": "ASCII",
"en": "ASCII"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 7,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
}
}
@@ -0,0 +1,27 @@
{
"key": "dark",
"names": {
"ru": "Темная",
"en": "Dark"
},
"enabled": true,
"default": true,
"use_primary_accent": true,
"use_in_admin": true,
"assets_version": 1,
"tokens": {
"color_scheme": "dark",
"bg": "#03070b",
"panel": "#111820",
"panel_2": "#0b1118",
"panel_3": "#17212b",
"border": "rgba(255, 255, 255, 0.12)",
"border_strong": "rgba(255, 255, 255, 0.2)",
"text": "#f2f7f4",
"muted": "#a9b4b0",
"dim": "#68736f",
"danger": "#ff6b6b",
"blue": "#2d9cff",
"radius": "8px"
}
}
+280
View File
@@ -0,0 +1,280 @@
.theme-key-light {
color-scheme: light;
--accent: #047857;
--bg: #f7f8fb;
--panel: #ffffff;
--panel-2: #f1f5f9;
--panel-3: #e8edf3;
--border: rgba(15, 23, 42, 0.11);
--border-strong: rgba(15, 23, 42, 0.2);
--text: #0f172a;
--muted: #475569;
--dim: #64748b;
--danger: #dc2626;
--danger-text: #b91c1c;
--danger-soft: color-mix(in srgb, var(--danger) 9%, var(--panel));
--danger-border: color-mix(in srgb, var(--danger) 34%, var(--border));
--success: #16a34a;
--success-text: #166534;
--success-soft: color-mix(in srgb, var(--success) 10%, var(--panel));
--success-border: color-mix(in srgb, var(--success) 34%, var(--border));
--warning: #d97706;
--warning-text: #92400e;
--warning-soft: color-mix(in srgb, var(--warning) 11%, var(--panel));
--warning-border: color-mix(in srgb, var(--warning) 34%, var(--border));
--info: #2563eb;
--info-text: #1d4ed8;
--info-soft: color-mix(in srgb, var(--info) 9%, var(--panel));
--info-border: color-mix(in srgb, var(--info) 30%, var(--border));
--blue: #2563eb;
--radius: 8px;
--accent-contrast: #ffffff;
--surface-sheen: rgba(15, 23, 42, 0.035);
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035);
--surface-subtle: rgba(15, 23, 42, 0.025);
--surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88);
--rail-bg: rgba(255, 255, 255, 0.72);
--shadow-soft: 0 6px 18px rgba(15, 23, 42, 0.06);
--shadow-strong: 0 18px 44px rgba(15, 23, 42, 0.12);
--shadow-popover: 0 14px 28px rgba(15, 23, 42, 0.12);
--inset-highlight: rgba(255, 255, 255, 0.75);
--admin-bg: #f7f8fb;
--admin-surface: #ffffff;
--admin-surface-2: #f1f5f9;
--admin-elev: #e8edf3;
--admin-border: rgba(15, 23, 42, 0.1);
--admin-border-strong: rgba(15, 23, 42, 0.18);
--admin-text: #0f172a;
--admin-muted: #64748b;
--admin-dim: #64748b;
--admin-chart-stroke: #065f46;
--admin-chart-fill: rgba(6, 95, 70, 0.22);
}
.theme-key-light .ui-spinner,
.theme-key-light .brand-mark-spinner {
color: inherit;
}
.theme-key-light .telegram-button-spinner {
border-color: rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
}
.theme-key-light .btn-primary,
.theme-key-light .admin-btn.admin-btn-primary,
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary {
background: color-mix(in srgb, var(--accent) 50%, #000000);
border-color: color-mix(in srgb, var(--accent) 42%, #000000);
color: #ffffff;
}
.theme-key-light .btn-primary:hover:not(:disabled),
.theme-key-light .admin-btn.admin-btn-primary:hover:not(:disabled),
.theme-key-light .admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 52%, #000000);
}
.theme-key-light.app-shell {
background: var(--bg) !important;
}
.theme-key-light .phone-screen {
background: var(--bg);
}
/* Flatten Settings rows: no gradient sheen, no inset highlight that reads as a 3D bevel */
.theme-key-light .settings-row {
background: var(--panel);
box-shadow: none;
}
.theme-key-light .settings-row-linked {
background: var(--success-soft);
}
/* Avatar/profile card: bigger lift, but rows below have an opaque background and
stack above, so the shadow stays visually under them instead of bleeding through. */
.theme-key-light .settings-profile {
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.10),
inset 0 1px 0 var(--inset-highlight);
}
.theme-key-light .settings-links-block {
position: relative;
z-index: 1;
}
/* New user-facing activation surfaces */
.theme-key-light .trial-offer-card,
.theme-key-light .trial-activation-card,
.theme-key-light .activation-success-dialog {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.theme-key-light .trial-card-head > svg,
.theme-key-light .dialog-title-icon {
color: color-mix(in srgb, var(--accent) 54%, #000000);
}
.theme-key-light .trial-card-facts span,
.theme-key-light .trial-activation-facts div {
border-color: rgba(15, 23, 42, 0.12);
background: rgba(15, 23, 42, 0.025);
}
/* Slightly stronger axis/grid contrast for the revenue chart on a light surface */
.theme-key-light .admin-revenue-svg-frame {
background: #ffffff;
}
/* Bonus section: drop accent color from body strongs; only the bonus-system heading
and explicitly-accent card headings stay tinted and they use the same darkened
accent technique as .btn-primary on light, so they remain readable on white. */
.theme-key-light .bonus-card strong {
color: var(--text);
}
.theme-key-light .bonus-card-head strong,
.theme-key-light .card-heading-accent {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
.theme-key-light .bonus-card-head > svg {
color: color-mix(in srgb, var(--accent) 50%, #000000);
}
/* Install guide theme surfaces */
.theme-key-light .install-platform-trigger,
.theme-key-light .install-app-button,
.theme-key-light .install-step,
.theme-key-light .install-subscription-card,
.theme-key-light .install-qr-wrap {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.055);
}
.theme-key-light .install-app-button.active {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.theme-key-light .install-platform-trigger:focus-visible,
.theme-key-light .install-platform-trigger[data-state="open"],
.theme-key-light .install-app-button:focus-visible {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
}
body:has(.theme-key-light) .install-platform-content {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.14);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.12);
}
body:has(.theme-key-light) .install-platform-item[data-highlighted],
body:has(.theme-key-light) .install-platform-item[data-selected] {
background: color-mix(in srgb, var(--accent) 9%, #ffffff);
}
.theme-key-light .install-step-icon,
.theme-key-light .install-subscription-header-icon {
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
color: color-mix(in srgb, var(--accent) 55%, #000000);
}
.theme-key-light .install-qr-divider {
color: rgba(15, 23, 42, 0.24);
}
.theme-key-light .install-feature-star.attention-dot {
background: #f59e0b;
}
.theme-key-light .install-loading .ui-spinner {
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);
}
@@ -0,0 +1,16 @@
{
"key": "light",
"names": {
"ru": "Светлая",
"en": "Light"
},
"enabled": true,
"default": false,
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 6,
"tokens": {
"color_scheme": "light"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

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