diff --git a/.dockerignore b/.dockerignore index 4ce4d8e..43d619d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ # Git -.git .gitignore .github .gitattributes @@ -11,7 +10,6 @@ README.md scratch_*.py *.local.* node_modules/ -.git/ # CI diff --git a/Dockerfile b/Dockerfile index dd1f6db..524a2fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,12 +27,22 @@ FROM python:3.12-slim WORKDIR /app -LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" +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 @@ -46,7 +56,38 @@ COPY --from=webapp-builder /webapp/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 rm -rf /root/.cache +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 diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index f1dd698..f75d99d 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -33,8 +33,10 @@ from bot.services.settings_override_service import ( current_value, update_overrides, ) +from bot.services.referral_service import ReferralService from bot.utils import MessageContent, send_message_via_queue from bot.utils.message_queue import get_queue_manager +from urllib.parse import parse_qsl, urlsplit, urlunsplit from config.settings import Settings from config.tariffs_config import TariffsConfig from db.dal import ( @@ -523,6 +525,7 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) async_session_factory: sessionmaker = request.app["async_session_factory"] + settings: Settings = request.app["settings"] async with async_session_factory() as session: user = await user_dal.get_user_by_id(session, target_id) @@ -548,6 +551,50 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: log_count = await message_log_dal.count_user_message_logs(session, target_id) avatar_keys = await _bulk_user_avatar_keys(session, [target_id]) + # Referral links — both the bot deep-link and the webapp deep-link. + referral_code: Optional[str] = None + try: + referral_code = await user_dal.ensure_referral_code(session, user) + await session.commit() + except Exception as exc_ref: # pragma: no cover — defensive + logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref) + await session.rollback() + + referral_service: Optional[ReferralService] = request.app.get("referral_service") + bot_username = request.app.get("bot_username") or "" + referral_bot_link: Optional[str] = None + if referral_service and bot_username and referral_code: + try: + async with async_session_factory() as session: + referral_bot_link = await referral_service.generate_referral_link( + session, bot_username, target_id + ) + except Exception as exc_link: # pragma: no cover + logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link) + referral_webapp_link = _build_admin_webapp_referral_link( + getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None), + referral_code, + ) + + # Subscription page URL — the raw panel `subscriptionUrl` that the user + # 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) + if panel_uuid: + subscription_service = request.app.get("subscription_service") + panel_service = getattr(subscription_service, "panel_service", None) + if panel_service is not None: + try: + panel_data = await panel_service.get_user_by_uuid(panel_uuid) + if panel_data: + subscription_url = panel_data.get("subscriptionUrl") or None + except Exception as exc_panel: # pragma: no cover + logger.warning( + "Failed to fetch subscriptionUrl 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]}" @@ -563,10 +610,33 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: "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, + "referral": { + "code": referral_code, + "bot_link": referral_bot_link, + "webapp_link": referral_webapp_link, + }, } ) +def _build_admin_webapp_referral_link( + base_url: Optional[str], referral_code: Optional[str] +) -> Optional[str]: + """Mirror of ``subscription_webapp._build_webapp_referral_link``. + + Kept local to avoid a cross-module import cycle (subscription_webapp + imports admin_api). + """ + if not base_url or not referral_code: + return None + parts = urlsplit(base_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["ref"] = f"u{referral_code}" + new_query = "&".join(f"{k}={v}" for k, v in query.items()) + return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) + + async def admin_user_ban_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -1273,13 +1343,13 @@ def setup_admin_routes(app: web.Application) -> None: router.add_get("/api/admin/stats", admin_stats_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+}/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+}/reset-trial", admin_user_reset_trial_route) - router.add_post("/api/admin/users/{user_id:\\d+}/extend", admin_user_extend_route) - router.add_delete("/api/admin/users/{user_id:\\d+}", admin_user_delete_route) + router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_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+}/reset-trial", admin_user_reset_trial_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) + 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/export.csv", admin_payments_export_route) diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index b39f045..a07969d 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -98,6 +98,8 @@ telegramLoginBotId: 1234567890, telegramOAuthClientId: 1234567890, telegramOAuthRequestAccess: ["write"], + appVersion: "dev+local", + appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop", }, data: { ok: true, @@ -759,18 +761,25 @@ function adminSectionFromPath(pathname) { const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); - const m = normalized.match(/^\/admin\/([a-z0-9_-]+)$/); + const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/); if (m && ADMIN_SECTIONS.has(m[1])) return m[1]; return "stats"; } - function syncSectionPath(section, replace = false, adminSection = null) { + function adminUserIdFromPath(pathname) { + const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); + const m = normalized.match(/^\/admin\/users\/(-?\d+)$/); + return m ? Number(m[1]) : null; + } + + function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) { if (window.location.protocol === "file:") return; const normalized = normalizeSection(section); let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; if (normalized === "admin") { const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats"; - targetPath = `/admin/${adm}`; + const uid = adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null); + targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`; } if (window.location.pathname === targetPath) return; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; @@ -1117,6 +1126,12 @@ { payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" }, ], log_count: 18, + subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP", + referral: { + code: "ABCD1234", + bot_link: "https://t.me/preview_bot?start=ref_uABCD1234", + webapp_link: "https://app.example.com/?ref=uABCD1234", + }, }; } if (path === "/admin/tariffs") { @@ -2180,10 +2195,12 @@ syncSectionPath("settings"); } - function handleAdminSectionChange(adminSection) { + function handleAdminSectionChange(adminSection, adminUserId = null) { if (screen !== "admin") return; if (window.location.protocol === "file:") return; - const targetPath = `/admin/${adminSection}`; + const targetPath = adminSection === "users" && adminUserId + ? `/admin/users/${adminUserId}` + : `/admin/${adminSection}`; if (window.location.pathname === targetPath) return; window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`); } @@ -2937,9 +2954,15 @@ onClose={closeAdminPanel} onToast={(text) => showToast(text)} initialSection={adminSectionFromPath(window.location.pathname)} + initialUserId={adminUserIdFromPath(window.location.pathname)} onSectionChange={handleAdminSectionChange} onSettingsSaved={handleSettingsSaved} onTariffsSaved={handleTariffsSaved} + brandTitle={brandTitle} + logoUrl={CFG.logoUrl} + logoEmoji={brandEmoji} + appVersion={CFG.appVersion} + appRepositoryUrl={CFG.appRepositoryUrl} /> {:else}