diff --git a/.gitattributes b/.gitattributes index 27c560a..4380649 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ .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 diff --git a/.github/workflows/_docker-build-push.yml b/.github/workflows/_docker-build-push.yml new file mode 100644 index 0000000..bcbd24d --- /dev/null +++ b/.github/workflows/_docker-build-push.yml @@ -0,0 +1,117 @@ +name: Docker build & push (reusable) + +# Reusable workflow that builds the three image targets defined in +# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes +# them to both ghcr.io and Docker Hub under the 3252a8/ namespace. +# +# Called by: +# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev +# - docker-release.yml (tag_mode: release, push: true) on pushes to main +# - 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 + +# 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 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + 3252a8/${{ matrix.image }} + ghcr.io/3252a8/${{ matrix.image }} + tags: | + type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }} + type=sha,prefix=dev-,format=short,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 "-" 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 }} + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} + provenance: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..027a381 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: PR checks + +# Runs on pull requests into main (typically from dev) and into dev (typically +# from feature/* branches): lint + format checks and a no-push image build to +# 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 + + build: + name: Docker build + uses: ./.github/workflows/_docker-build-push.yml + with: + push: false + tag_mode: dev diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..41c850d --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -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 diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 0000000..93d3f88 --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -0,0 +1,25 @@ +name: Dev images + +# On every push to the dev branch, build all three images and push them to +# ghcr.io and Docker Hub tagged `dev` and `dev-`. + +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 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..56bfbab --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..b02f00a --- /dev/null +++ b/.github/workflows/security.yml @@ -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 diff --git a/backend/bot/handlers/admin/statistics.py b/backend/bot/handlers/admin/statistics.py index 888970f..5e34c8c 100644 --- a/backend/bot/handlers/admin/statistics.py +++ b/backend/bot/handlers/admin/statistics.py @@ -71,11 +71,17 @@ async def show_statistics_handler( f"📊 {_('admin_user_stats_total_label')}: {user_stats['total_users']}" ) # Removed: Active today moved to panel stats + stats_text_parts.append( + f"📡 {_('admin_user_stats_active_subscription_label')}: {user_stats['active_subscriptions']}" # noqa: E501 + ) stats_text_parts.append( f"💳 {_('admin_user_stats_paid_subs_label')}: {user_stats['paid_subscriptions']}" ) stats_text_parts.append( - f"🆓 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}" + f"🧪 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}" + ) + stats_text_parts.append( + f"🎁 {_('admin_user_stats_free_subscription_label')}: {user_stats['free_subscription_users']}" # noqa: E501 ) stats_text_parts.append( f"😴 {_('admin_user_stats_inactive_label')}: {user_stats['inactive_users']}" diff --git a/backend/bot/handlers/inline_mode.py b/backend/bot/handlers/inline_mode.py index c0e5f7e..63421b7 100644 --- a/backend/bot/handlers/inline_mode.py +++ b/backend/bot/handlers/inline_mode.py @@ -166,8 +166,10 @@ async def create_user_stats_result( "inline_user_stats_message", total=user_stats["total_users"], active_today=user_stats["active_today"], + active=user_stats["active_subscriptions"], paid=user_stats["paid_subscriptions"], trial=user_stats["trial_users"], + free=user_stats["free_subscription_users"], inactive=user_stats["inactive_users"], banned=user_stats["banned_users"], referral=user_stats["referral_users"], @@ -179,7 +181,7 @@ async def create_user_stats_result( description=_( "inline_user_stats_description", total=user_stats["total_users"], - active=user_stats["paid_subscriptions"], + active=user_stats["active_subscriptions"], ), input_message_content=InputTextMessageContent( message_text=stats_text, parse_mode="HTML" diff --git a/backend/bot/services/telegram_notifications.py b/backend/bot/services/telegram_notifications.py index 8d59b83..326b822 100644 --- a/backend/bot/services/telegram_notifications.py +++ b/backend/bot/services/telegram_notifications.py @@ -4,7 +4,6 @@ from typing import Any, Optional from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo from sqlalchemy.ext.asyncio import AsyncSession from bot.middlewares.i18n import JsonI18n @@ -128,37 +127,6 @@ async def mark_telegram_notifications_enabled_for_telegram_user( ) -def _translate( - i18n: Optional[JsonI18n], - language: str, - key: str, - fallback: str, - **kwargs: Any, -) -> str: - if not i18n: - return fallback.format(**kwargs) if kwargs else fallback - return i18n.gettext(language, key, **kwargs) or fallback - - -def _probe_keyboard( - settings: Settings, - i18n: Optional[JsonI18n], - language: str, -) -> Optional[InlineKeyboardMarkup]: - app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() - if not app_url: - return None - text = _translate( - i18n, - language, - "telegram_notifications_open_app_button", - "Open app", - ) - return InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]] - ) - - async def probe_telegram_notifications( *, session: AsyncSession, @@ -187,20 +155,8 @@ async def probe_telegram_notifications( "start_link": telegram_notifications_start_link(bot_username), } - language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE) - text = _translate( - i18n, - language, - "telegram_notifications_enabled_message", - "Telegram notifications are enabled.", - ) try: - await bot.send_message( - int(telegram_id), - text, - reply_markup=_probe_keyboard(settings, i18n, language), - disable_web_page_preview=True, - ) + await bot.get_chat(int(telegram_id)) except Exception as exc: status = telegram_notification_status_from_error(exc) if status: @@ -211,7 +167,7 @@ async def probe_telegram_notifications( "start_link": telegram_notifications_start_link(bot_username), } logger.warning( - "Telegram notification probe failed for user %s / telegram %s: %s", + "Telegram notification chat probe failed for user %s / telegram %s: %s", user.user_id, telegram_id, exc, diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 3bad5d9..8b506e4 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -666,17 +666,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: active_today = int(user_counts[2] or 0) referral_users = int(user_counts[3] or 0) - subscription_counts_stmt = ( + provider_value = func.lower(func.coalesce(Subscription.provider, "")) + panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, "")) + trial_subscription_condition = or_( + provider_value == "trial", + panel_status_value == "TRIAL", + ) + paid_subscription_condition = and_( + provider_value != "", + provider_value != "trial", + panel_status_value != "TRIAL", + ) + free_subscription_condition = and_( + provider_value == "", + panel_status_value != "TRIAL", + ) + + active_subscription_flags_sq = ( select( - func.count( - func.distinct( - case((Subscription.provider.is_not(None), Subscription.user_id), else_=None) - ) + Subscription.user_id.label("user_id"), + func.max(case((paid_subscription_condition, 1), else_=0)).label( + "has_paid_subscription" ), - func.count( - func.distinct( - case((Subscription.provider.is_(None), Subscription.user_id), else_=None) - ) + func.max(case((trial_subscription_condition, 1), else_=0)).label( + "has_trial_subscription" + ), + func.max(case((free_subscription_condition, 1), else_=0)).label( + "has_free_subscription" ), ) .join(User, Subscription.user_id == User.user_id) @@ -686,27 +702,64 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: Subscription.end_date > now, ) ) + .group_by(Subscription.user_id) + .subquery() + ) + + subscription_counts_stmt = select( + func.count(active_subscription_flags_sq.c.user_id), + func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0), + func.coalesce( + func.sum( + case( + ( + active_subscription_flags_sq.c.has_paid_subscription == 0, + active_subscription_flags_sq.c.has_trial_subscription, + ), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case( + ( + and_( + active_subscription_flags_sq.c.has_paid_subscription == 0, + active_subscription_flags_sq.c.has_trial_subscription == 0, + ), + active_subscription_flags_sq.c.has_free_subscription, + ), + else_=0, + ) + ), + 0, + ), ) subscription_counts = (await session.execute(subscription_counts_stmt)).one() - paid_subs_users = int(subscription_counts[0] or 0) - trial_users = int(subscription_counts[1] or 0) + active_subscription_users = int(subscription_counts[0] or 0) + paid_subs_users = int(subscription_counts[1] or 0) + trial_users = int(subscription_counts[2] or 0) + free_subscription_users = int(subscription_counts[3] or 0) - # Inactive users (no active subscription) - inactive_users = total_users - paid_subs_users - trial_users - banned_users + inactive_users = total_users - active_subscription_users return { "total_users": total_users, "banned_users": banned_users, "active_today": active_today, + "active_subscriptions": active_subscription_users, "paid_subscriptions": paid_subs_users, "trial_users": trial_users, + "free_subscription_users": free_subscription_users, "inactive_users": max(0, inactive_users), "referral_users": referral_users, } async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]: - """Return non-banned user IDs who have an active subscription (paid or trial).""" + """Return non-banned user IDs who have any active subscription.""" from datetime import datetime, timezone now = datetime.now(timezone.utc) diff --git a/docs-site/public/_redirects b/docs-site/public/_redirects index 72bf040..26e2b14 100644 --- a/docs-site/public/_redirects +++ b/docs-site/public/_redirects @@ -1,11 +1,12 @@ # Cloudflare Pages rewrites for non-materialized static docs demo runtime routes. -# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app. -/demo/runtime/home/* /demo/runtime/app/ 200 -/demo/runtime/install/* /demo/runtime/app/ 200 -/demo/runtime/trial/* /demo/runtime/app/ 200 -/demo/runtime/invite/* /demo/runtime/app/ 200 -/demo/runtime/devices/* /demo/runtime/app/ 200 -/demo/runtime/support/* /demo/runtime/app/ 200 -/demo/runtime/settings/* /demo/runtime/app/ 200 -/demo/runtime/login/* /demo/runtime/app/ 200 -/demo/runtime/admin/* /demo/runtime/app/ 200 +# Use the generated directory index instead of app.html so Pages clean URLs do not +# loop between extensionless and .html variants. +/demo/runtime/home/* /demo/runtime/app/index.html 200 +/demo/runtime/install/* /demo/runtime/app/index.html 200 +/demo/runtime/trial/* /demo/runtime/app/index.html 200 +/demo/runtime/invite/* /demo/runtime/app/index.html 200 +/demo/runtime/devices/* /demo/runtime/app/index.html 200 +/demo/runtime/support/* /demo/runtime/app/index.html 200 +/demo/runtime/settings/* /demo/runtime/app/index.html 200 +/demo/runtime/login/* /demo/runtime/app/index.html 200 +/demo/runtime/admin/* /demo/runtime/app/index.html 200 diff --git a/docs-site/scripts/build-demo-runtime.mjs b/docs-site/scripts/build-demo-runtime.mjs index 4a96b3a..09294d1 100644 --- a/docs-site/scripts/build-demo-runtime.mjs +++ b/docs-site/scripts/build-demo-runtime.mjs @@ -191,6 +191,7 @@ await runNpm(["--prefix", frontendRoot, "run", "build:docs-demo"]); await rm(runtimeDir, { recursive: true, force: true }); await mkdir(runtimeDir, { recursive: true }); +await mkdir(path.join(runtimeDir, "app"), { recursive: true }); const html = await appHtml(); @@ -204,7 +205,7 @@ await Promise.all([ path.join(runtimeDir, "default-brand"), ), copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile), - writeFile(path.join(runtimeDir, "app.html"), html, "utf8"), + writeFile(path.join(runtimeDir, "app", "index.html"), html, "utf8"), installGuidesConfigPayload().then((payload) => writeFile( path.join(runtimeDir, "subscription-guides-config.json"), diff --git a/docs-site/scripts/materialize-demo-routes.mjs b/docs-site/scripts/materialize-demo-routes.mjs index 239badb..ab0a411 100644 --- a/docs-site/scripts/materialize-demo-routes.mjs +++ b/docs-site/scripts/materialize-demo-routes.mjs @@ -19,7 +19,13 @@ async function copyHtml(source, route) { } const demoShell = path.join(distRoot, "demo", "index.html"); -const runtimeApp = path.join(distRoot, "demo", "runtime", "app.html"); +const runtimeApp = path.join( + distRoot, + "demo", + "runtime", + "app", + "index.html", +); for (const route of demoRoutes) { await copyHtml(demoShell, route); diff --git a/docs-site/src/pages/demo/runtime/app/index.js b/docs-site/src/pages/demo/runtime/app/index.js deleted file mode 100644 index f488870..0000000 --- a/docs-site/src/pages/demo/runtime/app/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html"); - -export const prerender = true; - -export async function GET() { - return new Response(await readFile(runtimeAppHtml, "utf8"), { - headers: { - "Content-Type": "text/html; charset=utf-8", - }, - }); -} diff --git a/docs/getting-started/demo.md b/docs/getting-started/demo.md index 8bfc7a7..c3e6caa 100644 --- a/docs/getting-started/demo.md +++ b/docs/getting-started/demo.md @@ -23,7 +23,7 @@ - использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API; - дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо; - копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`; -- генерирует `app.html`, который грузит demo runtime и встроенные переводы; +- генерирует `app/index.html`, который грузит demo runtime и встроенные переводы; - после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`; - Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации; - страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e2b8dfc..6bb3552 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "lucide-svelte": "^1.0.1", "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.0", - "svelte": "^5.55.5", + "svelte": "^5.56.0", "svelte-eslint-parser": "^1.4.1", "tailwind-merge": "^3.6.0", "tailwindcss": "4.3.0", @@ -947,9 +947,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -971,9 +968,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -995,9 +989,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1019,9 +1010,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1043,9 +1031,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1067,9 +1052,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1239,9 +1221,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1259,9 +1238,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1279,9 +1255,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1299,9 +1272,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1319,9 +1289,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1339,9 +1306,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1429,9 +1393,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1620,9 +1584,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1640,9 +1601,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1660,9 +1618,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1680,9 +1635,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2096,9 +2048,9 @@ } }, "node_modules/devalue": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true, "license": "MIT" }, @@ -2421,9 +2373,9 @@ } }, "node_modules/esrap": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", - "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", "dev": true, "license": "MIT", "dependencies": { @@ -2899,9 +2851,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2923,9 +2872,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2947,9 +2893,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2971,9 +2914,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3640,24 +3580,24 @@ } }, "node_modules/svelte": { - "version": "5.55.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", - "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz", + "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.4", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", diff --git a/frontend/package.json b/frontend/package.json index 3a8ef78..918ddd5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,7 @@ "lucide-svelte": "^1.0.1", "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.0", - "svelte": "^5.55.5", + "svelte": "^5.56.0", "svelte-eslint-parser": "^1.4.1", "tailwind-merge": "^3.6.0", "tailwindcss": "4.3.0", diff --git a/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte index 67aa380..ed9d30e 100644 --- a/frontend/src/admin/sections/StatsSection.svelte +++ b/frontend/src/admin/sections/StatsSection.svelte @@ -692,17 +692,25 @@ - {at("stats_label_paid_subs", {}, "")} - {users.paid_subscriptions ?? 0} + {at("stats_label_active_subs", {}, "")} + {users.active_subscriptions ?? 0} - {users.trial_users ?? 0} + {users.total_users + ? Math.round(((users.active_subscriptions ?? 0) / (users.total_users || 1)) * 100) + : 0}% + - diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 7a9a95c..670e33f 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -1231,7 +1231,17 @@ export async function mockApi(path, options = {}, context = {}) { return { ok: true, currency_symbol: "RUB", - users: { total_users: 248, active_subscriptions: 172, banned_users: 3 }, + users: { + total_users: 248, + active_today: 9, + active_subscriptions: 172, + paid_subscriptions: 141, + trial_users: 8, + free_subscription_users: 23, + inactive_users: 76, + banned_users: 3, + referral_users: 34, + }, financial: { today_revenue: 1240, week_revenue: 15800, diff --git a/locales/en.json b/locales/en.json index 0f6f364..a21cfb0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -347,8 +347,8 @@ "admin_user_no_logs": "📜 User has no actions", "admin_user_logs_error": "❌ Error loading user actions", "admin_direct_message_signature": "\n\n---\n💬 Message from administrator", - "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n💳 With paid subscription: {paid}\n🆓 On trial period: {trial}\n😴 Inactive: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", - "inline_user_stats_description": "Total: {total}, Paid: {active}", + "inline_user_stats_message": "👥 User Statistics\n\n📊 Total: {total}\n📈 Active today: {active_today}\n📡 With active subscription: {active}\n💳 With paid subscription: {paid}\n🧪 With trial subscription: {trial}\n🎁 With free subscription: {free}\n😴 Without active subscription: {inactive}\n🚫 Banned: {banned}\n🎁 Via referral program: {referral}", + "inline_user_stats_description": "Total: {total}, subscribed: {active}", "inline_admin_user_stats_title": "👥 User Statistics", "inline_financial_stats_message": "💰 Financial Statistics\n\n📅 Today: {today:.2f} RUB\n ({today_count} payments)\n📅 Week: {week:.2f} RUB\n📅 Month: {month:.2f} RUB\n🏆 All time: {all_time:.2f} RUB", "inline_admin_financial_stats_title": "💰 Financial Statistics", @@ -488,9 +488,11 @@ "inline_referral_description": "Share referral link to get bonuses", "inline_financial_description": "Today: {today} RUB", "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", + "admin_user_stats_active_subscription_label": "With active subscription", "admin_user_stats_paid_subs_label": "With paid subscription", - "admin_user_stats_trial_label": "On trial period", - "admin_user_stats_inactive_label": "Inactive", + "admin_user_stats_trial_label": "With trial subscription", + "admin_user_stats_free_subscription_label": "With free subscription", + "admin_user_stats_inactive_label": "Without active subscription", "admin_user_stats_banned_label": "Banned", "admin_user_stats_referral_label": "Attracted via referral program", "admin_financial_today_label": "Today", @@ -1098,8 +1100,13 @@ "admin_stats_error": "Failed to load statistics: {error}", "admin_stats_label_users": "Users", "admin_stats_trend_banned": "Banned: {count}", - "admin_stats_label_paid_subs": "Paid Subscriptions", + "admin_stats_label_active_subs": "Active users", + "admin_stats_label_paid_subs": "Paid users", + "admin_stats_label_trial_users": "With trial subscription", + "admin_stats_label_free_users": "With free subscription", + "admin_stats_trend_paid": "Paid: {count}", "admin_stats_trend_trials": "Trials: {count}", + "admin_stats_trend_free": "Free: {count}", "admin_stats_label_today_rev": "Today's Revenue", "admin_stats_trend_payments": "{count} payments", "admin_stats_label_week": "This Week", @@ -1145,7 +1152,10 @@ "admin_stats_revenue_avg_check": "Average ticket today: {value}", "admin_stats_revenue_avg_none": "No successful payments today", "admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)", - "admin_stats_card_paid_caption": "Trials shown in the badge", + "admin_stats_card_active_subs_caption": "Paid, trial, and free access combined", + "admin_stats_card_paid_caption": "Paid users separated from trial and free access", + "admin_stats_card_trial_caption": "Only real trial subscriptions", + "admin_stats_card_free_caption": "Manual extensions and bonus access", "admin_stats_card_inactive_caption": "Share of all users is in the badge", "admin_stats_revenue_last_7_calendar": "Last 7 calendar days total", "admin_stats_revenue_prev_7_calendar": "Previous 7 days", diff --git a/locales/ru.json b/locales/ru.json index bb7164f..431a693 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -347,8 +347,8 @@ "admin_user_no_logs": "📜 У пользователя нет действий", "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", - "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n💳 С платной подпиской: {paid}\n🆓 На пробном периоде: {trial}\n😴 Неактивных: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", - "inline_user_stats_description": "Всего: {total}, Платных: {active}", + "inline_user_stats_message": "📊 Статистика Бота\n👥 Пользователи\n\n📊 Всего: {total}\n📈 Активных сегодня: {active_today}\n📡 С активной подпиской: {active}\n💳 С платной подпиской: {paid}\n🧪 С пробной подпиской: {trial}\n🎁 С бесплатной подпиской: {free}\n😴 Без активной подписки: {inactive}\n🚫 Заблокированных: {banned}\n🎁 Привлечено по реферальной программе: {referral}", + "inline_user_stats_description": "Всего: {total}, с подпиской: {active}", "inline_admin_user_stats_title": "👥 Статистика пользователей", "inline_financial_stats_message": "💰 Финансовая статистика\n\n📅 За сегодня: {today:.2f} RUB\n ({today_count} платежей)\n📅 За неделю: {week:.2f} RUB\n📅 За месяц: {month:.2f} RUB\n🏆 За все время: {all_time:.2f} RUB", "inline_admin_financial_stats_title": "💰 Финансовая статистика", @@ -488,9 +488,11 @@ "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", "inline_financial_description": "Сегодня: {today} RUB", "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", + "admin_user_stats_active_subscription_label": "С активной подпиской", "admin_user_stats_paid_subs_label": "С платной подпиской", - "admin_user_stats_trial_label": "На пробном периоде", - "admin_user_stats_inactive_label": "Неактивных", + "admin_user_stats_trial_label": "С пробной подпиской", + "admin_user_stats_free_subscription_label": "С бесплатной подпиской", + "admin_user_stats_inactive_label": "Без активной подписки", "admin_user_stats_banned_label": "Заблокированных", "admin_user_stats_referral_label": "Привлечено по реферальной программе", "admin_financial_today_label": "За сегодня", @@ -1098,8 +1100,13 @@ "admin_stats_error": "Не удалось загрузить статистику: {error}", "admin_stats_label_users": "Пользователи", "admin_stats_trend_banned": "В бане: {count}", - "admin_stats_label_paid_subs": "Платные подписки", - "admin_stats_trend_trials": "Триалы: {count}", + "admin_stats_label_active_subs": "Активные пользователи", + "admin_stats_label_paid_subs": "Платные пользователи", + "admin_stats_label_trial_users": "С пробной подпиской", + "admin_stats_label_free_users": "С бесплатной подпиской", + "admin_stats_trend_paid": "Платные: {count}", + "admin_stats_trend_trials": "Пробные: {count}", + "admin_stats_trend_free": "Бесплатные: {count}", "admin_stats_label_today_rev": "Доход за день", "admin_stats_trend_payments": "{count} платежей", "admin_stats_label_week": "За неделю", @@ -1145,7 +1152,10 @@ "admin_stats_revenue_avg_check": "Средний чек сегодня: {value}", "admin_stats_revenue_avg_none": "Сегодня без успешных платежей", "admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)", - "admin_stats_card_paid_caption": "Триалы — отдельно в бейдже", + "admin_stats_card_active_subs_caption": "Платные, пробные и бесплатные вместе", + "admin_stats_card_paid_caption": "Платные отдельно от пробных и бесплатных", + "admin_stats_card_trial_caption": "Только реальные пробные подписки", + "admin_stats_card_free_caption": "Ручные начисления и бонусный доступ", "admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже", "admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)", "admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.", diff --git a/tests/test_admin_panel_stats_cache.py b/tests/test_admin_panel_stats_cache.py index e37abb7..74ed0ef 100644 --- a/tests/test_admin_panel_stats_cache.py +++ b/tests/test_admin_panel_stats_cache.py @@ -96,9 +96,11 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase): "total_users": 10, "banned_users": 1, "active_today": 2, + "active_subscriptions": 8, "paid_subscriptions": 7, "trial_users": 1, - "inactive_users": 1, + "free_subscription_users": 0, + "inactive_users": 2, "referral_users": 3, } ) diff --git a/tests/test_telegram_notifications.py b/tests/test_telegram_notifications.py index 0ca675e..a1ab52b 100644 --- a/tests/test_telegram_notifications.py +++ b/tests/test_telegram_notifications.py @@ -1,6 +1,12 @@ +import asyncio from types import SimpleNamespace +from unittest.mock import AsyncMock -from bot.services.telegram_notifications import telegram_notifications_need_prompt +from bot.services import telegram_notifications as module +from bot.services.telegram_notifications import ( + TELEGRAM_NOTIFICATIONS_ENABLED, + telegram_notifications_need_prompt, +) def _user(status: str): @@ -22,3 +28,39 @@ def test_telegram_notifications_prompt_requires_linked_telegram(): ) assert telegram_notifications_need_prompt(user) is False + + +def test_probe_telegram_notifications_uses_silent_chat_check(monkeypatch): + user = SimpleNamespace( + user_id=42, + telegram_id=123, + telegram_notifications_status="unknown", + ) + bot = SimpleNamespace( + get_chat=AsyncMock(return_value=SimpleNamespace(id=123)), + send_message=AsyncMock(), + ) + recorded = [] + + async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None): + recorded.append((session, user_id, status, telegram_id, checked_at)) + return user + + monkeypatch.setattr(module, "mark_telegram_notifications_status", fake_mark_status) + + result = asyncio.run( + module.probe_telegram_notifications( + session="session", + bot=bot, + settings=SimpleNamespace(DEFAULT_LANGUAGE="ru"), + i18n=None, + user=user, + bot_username="preview_bot", + ) + ) + + bot.get_chat.assert_awaited_once_with(123) + bot.send_message.assert_not_called() + assert result["ok"] is True + assert result["status"] == TELEGRAM_NOTIFICATIONS_ENABLED + assert recorded == [("session", 42, TELEGRAM_NOTIFICATIONS_ENABLED, 123, None)] diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 05cb13e..a0358ec 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -27,6 +27,49 @@ class FakeResult: return self._scalar_value return [self._scalar_value] + def one(self): + return self._scalar_value + + +class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): + async def test_get_enhanced_user_statistics_splits_paid_trial_and_free_users(self): + session = SimpleNamespace( + execute=AsyncMock( + side_effect=[ + FakeResult((10, 1, 2, 3)), + FakeResult((8, 4, 2, 2)), + ] + ) + ) + + stats = await user_dal.get_enhanced_user_statistics(session) + + self.assertEqual( + stats, + { + "total_users": 10, + "banned_users": 1, + "active_today": 2, + "active_subscriptions": 8, + "paid_subscriptions": 4, + "trial_users": 2, + "free_subscription_users": 2, + "inactive_users": 2, + "referral_users": 3, + }, + ) + + stmt = session.execute.await_args_list[1].args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("GROUP BY SUBSCRIPTIONS.USER_ID", sql) + self.assertIn("SUBSCRIPTIONS.PROVIDER", sql) + self.assertIn("TRIAL", sql) + class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):