Merge pull request #1 from 3252a8/dev
Stabilize account linking, auth, and admin settings
This commit is contained in:
@@ -13,6 +13,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"]
|
||||
@@ -34,6 +35,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
}
|
||||
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"])
|
||||
@@ -70,6 +79,12 @@ async def admin_settings_patch_route(request: web.Request) -> web.Response:
|
||||
if isinstance(cache, dict):
|
||||
cache["ts"] = 0.0
|
||||
cache["data"] = {}
|
||||
try:
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_all_webapp_user_caches
|
||||
|
||||
await invalidate_all_webapp_user_caches(settings, include_devices=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to invalidate WebApp user payload caches after settings update")
|
||||
if (
|
||||
"WEBAPP_LOGO_URL" in updates
|
||||
or "WEBAPP_LOGO_URL" in deletes
|
||||
|
||||
@@ -507,7 +507,11 @@ def manifest_payload() -> List[dict]:
|
||||
same value so existing UIs that only read ``placeholder`` also show the
|
||||
hint inside the empty input.
|
||||
"""
|
||||
from bot.payment_providers import find_manifest_owner, manifest_field_default
|
||||
from bot.payment_providers import (
|
||||
find_manifest_owner,
|
||||
manifest_field_default,
|
||||
provider_webhook_metadata,
|
||||
)
|
||||
|
||||
sections_order = {
|
||||
"general": 1,
|
||||
@@ -531,10 +535,12 @@ def manifest_payload() -> List[dict]:
|
||||
)
|
||||
|
||||
default_value: Optional[str] = None
|
||||
webhook_metadata: Optional[dict] = None
|
||||
owner = find_manifest_owner(field.key)
|
||||
if owner is not None:
|
||||
spec, manifest_field = owner
|
||||
default_value = manifest_field_default(spec, manifest_field)
|
||||
webhook_metadata = provider_webhook_metadata(spec)
|
||||
|
||||
placeholder = field.placeholder
|
||||
if not placeholder and default_value:
|
||||
@@ -561,6 +567,8 @@ def manifest_payload() -> List[dict]:
|
||||
}
|
||||
if default_value is not None:
|
||||
item["default"] = default_value
|
||||
if webhook_metadata:
|
||||
item.update(webhook_metadata)
|
||||
if field.choices:
|
||||
item["choices"] = [
|
||||
{
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
|
||||
from .auth import _hash_email_password
|
||||
from .auth import (
|
||||
_hash_email_password,
|
||||
_notify_account_merged,
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
from .common import _invalidate_webapp_user_caches
|
||||
|
||||
|
||||
@@ -109,7 +113,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
current_user.email = email
|
||||
current_user.email_verified_at = datetime.now(timezone.utc)
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
if not merge_notice:
|
||||
await _sync_panel_identity_for_user(request, current_user)
|
||||
await session.commit()
|
||||
final_user_id = int(current_user.user_id)
|
||||
final_telegram_id = _telegram_id_for_user(current_user)
|
||||
@@ -122,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
current_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -178,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_email_linked:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
@@ -345,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
merge_end_date = (
|
||||
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
)
|
||||
await _sync_panel_identity_for_user(
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
db_user,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
# Best-effort cleanup of the removed panel account after the DB merge.
|
||||
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
|
||||
subscription_service: SubscriptionService = request.app.get(
|
||||
"subscription_service"
|
||||
)
|
||||
if subscription_service and subscription_service.panel_service:
|
||||
try:
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
|
||||
email_service: EmailAuthService = request.app.get("email_auth_service")
|
||||
if email_service and final_email:
|
||||
@@ -401,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
return _json_error(500, "link_failed", "Link failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
|
||||
if merge_notice:
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=merge_notice,
|
||||
email=final_email,
|
||||
telegram_id=final_telegram_id,
|
||||
username=final_username,
|
||||
first_name=final_first_name,
|
||||
)
|
||||
if should_notify_telegram_linked and final_telegram_id:
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
@@ -834,6 +834,45 @@ def _run_git_command(*args: str) -> str:
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _normalize_version_branch(raw_branch: str) -> str:
|
||||
branch = str(raw_branch or "").strip()
|
||||
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
|
||||
if branch.startswith(prefix):
|
||||
branch = branch[len(prefix) :]
|
||||
break
|
||||
if branch == "HEAD":
|
||||
return ""
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
|
||||
|
||||
|
||||
def _resolve_version_branch() -> str:
|
||||
for env_name in (
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
"GITHUB_REF_NAME",
|
||||
"CI_COMMIT_REF_NAME",
|
||||
):
|
||||
branch = _normalize_version_branch(os.getenv(env_name, ""))
|
||||
if branch:
|
||||
return branch
|
||||
return _normalize_version_branch(
|
||||
_run_git_command("branch", "--show-current")
|
||||
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
)
|
||||
|
||||
|
||||
def _format_app_version(tag: str, sha: str, branch: str) -> str:
|
||||
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
|
||||
if tag and sha:
|
||||
return f"{tag}{branch_suffix}+g{sha}"
|
||||
if sha:
|
||||
return f"dev{branch_suffix}+g{sha}"
|
||||
if tag:
|
||||
return f"{tag}{branch_suffix}"
|
||||
return f"dev{branch_suffix}+unknown"
|
||||
|
||||
|
||||
def _resolve_app_version() -> str:
|
||||
global _APP_VERSION_CACHE
|
||||
if _APP_VERSION_CACHE:
|
||||
@@ -855,21 +894,8 @@ def _resolve_app_version() -> str:
|
||||
|
||||
tag = _run_git_command("describe", "--tags", "--abbrev=0")
|
||||
sha = _run_git_command("rev-parse", "--short", "HEAD")
|
||||
dirty = bool(_run_git_command("status", "--porcelain"))
|
||||
|
||||
if tag and sha:
|
||||
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
|
||||
if commits_since_tag and commits_since_tag != "0":
|
||||
version = f"{tag}+{commits_since_tag}.g{sha}"
|
||||
else:
|
||||
version = tag
|
||||
elif sha:
|
||||
version = f"dev+g{sha}"
|
||||
else:
|
||||
version = "dev+unknown"
|
||||
|
||||
if dirty:
|
||||
version = f"{version}-dirty"
|
||||
branch = _resolve_version_branch()
|
||||
version = _format_app_version(tag, sha, branch)
|
||||
|
||||
_APP_VERSION_CACHE = version
|
||||
return version
|
||||
@@ -1372,10 +1398,10 @@ def _resolve_webapp_js_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_js_asset_name() -> str:
|
||||
return _resolve_hashed_js_asset_name(
|
||||
kind="admin-js",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
# The admin bundle is lazy-loaded from the already running Mini App. In
|
||||
# deployments where nginx serves static files in front of aiohttp, stale
|
||||
# hashed admin filenames can 404 even though the runtime build asset exists.
|
||||
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
|
||||
|
||||
|
||||
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||
@@ -1405,10 +1431,9 @@ def _resolve_webapp_css_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_css_asset_name() -> str:
|
||||
return _resolve_hashed_css_asset_name(
|
||||
kind="admin-css",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
# Keep the lazy-loaded admin stylesheet on the stable build filename for
|
||||
# the same reason as the JS bundle above.
|
||||
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
|
||||
|
||||
|
||||
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||
|
||||
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
redirect_path = "/settings" if purpose == "link" else "/"
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
final_user_id: Optional[int] = None
|
||||
source_user_id_for_cache: Optional[int] = None
|
||||
linked_user_for_panel: Optional[User] = None
|
||||
link_source_panel_uuid: Optional[str] = None
|
||||
link_final_panel_uuid: Optional[str] = None
|
||||
link_merge_notice: Optional[Dict[str, Any]] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if purpose == "link":
|
||||
current_user_id = int(state.get("user_id") or 0)
|
||||
source_user_id_for_cache = current_user_id
|
||||
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
|
||||
link_source_panel_uuid = (
|
||||
current_user_before_link.panel_user_uuid if current_user_before_link else None
|
||||
)
|
||||
db_user = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
telegram_user=telegram_user,
|
||||
settings=settings,
|
||||
)
|
||||
if int(db_user.user_id) != current_user_id:
|
||||
link_final_panel_uuid = db_user.panel_user_uuid
|
||||
link_merge_notice = await _build_account_merge_notice(
|
||||
session,
|
||||
merged_user=db_user,
|
||||
source_user_id=current_user_id,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
settings=settings,
|
||||
)
|
||||
linked_user_for_panel = db_user
|
||||
else:
|
||||
db_user = await _ensure_user_from_telegram(
|
||||
session,
|
||||
@@ -388,6 +408,34 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
|
||||
raise redirect(redirect_path, "failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
|
||||
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
|
||||
await _invalidate_webapp_user_caches(
|
||||
settings,
|
||||
source_user_id_for_cache,
|
||||
final_user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
|
||||
if purpose == "link" and link_merge_notice and linked_user_for_panel:
|
||||
merge_end_date_raw = link_merge_notice.get("final_end_date")
|
||||
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
|
||||
await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
linked_user_for_panel,
|
||||
source_panel_uuid=link_source_panel_uuid,
|
||||
final_panel_uuid=link_final_panel_uuid,
|
||||
expire_at=merge_end_date,
|
||||
)
|
||||
await _notify_account_merged(
|
||||
request,
|
||||
settings,
|
||||
merge_notice=link_merge_notice,
|
||||
email=linked_user_for_panel.email,
|
||||
telegram_id=_telegram_id_for_user(linked_user_for_panel),
|
||||
username=linked_user_for_panel.username,
|
||||
first_name=linked_user_for_panel.first_name,
|
||||
)
|
||||
|
||||
token = create_webapp_session_token(settings, int(final_user_id))
|
||||
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
|
||||
_clear_telegram_oauth_state_cookie(response)
|
||||
@@ -974,6 +1022,14 @@ def _panel_description_for_user(user: User) -> str:
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
|
||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||
raw_value = telegram_user.get("photo_url")
|
||||
if not raw_value:
|
||||
return None
|
||||
value = str(raw_value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
async def _sync_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
@@ -995,7 +1051,11 @@ async def _sync_panel_identity_for_user(
|
||||
if user.email:
|
||||
payload["email"] = user.email
|
||||
if expire_at is not None:
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
if expire_at > datetime.now(timezone.utc):
|
||||
payload["status"] = "ACTIVE"
|
||||
|
||||
try:
|
||||
await subscription_service.panel_service.update_user_details_on_panel(
|
||||
@@ -1013,6 +1073,53 @@ async def _sync_panel_identity_for_user(
|
||||
return False
|
||||
|
||||
|
||||
async def _delete_merged_source_panel_user(
|
||||
request: web.Request,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
) -> bool:
|
||||
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
|
||||
return True
|
||||
|
||||
subscription_service: SubscriptionService = request.app.get("subscription_service")
|
||||
if not subscription_service or not subscription_service.panel_service:
|
||||
return False
|
||||
|
||||
try:
|
||||
return bool(
|
||||
await subscription_service.panel_service.delete_user_from_panel(
|
||||
source_panel_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete merged source panel user %s: %s",
|
||||
source_panel_uuid,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _sync_merged_panel_identity_for_user(
|
||||
request: web.Request,
|
||||
user: User,
|
||||
*,
|
||||
source_panel_uuid: Optional[str],
|
||||
final_panel_uuid: Optional[str],
|
||||
expire_at: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
|
||||
# before patching the surviving one so merged accounts can accept both IDs.
|
||||
await _delete_merged_source_panel_user(
|
||||
request,
|
||||
source_panel_uuid=source_panel_uuid,
|
||||
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
|
||||
)
|
||||
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
|
||||
|
||||
|
||||
async def _build_account_merge_notice(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -1050,6 +1157,42 @@ async def _build_account_merge_notice(
|
||||
}
|
||||
|
||||
|
||||
async def _notify_account_merged(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
*,
|
||||
merge_notice: Optional[Dict[str, Any]],
|
||||
email: Optional[str],
|
||||
telegram_id: Optional[int],
|
||||
username: Optional[str],
|
||||
first_name: Optional[str],
|
||||
) -> None:
|
||||
if not merge_notice:
|
||||
return
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
|
||||
bot: Bot = request.app["bot"]
|
||||
notification_service = NotificationService(
|
||||
bot,
|
||||
settings,
|
||||
request.app.get("i18n"),
|
||||
)
|
||||
await notification_service.notify_account_merged(
|
||||
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
|
||||
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
|
||||
email=email,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
|
||||
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
|
||||
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send account merged notification")
|
||||
|
||||
|
||||
def _apply_telegram_profile_to_user(
|
||||
user: User,
|
||||
telegram_user: Dict[str, Any],
|
||||
@@ -1102,7 +1245,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if not existing_telegram_user and int(current_user.user_id) < 0:
|
||||
@@ -1134,7 +1276,6 @@ async def _link_telegram_to_user(
|
||||
)
|
||||
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
|
||||
await session.flush()
|
||||
await _sync_panel_identity_for_user(request, merged_user)
|
||||
return merged_user
|
||||
|
||||
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
from bot.infra.redis import cache_delete, redis_key
|
||||
from bot.infra.redis import cache_delete, cache_delete_pattern, redis_key
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
from config.settings import Settings
|
||||
|
||||
@@ -55,6 +55,20 @@ def invalidate_local_webapp_user_payload(
|
||||
cache.invalidate(key)
|
||||
|
||||
|
||||
def invalidate_all_local_webapp_user_payloads(
|
||||
settings: Settings,
|
||||
namespace: Optional[str] = None,
|
||||
) -> None:
|
||||
for (settings_id, cache_namespace, _ttl), cache in tuple(
|
||||
_WEBAPP_USER_PAYLOAD_CACHES.items()
|
||||
):
|
||||
if settings_id != id(settings):
|
||||
continue
|
||||
if namespace is not None and cache_namespace != namespace:
|
||||
continue
|
||||
cache.invalidate()
|
||||
|
||||
|
||||
async def invalidate_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*user_ids: Optional[int],
|
||||
@@ -79,3 +93,23 @@ async def invalidate_webapp_user_caches(
|
||||
invalidate_local_webapp_user_payload(settings, "devices", user_id)
|
||||
if keys:
|
||||
await cache_delete(settings, *keys)
|
||||
|
||||
|
||||
async def invalidate_all_webapp_user_caches(
|
||||
settings: Settings,
|
||||
*,
|
||||
include_devices: bool = False,
|
||||
) -> None:
|
||||
namespaces = ["me"]
|
||||
if include_devices:
|
||||
namespaces.append("devices")
|
||||
|
||||
for namespace in namespaces:
|
||||
invalidate_all_local_webapp_user_payloads(settings, namespace)
|
||||
try:
|
||||
await cache_delete_pattern(
|
||||
settings,
|
||||
redis_key(settings, "cache", "webapp", namespace, "*"),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -42,8 +42,55 @@ def _normalize_description(value: Optional[str]) -> str:
|
||||
return "\n".join((value or "").split()).strip()
|
||||
|
||||
|
||||
def _repair_cp1251_mojibake(value: str) -> str:
|
||||
try:
|
||||
return value.encode("latin1").decode("cp1251")
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
return value
|
||||
|
||||
|
||||
def _description_variants(value: Optional[str]) -> set[str]:
|
||||
normalized = _normalize_description(value)
|
||||
variants = {normalized}
|
||||
repaired = _normalize_description(_repair_cp1251_mojibake(normalized))
|
||||
if repaired:
|
||||
variants.add(repaired)
|
||||
return variants
|
||||
|
||||
|
||||
def _description_matches(current: Optional[str], desired: str) -> bool:
|
||||
return _normalize_description(current) == _normalize_description(desired)
|
||||
return bool(_description_variants(current) & _description_variants(desired))
|
||||
|
||||
|
||||
def _panel_identity_matches_user(
|
||||
panel_user: dict[str, Any],
|
||||
user: User,
|
||||
desired_description: str,
|
||||
) -> bool:
|
||||
if desired_description and not _description_matches(
|
||||
panel_user.get("description"),
|
||||
desired_description,
|
||||
):
|
||||
return False
|
||||
|
||||
if user.email and _normalize_panel_email(panel_user.get("email")) != user.email.strip().lower():
|
||||
return False
|
||||
|
||||
if user.telegram_id and _coerce_panel_telegram_id(panel_user.get("telegramId")) != int(
|
||||
user.telegram_id
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"description": description_text}
|
||||
if user.email:
|
||||
payload["email"] = user.email
|
||||
if user.telegram_id:
|
||||
payload["telegramId"] = user.telegram_id
|
||||
return payload
|
||||
|
||||
|
||||
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
|
||||
@@ -589,28 +636,15 @@ async def _perform_sync_impl(
|
||||
if line
|
||||
)
|
||||
# Update description only when it differs from the current one on panel
|
||||
current_panel_description = (
|
||||
panel_user_dict.get("description") or ""
|
||||
).strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and not _description_matches(
|
||||
current_panel_description, desired_description
|
||||
if desired_description and not _panel_identity_matches_user(
|
||||
panel_user_dict,
|
||||
existing_user,
|
||||
desired_description,
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
"description": description_text,
|
||||
**(
|
||||
{"email": existing_user.email}
|
||||
if existing_user.email
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"telegramId": existing_user.telegram_id}
|
||||
if existing_user.telegram_id
|
||||
else {}
|
||||
),
|
||||
},
|
||||
_panel_identity_update_payload(existing_user, description_text),
|
||||
)
|
||||
except Exception as e_desc:
|
||||
logging.warning(
|
||||
|
||||
@@ -25,6 +25,7 @@ from .registry import (
|
||||
provider_emoji_map,
|
||||
provider_label_map,
|
||||
provider_telegram_button_text,
|
||||
provider_webhook_metadata,
|
||||
resolve_provider_presentation,
|
||||
)
|
||||
|
||||
@@ -53,5 +54,6 @@ __all__ = [
|
||||
"provider_telegram_button_text",
|
||||
"provider_emoji_map",
|
||||
"provider_label_map",
|
||||
"provider_webhook_metadata",
|
||||
"resolve_provider_presentation",
|
||||
]
|
||||
|
||||
@@ -333,6 +333,46 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
|
||||
return None
|
||||
|
||||
|
||||
def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]:
|
||||
if spec.webhook_path and spec.webhook_route:
|
||||
return spec
|
||||
if not spec.service_key:
|
||||
return None
|
||||
for candidate in PAYMENT_PROVIDER_SPECS:
|
||||
if (
|
||||
candidate.service_key == spec.service_key
|
||||
and candidate.webhook_path
|
||||
and candidate.webhook_route
|
||||
):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]:
|
||||
"""Return admin-manifest webhook metadata for a provider SPEC.
|
||||
|
||||
Some visible payment buttons share one backing service and webhook route
|
||||
(for example Platega SBP and Platega Crypto), so presentation-only specs
|
||||
inherit the route from their service sibling.
|
||||
"""
|
||||
webhook_spec = _webhook_spec_for(spec)
|
||||
if webhook_spec is None or not webhook_spec.webhook_path:
|
||||
return None
|
||||
try:
|
||||
path = str(webhook_spec.webhook_path(None) or "").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not path:
|
||||
return None
|
||||
return {
|
||||
"provider_id": spec.id,
|
||||
"provider_label": spec.label,
|
||||
"webhook_provider_id": webhook_spec.id,
|
||||
"webhook_path": path,
|
||||
"webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url),
|
||||
}
|
||||
|
||||
|
||||
def manifest_field_default(
|
||||
spec: PaymentProviderSpec,
|
||||
manifest_field: ProviderManifestField,
|
||||
|
||||
@@ -690,6 +690,51 @@ class NotificationService:
|
||||
profile_keyboard = self._build_profile_keyboard(_, telegram_id)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
async def notify_account_merged(
|
||||
self,
|
||||
*,
|
||||
primary_user_id: int,
|
||||
removed_user_id: int,
|
||||
email: Optional[str],
|
||||
telegram_id: Optional[int],
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
final_end_date_text: Optional[str] = None,
|
||||
primary_panel_user_uuid: Optional[str] = None,
|
||||
removed_panel_user_uuid: Optional[str] = None,
|
||||
):
|
||||
"""Send notification when duplicate email/Telegram accounts are merged."""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
display_user_id = int(telegram_id or primary_user_id)
|
||||
user_display = self._format_user_display(
|
||||
user_id=display_user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_account_merged",
|
||||
primary_user_id=primary_user_id,
|
||||
removed_user_id=removed_user_id,
|
||||
telegram_id=telegram_id or "",
|
||||
user_display=user_display,
|
||||
email=hd.quote(email or ""),
|
||||
final_end_date=hd.quote(final_end_date_text or ""),
|
||||
primary_panel_user_uuid=hd.quote(primary_panel_user_uuid or ""),
|
||||
removed_panel_user_uuid=hd.quote(removed_panel_user_uuid or ""),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
profile_keyboard = (
|
||||
self._build_profile_keyboard(_, int(telegram_id)) if telegram_id else None
|
||||
)
|
||||
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||
|
||||
def _format_traffic_gb_admin(self, traffic_gb: float) -> str:
|
||||
value = float(traffic_gb)
|
||||
if value.is_integer():
|
||||
|
||||
@@ -155,6 +155,13 @@ class TariffTrafficWorker:
|
||||
cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid))
|
||||
if cached_panel_user is not None:
|
||||
return cached_panel_user
|
||||
return await self._repair_missing_panel_user_for_subscription(
|
||||
session,
|
||||
sub,
|
||||
panel_users_by_uuid=panel_users_by_uuid,
|
||||
semaphore=semaphore,
|
||||
confirmed_missing=True,
|
||||
)
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
@@ -167,12 +174,22 @@ class TariffTrafficWorker:
|
||||
sub.panel_user_uuid,
|
||||
)
|
||||
return {}
|
||||
return data or {}
|
||||
if data:
|
||||
return data
|
||||
return await self._repair_missing_panel_user_for_subscription(
|
||||
session,
|
||||
sub,
|
||||
panel_users_by_uuid=None,
|
||||
semaphore=semaphore,
|
||||
confirmed_missing=False,
|
||||
)
|
||||
|
||||
for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE):
|
||||
chunk = subs[chunk_start : chunk_start + TARIFF_WORKER_BATCH_SIZE]
|
||||
panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk))
|
||||
for sub, panel_data in zip(chunk, panel_payloads):
|
||||
if not panel_data:
|
||||
continue
|
||||
try:
|
||||
tariff = self.settings.tariffs_config.require(sub.tariff_key)
|
||||
except Exception:
|
||||
@@ -255,6 +272,69 @@ class TariffTrafficWorker:
|
||||
)
|
||||
return by_uuid
|
||||
|
||||
async def _repair_missing_panel_user_for_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
sub: Subscription,
|
||||
*,
|
||||
panel_users_by_uuid: Optional[dict[str, dict]],
|
||||
semaphore: asyncio.Semaphore,
|
||||
confirmed_missing: bool,
|
||||
) -> dict:
|
||||
current_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
|
||||
try:
|
||||
user_id = int(sub.user_id)
|
||||
except (TypeError, ValueError):
|
||||
user_id = 0
|
||||
db_user = await user_dal.get_user_by_id(session, user_id) if user_id else None
|
||||
canonical_uuid = str(getattr(db_user, "panel_user_uuid", "") or "").strip()
|
||||
|
||||
if canonical_uuid and canonical_uuid != current_uuid:
|
||||
panel_user = None
|
||||
if panel_users_by_uuid is not None:
|
||||
panel_user = panel_users_by_uuid.get(canonical_uuid)
|
||||
else:
|
||||
async with semaphore:
|
||||
try:
|
||||
panel_user = await self.panel_service.get_user_by_uuid(
|
||||
canonical_uuid,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"TariffTrafficWorker: failed to fetch canonical panel user %s",
|
||||
canonical_uuid,
|
||||
)
|
||||
panel_user = None
|
||||
if panel_user:
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: repaired subscription %s panel UUID %s -> %s",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
canonical_uuid,
|
||||
)
|
||||
sub.panel_user_uuid = canonical_uuid
|
||||
return panel_user
|
||||
|
||||
if confirmed_missing:
|
||||
sub.is_active = False
|
||||
sub.skip_notifications = True
|
||||
sub.status_from_panel = "PANEL_USER_NOT_FOUND"
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: deactivated subscription %s because panel user %s "
|
||||
"is missing",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"TariffTrafficWorker: skipping subscription %s because panel user %s "
|
||||
"could not be fetched",
|
||||
sub.subscription_id,
|
||||
current_uuid,
|
||||
)
|
||||
return {}
|
||||
|
||||
async def _ensure_period_reset_strategy(
|
||||
self,
|
||||
sub: Subscription,
|
||||
|
||||
+30
-17
@@ -1,32 +1,45 @@
|
||||
# Resolve the application version from .git at build time and emit a single
|
||||
# tiny ``.build-version`` file. The .git tree is consumed in this throwaway
|
||||
# stage and never copied into the runtime image — only the resulting one-line
|
||||
# version string ships. This matches the runtime fallback chain in
|
||||
# ``_resolve_app_version`` (REMNAWAVE_MINISHOP_VERSION env > .build-version
|
||||
# file > live git > "dev+unknown") so the admin sidebar always shows a tag /
|
||||
# sha even though the runtime images have no git tooling and no .git tree.
|
||||
# Resolve the application version from .git at build time and emit a tiny
|
||||
# .build-version file. The .git tree is consumed in this throwaway stage and
|
||||
# never copied into the runtime image; only the tag + commit version string
|
||||
# ships. Non-main builds include the branch name so they are visibly distinct
|
||||
# from release builds. This matches the runtime fallback chain in _resolve_app_version
|
||||
# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
|
||||
# "dev+unknown") so the admin sidebar always shows a tag / sha even though the
|
||||
# runtime images have no git tooling and no .git tree.
|
||||
FROM alpine:3.20 AS version-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /repo
|
||||
ARG REMNAWAVE_MINISHOP_BRANCH=""
|
||||
ARG GIT_BRANCH=""
|
||||
ARG BRANCH_NAME=""
|
||||
ARG GITHUB_REF_NAME=""
|
||||
ARG CI_COMMIT_REF_NAME=""
|
||||
COPY .git ./.git
|
||||
RUN set -eu; \
|
||||
git config --global --add safe.directory /repo; \
|
||||
tag=$(git describe --tags --abbrev=0 2>/dev/null || true); \
|
||||
sha=$(git rev-parse --short HEAD 2>/dev/null || true); \
|
||||
dirty=$(git status --porcelain 2>/dev/null | head -c1 || true); \
|
||||
branch="${REMNAWAVE_MINISHOP_BRANCH:-${GIT_BRANCH:-${BRANCH_NAME:-${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME:-}}}}}"; \
|
||||
if [ -z "$branch" ]; then branch=$(git branch --show-current 2>/dev/null || true); fi; \
|
||||
if [ -z "$branch" ]; then branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true); fi; \
|
||||
case "$branch" in \
|
||||
refs/heads/*) branch="${branch#refs/heads/}" ;; \
|
||||
refs/remotes/origin/*) branch="${branch#refs/remotes/origin/}" ;; \
|
||||
origin/*) branch="${branch#origin/}" ;; \
|
||||
HEAD) branch="" ;; \
|
||||
esac; \
|
||||
branch_slug=$(printf '%s' "$branch" | sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//' | cut -c1-48); \
|
||||
branch_suffix=""; \
|
||||
if [ -n "$branch_slug" ] && [ "$branch_slug" != "main" ]; then branch_suffix="-$branch_slug"; fi; \
|
||||
if [ -n "$tag" ] && [ -n "$sha" ]; then \
|
||||
commits_since_tag=$(git rev-list "$tag..HEAD" --count 2>/dev/null || true); \
|
||||
if [ -n "$commits_since_tag" ] && [ "$commits_since_tag" != "0" ]; then \
|
||||
version="${tag}+${commits_since_tag}.g${sha}"; \
|
||||
else \
|
||||
version="$tag"; \
|
||||
fi; \
|
||||
version="${tag}${branch_suffix}+g${sha}"; \
|
||||
elif [ -n "$sha" ]; then \
|
||||
version="dev+g${sha}"; \
|
||||
version="dev${branch_suffix}+g${sha}"; \
|
||||
elif [ -n "$tag" ]; then \
|
||||
version="${tag}${branch_suffix}"; \
|
||||
else \
|
||||
version="dev+unknown"; \
|
||||
version="dev${branch_suffix}+unknown"; \
|
||||
fi; \
|
||||
if [ -n "$dirty" ]; then version="${version}-dirty"; fi; \
|
||||
printf '%s' "$version" > /build-version; \
|
||||
printf '%s' "${tag:-unknown}" > /build-tag; \
|
||||
printf '%s' "${sha:-unknown}" > /build-commit
|
||||
|
||||
+43
-6
@@ -194,7 +194,7 @@
|
||||
showLogin,
|
||||
telegramSdk,
|
||||
getTg: () => tg,
|
||||
telegramOAuthClientId,
|
||||
telegramOAuthClientId: () => telegramOAuthClientId,
|
||||
currentLang: () => currentLang,
|
||||
normalizeLangCode,
|
||||
updateLocalData: (updatedLanguage) => {
|
||||
@@ -397,6 +397,7 @@
|
||||
$: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0);
|
||||
$: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
|
||||
$: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData);
|
||||
$: telegramMiniAppContext = hasTelegramLaunchParams();
|
||||
$: telegramLoginUnavailable =
|
||||
!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading";
|
||||
$: telegramLoginChecking =
|
||||
@@ -634,7 +635,10 @@
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
link.onload = () => resolve();
|
||||
link.onerror = () => reject(new Error(`stylesheet_load_failed:${href}`));
|
||||
link.onerror = () => {
|
||||
link.remove();
|
||||
reject(new Error(`stylesheet_load_failed:${href}`));
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
@@ -647,11 +651,34 @@
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`script_load_failed:${src}`));
|
||||
script.onerror = () => {
|
||||
script.remove();
|
||||
reject(new Error(`script_load_failed:${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function appendStylesheetWithFallback(id, href, fallbackName) {
|
||||
const fallbackHref = resolveWebappAssetPath("", fallbackName);
|
||||
try {
|
||||
await appendStylesheetOnce(id, href);
|
||||
} catch (error) {
|
||||
if (!fallbackHref || href === fallbackHref) throw error;
|
||||
await appendStylesheetOnce(id, fallbackHref);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendScriptWithFallback(id, src, fallbackName) {
|
||||
const fallbackSrc = resolveWebappAssetPath("", fallbackName);
|
||||
try {
|
||||
await appendScriptOnce(id, src);
|
||||
} catch (error) {
|
||||
if (!fallbackSrc || src === fallbackSrc) throw error;
|
||||
await appendScriptOnce(id, fallbackSrc);
|
||||
}
|
||||
}
|
||||
|
||||
function readAdminBundleApi() {
|
||||
const bundle = window.SubscriptionWebAppAdmin;
|
||||
return bundle?.mount ? bundle : null;
|
||||
@@ -671,8 +698,16 @@
|
||||
adminBundlePromise = (async () => {
|
||||
const cssHref = resolveWebappAssetPath(CFG.adminCssAsset, "subscription_webapp_admin.css");
|
||||
const jsSrc = resolveWebappAssetPath(CFG.adminJsAsset, "subscription_webapp_admin.js");
|
||||
await appendStylesheetOnce("subscription-webapp-admin-css", cssHref);
|
||||
await appendScriptOnce("subscription-webapp-admin-js", jsSrc);
|
||||
await appendStylesheetWithFallback(
|
||||
"subscription-webapp-admin-css",
|
||||
cssHref,
|
||||
"subscription_webapp_admin.css",
|
||||
);
|
||||
await appendScriptWithFallback(
|
||||
"subscription-webapp-admin-js",
|
||||
jsSrc,
|
||||
"subscription_webapp_admin.js",
|
||||
);
|
||||
const loaded = readAdminBundleApi();
|
||||
if (!loaded) throw new Error("admin_bundle_missing_mount");
|
||||
adminBundleApi = loaded;
|
||||
@@ -1407,7 +1442,9 @@
|
||||
{user}
|
||||
{userAgreementUrl}
|
||||
{userLanguage}
|
||||
linkTelegramAccount={accountStore.linkTelegramAccount}
|
||||
showLogout={!telegramMiniAppContext}
|
||||
linkTelegramAccount={() =>
|
||||
accountStore.linkTelegramAccount(() => telegramMiniAppInitData)}
|
||||
logout={accountStore.logout}
|
||||
{openAdminPanel}
|
||||
{openExternalLink}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { ChevronRight, Eye, EyeOff, Search, X } from "$components/ui/icons.js";
|
||||
import { Check, ChevronRight, Copy, Eye, EyeOff, Search, X } from "$components/ui/icons.js";
|
||||
import * as UiIcons from "$components/ui/icons.js";
|
||||
import { Accordion, Switch } from "$components/ui/primitives.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
@@ -9,7 +9,7 @@
|
||||
AdminEmptyState,
|
||||
AdminSelect,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { getContext, onDestroy, onMount } from "svelte";
|
||||
|
||||
export let at;
|
||||
export let onSettingsSaved;
|
||||
@@ -26,6 +26,8 @@
|
||||
let revealedSecrets = new Set();
|
||||
let iconPickerField = null;
|
||||
let iconPickerSearch = "";
|
||||
let copiedWebhookKey = "";
|
||||
let copiedWebhookTimer = null;
|
||||
|
||||
$: settingsAllOpen =
|
||||
visibleSettingsSections.length > 0 &&
|
||||
@@ -48,6 +50,12 @@
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (copiedWebhookTimer && typeof window !== "undefined") {
|
||||
window.clearTimeout(copiedWebhookTimer);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleAllSections() {
|
||||
if (settingsOpenSections.length === visibleSettingsSections.length) {
|
||||
settingsOpenSections = [];
|
||||
@@ -123,6 +131,60 @@
|
||||
closeIconPicker();
|
||||
}
|
||||
|
||||
function normalizeWebhookPath(path) {
|
||||
const normalized = String(path || "").trim();
|
||||
if (!normalized) return "";
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
function webhookUrlForField(field) {
|
||||
const explicit = String(field?.webhook_url || "").trim();
|
||||
if (explicit) return explicit;
|
||||
const path = normalizeWebhookPath(field?.webhook_path);
|
||||
if (!path) return "";
|
||||
if (field?.webhook_requires_base_url && field?.webhook_base_url_configured === false) {
|
||||
return "";
|
||||
}
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function groupWebhook(fields) {
|
||||
const field = (fields || []).find((item) => item.webhook_path || item.webhook_url);
|
||||
if (!field) return null;
|
||||
const path = normalizeWebhookPath(field.webhook_path);
|
||||
const url = webhookUrlForField(field);
|
||||
if (!url && !path) return null;
|
||||
return {
|
||||
key: `${field.provider_id || field.key || "provider"}:${path || url}`,
|
||||
path,
|
||||
url,
|
||||
requiresBaseUrl: Boolean(field.webhook_requires_base_url),
|
||||
baseConfigured: field.webhook_base_url_configured !== false,
|
||||
};
|
||||
}
|
||||
|
||||
async function copyWebhookUrl(webhook) {
|
||||
if (!webhook?.url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhook.url);
|
||||
copiedWebhookKey = webhook.key;
|
||||
if (copiedWebhookTimer && typeof window !== "undefined") {
|
||||
window.clearTimeout(copiedWebhookTimer);
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
copiedWebhookTimer = window.setTimeout(() => {
|
||||
copiedWebhookKey = "";
|
||||
copiedWebhookTimer = null;
|
||||
}, 1400);
|
||||
}
|
||||
} catch {
|
||||
copiedWebhookKey = "";
|
||||
}
|
||||
}
|
||||
|
||||
function groupSectionFields(section) {
|
||||
const groups = new Map();
|
||||
for (const field of section.fields || []) {
|
||||
@@ -140,6 +202,7 @@
|
||||
id,
|
||||
label: id === "_root" ? null : id,
|
||||
i18nLabelKey: group.i18nLabelKey,
|
||||
webhook: groupWebhook(group.fields),
|
||||
fields: group.fields,
|
||||
}));
|
||||
}
|
||||
@@ -219,6 +282,47 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderWebhookHint(webhook)}
|
||||
{@const displayValue = webhook.url || webhook.path}
|
||||
<div class="admin-webhook-hint">
|
||||
<div class="admin-webhook-hint-meta">
|
||||
<strong>{at("settings_provider_webhook_url", {}, "Webhook URL")}</strong>
|
||||
<small>
|
||||
{webhook.url
|
||||
? at(
|
||||
"settings_provider_webhook_url_hint",
|
||||
{},
|
||||
"Use this URL in the provider webhook settings."
|
||||
)
|
||||
: at(
|
||||
"settings_provider_webhook_base_missing",
|
||||
{ path: webhook.path },
|
||||
`Set WEBHOOK_BASE_URL to show the full URL for ${webhook.path}.`
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div class="admin-webhook-value">
|
||||
<code title={displayValue}>{displayValue}</code>
|
||||
<AdminButton
|
||||
class="admin-webhook-copy"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!webhook.url}
|
||||
title={at("copy", {}, "Copy")}
|
||||
onclick={() => copyWebhookUrl(webhook)}
|
||||
>
|
||||
{#if copiedWebhookKey === webhook.key}
|
||||
<Check size={13} />
|
||||
<span>{at("copied", {}, "Copied")}</span>
|
||||
{:else}
|
||||
<Copy size={13} />
|
||||
<span>{at("copy", {}, "Copy")}</span>
|
||||
{/if}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet renderField(field)}
|
||||
{@const revealed = isSecretRevealed(field.key)}
|
||||
<div class="admin-setting" class:is-overridden={isOverridden(field)}>
|
||||
@@ -420,6 +524,9 @@
|
||||
{@const labelGroups = groups.filter((g) => g.label)}
|
||||
<div class="admin-settings-fields">
|
||||
{#if rootGroup}
|
||||
{#if rootGroup.webhook}
|
||||
{@render renderWebhookHint(rootGroup.webhook)}
|
||||
{/if}
|
||||
{#each rootGroup.fields as field}
|
||||
{@render renderField(field)}
|
||||
{/each}
|
||||
@@ -463,6 +570,9 @@
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="admin-accordion-content">
|
||||
<div class="admin-settings-subsection-body">
|
||||
{#if group.webhook}
|
||||
{@render renderWebhookHint(group.webhook)}
|
||||
{/if}
|
||||
{#each group.fields as field}
|
||||
{@render renderField(field)}
|
||||
{/each}
|
||||
|
||||
@@ -145,6 +145,12 @@ export function createAccountStore({
|
||||
clearPasswordCooldownTimer();
|
||||
}
|
||||
|
||||
function getTelegramOAuthClientId() {
|
||||
const value =
|
||||
typeof telegramOAuthClientId === "function" ? telegramOAuthClientId() : telegramOAuthClientId;
|
||||
return Number(value || 0);
|
||||
}
|
||||
|
||||
function closeSetPasswordDialog() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
@@ -318,19 +324,21 @@ export function createAccountStore({
|
||||
}
|
||||
}
|
||||
|
||||
async function linkTelegramAccount(getTelegramMiniAppInitData) {
|
||||
async function linkTelegramAccount(getTelegramMiniAppInitData = () => "") {
|
||||
const s = get(state);
|
||||
if (s.linkTelegramBusy) return;
|
||||
const readTelegramMiniAppInitData =
|
||||
typeof getTelegramMiniAppInitData === "function" ? getTelegramMiniAppInitData : () => "";
|
||||
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
|
||||
if (isTelegramMiniAppAttempt) {
|
||||
await telegramSdk.ensureForAction();
|
||||
}
|
||||
const initData = getTelegramMiniAppInitData();
|
||||
const initData = readTelegramMiniAppInitData();
|
||||
if (initData) {
|
||||
await linkTelegramAccountWithPayload({ init_data: initData });
|
||||
return;
|
||||
}
|
||||
if (!telegramOAuthClientId) {
|
||||
if (!getTelegramOAuthClientId()) {
|
||||
showToast(t("wa_auth_telegram_not_configured"));
|
||||
return;
|
||||
}
|
||||
@@ -362,6 +370,7 @@ export function createAccountStore({
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (telegramSdk.hasLaunchParams()) return;
|
||||
markManualLogout();
|
||||
clearToken();
|
||||
try {
|
||||
|
||||
@@ -61,11 +61,6 @@ export async function runWebappBoot({
|
||||
);
|
||||
}
|
||||
|
||||
if (isManuallyLoggedOut()) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetAuthData = readTelegramLoginWidgetAuthData();
|
||||
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
|
||||
|
||||
@@ -78,6 +73,11 @@ export async function runWebappBoot({
|
||||
}
|
||||
}
|
||||
|
||||
if (isManuallyLoggedOut()) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (getToken() || getCsrfToken()) {
|
||||
try {
|
||||
await loadData();
|
||||
|
||||
@@ -1889,6 +1889,65 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-webhook-hint {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
background: color-mix(in srgb, var(--info) 7%, transparent);
|
||||
}
|
||||
|
||||
.admin-webhook-hint-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-webhook-hint-meta strong {
|
||||
color: var(--admin-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-webhook-hint-meta small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-webhook-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-webhook-value code {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2));
|
||||
color: var(--admin-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-webhook-copy {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-setting:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -2625,6 +2684,26 @@
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
.admin-webhook-hint {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-webhook-value {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-webhook-value code {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-webhook-copy {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.admin-card-head {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
@@ -2704,38 +2783,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-admin-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin {
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 42%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin:hover {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 58%, transparent);
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin > svg:first-child {
|
||||
color: #fbbf24;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
shadcn-svelte primitives: Tabs / Select / Switch / Label
|
||||
bits-ui exposes data-state attributes; styling here matches
|
||||
|
||||
@@ -1260,6 +1260,11 @@ a {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-admin-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
@@ -1302,6 +1307,56 @@ a {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin {
|
||||
min-height: 54px;
|
||||
border-color: var(--warning-border);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--warning) 13%, var(--panel)),
|
||||
color-mix(in srgb, var(--warning) 7%, var(--panel-2))
|
||||
);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.14s ease,
|
||||
border-color 0.14s ease,
|
||||
background 0.14s ease,
|
||||
box-shadow 0.14s ease;
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--warning) 52%, var(--border));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--warning) 16%, var(--panel)),
|
||||
color-mix(in srgb, var(--warning) 9%, var(--panel-2))
|
||||
);
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--warning) 58%, var(--border));
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--warning) 24%, transparent),
|
||||
inset 0 1px 0 var(--inset-highlight);
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin > svg:first-child {
|
||||
color: var(--warning-text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin > svg:last-child {
|
||||
color: color-mix(in srgb, var(--warning) 70%, var(--muted));
|
||||
}
|
||||
|
||||
.settings-row.settings-row-admin strong {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.settings-row-linked {
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
border-color: var(--success-border);
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
export let user = {};
|
||||
export let userAgreementUrl = "";
|
||||
export let userLanguage = "";
|
||||
export let showLogout = true;
|
||||
|
||||
export let linkTelegramAccount = () => {};
|
||||
export let logout = () => {};
|
||||
@@ -187,10 +188,12 @@
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="settings-row settings-row-logout" type="button" onclick={logout}>
|
||||
<UserRound size={21} />
|
||||
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{#if showLogout}
|
||||
<button class="settings-row settings-row-logout" type="button" onclick={logout}>
|
||||
<UserRound size={21} />
|
||||
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"log_new_email_user_registration": "📧 <b>New User (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Time: {timestamp}",
|
||||
"log_account_email_linked": "📧 <b>Email linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
|
||||
"log_account_telegram_linked": "📨 <b>Telegram linked</b>\n\n🆔 User ID: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Time: {timestamp}",
|
||||
"log_account_merged": "🔗 <b>Accounts merged</b>\n\n🆔 Kept user ID: <code>{primary_user_id}</code>\n🗑 Removed user ID: <code>{removed_user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 User: {user_display}\n📧 Email: <code>{email}</code>\n⏰ New end date: <b>{final_end_date}</b>\n📋 Kept panel UUID: <code>{primary_panel_user_uuid}</code>\n📋 Removed panel UUID: <code>{removed_panel_user_uuid}</code>\n🕐 Time: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received (traffic top-up)</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_payment_traffic_purchase_line": "<b>{gb} GB</b> · {kind}",
|
||||
@@ -956,6 +957,11 @@
|
||||
"admin_settings_subsection_cryptopay": "CryptoPay",
|
||||
"admin_settings_subsection_wata": "Wata",
|
||||
"admin_settings_subsection_heleket": "Heleket",
|
||||
"admin_settings_provider_webhook_url": "Webhook URL",
|
||||
"admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.",
|
||||
"admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.",
|
||||
"admin_copy": "Copy",
|
||||
"admin_copied": "Copied",
|
||||
"admin_settings_validation_errors": "Errors: {errors}",
|
||||
"admin_settings_save_error": "Error: {error}",
|
||||
"admin_sync_started": "Synchronization started",
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"log_new_email_user_registration": "📧 <b>Новый пользователь (email)</b>\n\n🆔 ID: <code>{user_id}</code>\n📧 Email: <code>{email}</code>{referral_text}\n📅 Время: {timestamp}",
|
||||
"log_account_email_linked": "📧 <b>Привязана почта</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
|
||||
"log_account_telegram_linked": "📨 <b>Привязан Telegram</b>\n\n🆔 ID пользователя: <code>{user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n🕐 Время: {timestamp}",
|
||||
"log_account_merged": "🔗 <b>Аккаунты объединены</b>\n\n🆔 Оставлен ID: <code>{primary_user_id}</code>\n🗑 Удалён ID: <code>{removed_user_id}</code>\n📨 Telegram ID: <code>{telegram_id}</code>\n👤 Пользователь: {user_display}\n📧 Email: <code>{email}</code>\n⏰ Новая дата окончания: <b>{final_end_date}</b>\n📋 UUID оставленного в панели: <code>{primary_panel_user_uuid}</code>\n📋 UUID удалённого в панели: <code>{removed_panel_user_uuid}</code>\n🕐 Время: {timestamp}",
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж (докупка трафика)</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_payment_traffic_purchase_line": "<b>{gb} ГБ</b> · {kind}",
|
||||
@@ -956,6 +957,11 @@
|
||||
"admin_settings_subsection_cryptopay": "CryptoPay",
|
||||
"admin_settings_subsection_wata": "Wata",
|
||||
"admin_settings_subsection_heleket": "Heleket",
|
||||
"admin_settings_provider_webhook_url": "Webhook URL",
|
||||
"admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.",
|
||||
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
||||
"admin_copy": "Копировать",
|
||||
"admin_copied": "Скопировано",
|
||||
"admin_settings_validation_errors": "Ошибки: {errors}",
|
||||
"admin_settings_save_error": "Ошибка: {error}",
|
||||
"admin_sync_started": "Синхронизация запущена",
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bot.app.web import subscription_webapp # noqa: F401
|
||||
from bot.app.web.webapp import account as account_routes
|
||||
from bot.app.web.webapp.auth import (
|
||||
_link_telegram_to_user,
|
||||
_sync_merged_panel_identity_for_user,
|
||||
)
|
||||
|
||||
|
||||
class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
|
||||
class _AsyncSessionFactory:
|
||||
def __init__(self):
|
||||
self.session = SimpleNamespace(
|
||||
commit=AsyncMock(),
|
||||
rollback=AsyncMock(),
|
||||
flush=AsyncMock(),
|
||||
)
|
||||
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.session
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def test_merged_panel_identity_deletes_source_before_updating_target(self):
|
||||
calls = []
|
||||
|
||||
async def delete_source(*args, **kwargs):
|
||||
calls.append("delete")
|
||||
return True
|
||||
|
||||
async def update_target(*args, **kwargs):
|
||||
calls.append("update")
|
||||
return {"uuid": "panel-target"}
|
||||
|
||||
panel_service = SimpleNamespace(
|
||||
delete_user_from_panel=AsyncMock(side_effect=delete_source),
|
||||
update_user_details_on_panel=AsyncMock(side_effect=update_target),
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
|
||||
)
|
||||
user = SimpleNamespace(
|
||||
user_id=42,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
email="linked@example.com",
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
last_name=None,
|
||||
)
|
||||
|
||||
result = await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
user,
|
||||
source_panel_uuid="panel-source",
|
||||
final_panel_uuid="panel-target",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(calls, ["delete", "update"])
|
||||
panel_service.delete_user_from_panel.assert_awaited_once_with(
|
||||
"panel-source",
|
||||
log_response=False,
|
||||
)
|
||||
panel_service.update_user_details_on_panel.assert_awaited_once()
|
||||
update_uuid, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
||||
self.assertEqual(update_uuid, "panel-target")
|
||||
self.assertEqual(payload["email"], "linked@example.com")
|
||||
self.assertEqual(payload["telegramId"], 42)
|
||||
|
||||
async def test_merged_panel_identity_reactivates_expired_target_with_transferred_time(self):
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
panel_service = SimpleNamespace(
|
||||
delete_user_from_panel=AsyncMock(return_value=True),
|
||||
update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-target"}),
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
|
||||
)
|
||||
user = SimpleNamespace(
|
||||
user_id=42,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
email="linked@example.com",
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
last_name=None,
|
||||
)
|
||||
|
||||
result = await _sync_merged_panel_identity_for_user(
|
||||
request,
|
||||
user,
|
||||
source_panel_uuid="panel-email",
|
||||
final_panel_uuid="panel-target",
|
||||
expire_at=expire_at,
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
panel_service.delete_user_from_panel.assert_awaited_once_with(
|
||||
"panel-email",
|
||||
log_response=False,
|
||||
)
|
||||
_, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
||||
expected_expire_at = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
self.assertEqual(payload["expireAt"], expected_expire_at)
|
||||
self.assertEqual(payload["status"], "ACTIVE")
|
||||
|
||||
async def test_telegram_merge_defers_panel_sync_until_source_cleanup(self):
|
||||
current_user = SimpleNamespace(
|
||||
user_id=-100,
|
||||
email="linked@example.com",
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-source",
|
||||
telegram_id=None,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
)
|
||||
existing_telegram_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email=None,
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
)
|
||||
merged_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email="linked@example.com",
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-target",
|
||||
telegram_id=42,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
)
|
||||
panel_service = SimpleNamespace(update_user_details_on_panel=AsyncMock())
|
||||
request = SimpleNamespace(
|
||||
app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
|
||||
)
|
||||
session = SimpleNamespace(flush=AsyncMock())
|
||||
telegram_user = {
|
||||
"id": 42,
|
||||
"username": "alice",
|
||||
"first_name": "Alice",
|
||||
"last_name": "",
|
||||
"language_code": "ru",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"bot.app.web.webapp.auth.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=current_user),
|
||||
),
|
||||
patch(
|
||||
"bot.app.web.webapp.auth.user_dal.get_user_by_telegram_id",
|
||||
AsyncMock(return_value=existing_telegram_user),
|
||||
),
|
||||
patch(
|
||||
"bot.app.web.webapp.auth.user_dal.merge_users",
|
||||
AsyncMock(return_value=merged_user),
|
||||
),
|
||||
):
|
||||
result = await _link_telegram_to_user(
|
||||
request,
|
||||
session,
|
||||
current_user_id=-100,
|
||||
telegram_user=telegram_user,
|
||||
settings=SimpleNamespace(DEFAULT_LANGUAGE="ru"),
|
||||
)
|
||||
|
||||
self.assertIs(result, merged_user)
|
||||
panel_service.update_user_details_on_panel.assert_not_awaited()
|
||||
self.assertEqual(merged_user.username, "alice")
|
||||
|
||||
async def test_email_only_session_can_link_existing_telegram_only_account(self):
|
||||
email_user = SimpleNamespace(
|
||||
user_id=-100,
|
||||
email="linked@example.com",
|
||||
email_verified_at=object(),
|
||||
panel_user_uuid="panel-email",
|
||||
telegram_id=None,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
is_banned=False,
|
||||
)
|
||||
telegram_user_record = SimpleNamespace(
|
||||
user_id=42,
|
||||
email=None,
|
||||
email_verified_at=None,
|
||||
panel_user_uuid="panel-telegram",
|
||||
telegram_id=42,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
is_banned=False,
|
||||
)
|
||||
merged_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
email="linked@example.com",
|
||||
email_verified_at=object(),
|
||||
panel_user_uuid="panel-telegram",
|
||||
telegram_id=42,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
is_banned=False,
|
||||
)
|
||||
panel_calls = []
|
||||
|
||||
async def delete_source(*args, **kwargs):
|
||||
panel_calls.append("delete")
|
||||
return True
|
||||
|
||||
async def update_target(*args, **kwargs):
|
||||
panel_calls.append("update")
|
||||
return {"uuid": "panel-telegram"}
|
||||
|
||||
panel_service = SimpleNamespace(
|
||||
delete_user_from_panel=AsyncMock(side_effect=delete_source),
|
||||
update_user_details_on_panel=AsyncMock(side_effect=update_target),
|
||||
)
|
||||
settings = SimpleNamespace(
|
||||
WEBAPP_SESSION_SECRET="session-secret",
|
||||
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||
REDIS_URL=None,
|
||||
REDIS_KEY_PREFIX="test",
|
||||
DEFAULT_LANGUAGE="ru",
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": settings,
|
||||
"async_session_factory": self._AsyncSessionFactory(),
|
||||
"subscription_service": SimpleNamespace(panel_service=panel_service),
|
||||
"email_auth_service": None,
|
||||
"i18n": None,
|
||||
"bot": SimpleNamespace(),
|
||||
},
|
||||
json=AsyncMock(return_value={"init_data": "telegram-init-data"}),
|
||||
)
|
||||
telegram_auth_payload = {
|
||||
"id": 42,
|
||||
"username": "alice",
|
||||
"first_name": "Alice",
|
||||
"last_name": "",
|
||||
"language_code": "ru",
|
||||
}
|
||||
notification_service = SimpleNamespace(
|
||||
notify_account_telegram_linked=AsyncMock(),
|
||||
notify_account_merged=AsyncMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(account_routes, "_require_user_id", return_value=-100),
|
||||
patch.object(
|
||||
account_routes,
|
||||
"_validate_telegram_auth_payload",
|
||||
AsyncMock(return_value=telegram_auth_payload),
|
||||
),
|
||||
patch.object(
|
||||
account_routes.user_dal,
|
||||
"get_user_by_id",
|
||||
AsyncMock(return_value=email_user),
|
||||
),
|
||||
patch.object(
|
||||
account_routes.user_dal,
|
||||
"get_user_by_telegram_id",
|
||||
AsyncMock(return_value=telegram_user_record),
|
||||
),
|
||||
patch.object(
|
||||
account_routes.user_dal,
|
||||
"merge_users",
|
||||
AsyncMock(return_value=merged_user),
|
||||
) as merge_users,
|
||||
patch.object(
|
||||
account_routes.subscription_dal,
|
||||
"get_active_subscription_by_user_id",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"bot.services.notification_service.NotificationService",
|
||||
return_value=notification_service,
|
||||
),
|
||||
):
|
||||
response = await account_routes.account_telegram_link_route(request)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
payload = json.loads(response.text)
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["user_id"], 42)
|
||||
self.assertEqual(payload["telegram_id"], 42)
|
||||
self.assertEqual(payload["account_merge"]["removed_user_id"], -100)
|
||||
self.assertEqual(payload["account_merge"]["primary_user_id"], 42)
|
||||
merge_users.assert_awaited_once_with(
|
||||
request.app["async_session_factory"].session,
|
||||
source_user_id=-100,
|
||||
target_user_id=42,
|
||||
)
|
||||
self.assertEqual(panel_calls, ["delete", "update"])
|
||||
panel_service.delete_user_from_panel.assert_awaited_once_with(
|
||||
"panel-email",
|
||||
log_response=False,
|
||||
)
|
||||
update_uuid, update_payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
||||
self.assertEqual(update_uuid, "panel-telegram")
|
||||
self.assertEqual(update_payload["email"], "linked@example.com")
|
||||
self.assertEqual(update_payload["telegramId"], 42)
|
||||
notification_service.notify_account_merged.assert_awaited_once_with(
|
||||
primary_user_id=42,
|
||||
removed_user_id=-100,
|
||||
email="linked@example.com",
|
||||
telegram_id=42,
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
final_end_date_text="",
|
||||
primary_panel_user_uuid="panel-telegram",
|
||||
removed_panel_user_uuid="panel-email",
|
||||
)
|
||||
self.assertIn("rw_webapp_session", response.cookies)
|
||||
@@ -68,3 +68,13 @@ def test_subscription_purchase_description_settings_i18n_keys_exist():
|
||||
assert field["section"] == "pricing"
|
||||
assert field["i18n_label_key"] in messages
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_payment_provider_settings_include_webhook_metadata():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
assert manifest["FREEKASSA_ENABLED"]["webhook_path"] == "/webhook/freekassa"
|
||||
assert manifest["FREEKASSA_ENABLED"]["provider_id"] == "freekassa"
|
||||
assert manifest["PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU"]["webhook_path"] == "/webhook/platega"
|
||||
assert manifest["YOOKASSA_SHOP_ID"]["webhook_requires_base_url"] is True
|
||||
assert "webhook_path" not in manifest["PAYMENT_STARS_WEBAPP_LABEL_RU"]
|
||||
|
||||
@@ -14,6 +14,20 @@ def test_description_match_ignores_whitespace_shape():
|
||||
assert _description_matches("email@example.com username", "email@example.com\nusername")
|
||||
|
||||
|
||||
def test_description_match_accepts_cp1251_mojibake_from_panel():
|
||||
desired = "user@example.com\nalice\nАлексей\nЧерников"
|
||||
panel_value = "user@example.com\nalice\nÀëåêñåé\n×åðíèêîâ"
|
||||
|
||||
assert _description_matches(panel_value, desired)
|
||||
|
||||
|
||||
def test_description_match_rejects_different_identity_after_mojibake_repair():
|
||||
desired = "user@example.com\nalice\nАлексей"
|
||||
panel_value = "other@example.com\nalice\nÀëåêñåé"
|
||||
|
||||
assert not _description_matches(panel_value, desired)
|
||||
|
||||
|
||||
def test_panel_telegram_id_is_coerced_to_int():
|
||||
assert _coerce_panel_telegram_id("12345") == 12345
|
||||
assert _coerce_panel_telegram_id("") is None
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
"""Tests for ``_resolve_app_version`` — the source of the admin sidebar footer.
|
||||
"""Tests for ``_resolve_app_version``: the admin sidebar footer source.
|
||||
|
||||
The admin sidebar shows ``{appVersion}`` next to a "remnawave-minishop" GitHub
|
||||
link. That string is rendered by
|
||||
``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through the
|
||||
following precedence chain:
|
||||
link. Release builds from ``main`` should render the latest reachable tag and
|
||||
commit sha, without a dirty suffix. Builds from other branches include the
|
||||
branch name. That string is rendered by
|
||||
``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through this
|
||||
precedence chain:
|
||||
|
||||
1. ``REMNAWAVE_MINISHOP_VERSION`` env var — manual override;
|
||||
2. ``$APP_ROOT/.build-version`` file — baked at Docker build time by the
|
||||
``version-builder`` stage in ``deploy/docker/Dockerfile`` (consumes .git
|
||||
in a throwaway stage and ships only this one tiny file);
|
||||
3. live ``git describe`` / ``rev-parse`` — works in local dev where .git
|
||||
is present;
|
||||
1. ``REMNAWAVE_MINISHOP_VERSION`` env var: manual override;
|
||||
2. ``$APP_ROOT/.build-version`` file: baked at Docker build time by the
|
||||
``version-builder`` stage in ``deploy/docker/Dockerfile``;
|
||||
3. live ``git describe`` / ``rev-parse``: local dev fallback where .git is
|
||||
present;
|
||||
4. ``"dev+unknown"`` as the ultimate fallback.
|
||||
|
||||
Before the build-time bake, the admin footer in production silently fell back
|
||||
to step 4 because the Docker image carries no .git tree and no git binary.
|
||||
The Docker image carries no git binary and no .git tree at runtime, so the
|
||||
baked ``.build-version`` file is the production source for the sidebar.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
@@ -28,11 +29,20 @@ from unittest.mock import patch
|
||||
import bot.app.web.subscription_webapp # noqa: F401
|
||||
from bot.app.web.webapp import assets as assets_module
|
||||
|
||||
_VERSION_ENV_NAMES = (
|
||||
"REMNAWAVE_MINISHOP_VERSION",
|
||||
"REMNAWAVE_MINISHOP_BRANCH",
|
||||
"GIT_BRANCH",
|
||||
"BRANCH_NAME",
|
||||
"GITHUB_REF_NAME",
|
||||
"CI_COMMIT_REF_NAME",
|
||||
)
|
||||
|
||||
|
||||
def _reset_cache() -> None:
|
||||
# The resolver memoizes the first result in a module-level global.
|
||||
assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
# Some callers reach through the facade re-export — clear that too.
|
||||
# Some callers reach through the facade re-export; clear that too.
|
||||
runtime = importlib.import_module("bot.app.web.webapp._runtime")
|
||||
runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined]
|
||||
|
||||
@@ -41,6 +51,13 @@ def _resolve():
|
||||
return assets_module._resolve_app_version()
|
||||
|
||||
|
||||
def _clean_version_env() -> dict:
|
||||
env = dict(os.environ)
|
||||
for name in _VERSION_ENV_NAMES:
|
||||
env.pop(name, None)
|
||||
return env
|
||||
|
||||
|
||||
class EnvOverrideTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_cache()
|
||||
@@ -62,12 +79,13 @@ class EnvOverrideTests(unittest.TestCase):
|
||||
self.assertEqual(called["git"], 0)
|
||||
|
||||
def test_blank_env_var_falls_through(self):
|
||||
env = {"REMNAWAVE_MINISHOP_VERSION": " "}
|
||||
env = _clean_version_env()
|
||||
env["REMNAWAVE_MINISHOP_VERSION"] = " "
|
||||
with (
|
||||
patch.dict(os.environ, env),
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "_run_git_command", lambda *a: ""),
|
||||
):
|
||||
# No env, no file, no git → fallback string.
|
||||
# No env, no file, no git: fallback string.
|
||||
self.assertEqual(_resolve(), "dev+unknown")
|
||||
|
||||
|
||||
@@ -80,24 +98,22 @@ class BuildVersionFileTests(unittest.TestCase):
|
||||
|
||||
def test_reads_baked_version_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-version").write_text("v3.4.5+12.gabcdef1", encoding="utf-8")
|
||||
env = dict(os.environ)
|
||||
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
|
||||
(Path(tmp) / ".build-version").write_text("v3.4.5+gabcdef1", encoding="utf-8")
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "APP_ROOT", Path(tmp)),
|
||||
# ensure live git doesn't accidentally win if file read fails:
|
||||
# Ensure live git does not accidentally win if file read fails.
|
||||
patch.object(assets_module, "_run_git_command", lambda *a: ""),
|
||||
):
|
||||
self.assertEqual(_resolve(), "v3.4.5+12.gabcdef1")
|
||||
self.assertEqual(_resolve(), "v3.4.5+gabcdef1")
|
||||
|
||||
def test_strips_trailing_whitespace_and_newlines_in_file(self):
|
||||
# Shell ``printf '%s'`` writes no newline, but earlier helpers used
|
||||
# ``echo`` which appends one. Both must produce the same result.
|
||||
# Shell ``printf '%s'`` writes no newline, but older helpers may have
|
||||
# used ``echo``. Both must produce the same result.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-version").write_text("v1.2.3\n\n", encoding="utf-8")
|
||||
env = dict(os.environ)
|
||||
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "APP_ROOT", Path(tmp)),
|
||||
@@ -108,14 +124,13 @@ class BuildVersionFileTests(unittest.TestCase):
|
||||
def test_empty_file_falls_through_to_git_then_unknown(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / ".build-version").write_text("", encoding="utf-8")
|
||||
env = dict(os.environ)
|
||||
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "APP_ROOT", Path(tmp)),
|
||||
patch.object(assets_module, "_run_git_command", lambda *a: ""),
|
||||
):
|
||||
# No env, empty file, no live git → ultimate fallback.
|
||||
# No env, empty file, no live git: ultimate fallback.
|
||||
self.assertEqual(_resolve(), "dev+unknown")
|
||||
|
||||
|
||||
@@ -126,56 +141,83 @@ class LiveGitFallbackTests(unittest.TestCase):
|
||||
_reset_cache()
|
||||
self.addCleanup(_reset_cache)
|
||||
|
||||
def _run_with_git(self, replies: dict, *, dirty: bool = False) -> str:
|
||||
# Map (subcommand, *args) tuples to canned stdout values.
|
||||
def fake_git(*args):
|
||||
return replies.get(args, "")
|
||||
|
||||
def _run_with_git(self, replies: dict) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# ``.build-version`` deliberately absent so we fall through.
|
||||
env = dict(os.environ)
|
||||
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
|
||||
env = _clean_version_env()
|
||||
base = {
|
||||
("describe", "--tags", "--abbrev=0"): replies.get("tag", ""),
|
||||
("rev-parse", "--short", "HEAD"): replies.get("sha", ""),
|
||||
("status", "--porcelain"): "M file\n" if dirty else "",
|
||||
("rev-list", f"{replies.get('tag', '')}..HEAD", "--count"): replies.get(
|
||||
"commits_since_tag", "0"
|
||||
),
|
||||
("branch", "--show-current"): replies.get("branch", ""),
|
||||
}
|
||||
|
||||
def real_fake_git(*args):
|
||||
def fake_git(*args):
|
||||
return base.get(args, "")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "APP_ROOT", Path(tmp)),
|
||||
patch.object(assets_module, "_run_git_command", real_fake_git),
|
||||
patch.object(assets_module, "_run_git_command", fake_git),
|
||||
):
|
||||
return _resolve()
|
||||
|
||||
def test_tag_with_zero_commits_since_returns_bare_tag(self):
|
||||
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"})
|
||||
self.assertEqual(result, "v2.0.0")
|
||||
def test_tag_with_sha_returns_tag_plus_sha(self):
|
||||
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1"})
|
||||
self.assertEqual(result, "v2.0.0+gabcdef1")
|
||||
|
||||
def test_tag_plus_distance_plus_sha_format(self):
|
||||
def test_main_branch_does_not_add_branch_suffix(self):
|
||||
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "branch": "main"})
|
||||
self.assertEqual(result, "v2.0.0+gabcdef1")
|
||||
|
||||
def test_non_main_branch_adds_branch_suffix(self):
|
||||
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "branch": "dev"})
|
||||
self.assertEqual(result, "v2.0.0-dev+gabcdef1")
|
||||
|
||||
def test_branch_name_is_sanitized_for_version(self):
|
||||
result = self._run_with_git(
|
||||
{"tag": "v2.0.0", "sha": "abcdef1", "branch": "feature/cool build"}
|
||||
)
|
||||
self.assertEqual(result, "v2.0.0-feature-cool-build+gabcdef1")
|
||||
|
||||
def test_commit_distance_is_not_included(self):
|
||||
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"})
|
||||
self.assertEqual(result, "v2.0.0+7.gabcdef1")
|
||||
self.assertEqual(result, "v2.0.0+gabcdef1")
|
||||
|
||||
def test_sha_only_when_no_tag(self):
|
||||
result = self._run_with_git({"sha": "abcdef1"})
|
||||
self.assertEqual(result, "dev+gabcdef1")
|
||||
|
||||
def test_tag_only_when_no_sha(self):
|
||||
result = self._run_with_git({"tag": "v2.0.0"})
|
||||
self.assertEqual(result, "v2.0.0")
|
||||
|
||||
def test_neither_tag_nor_sha_is_unknown(self):
|
||||
result = self._run_with_git({})
|
||||
self.assertEqual(result, "dev+unknown")
|
||||
|
||||
def test_dirty_suffix_is_appended(self):
|
||||
result = self._run_with_git(
|
||||
{"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"},
|
||||
dirty=True,
|
||||
)
|
||||
self.assertEqual(result, "v2.0.0-dirty")
|
||||
def test_dirty_suffix_is_not_appended_or_queried(self):
|
||||
calls = []
|
||||
|
||||
def fake_git(*args):
|
||||
calls.append(args)
|
||||
replies = {
|
||||
("describe", "--tags", "--abbrev=0"): "v2.0.0",
|
||||
("rev-parse", "--short", "HEAD"): "abcdef1",
|
||||
("status", "--porcelain"): "M file\n",
|
||||
}
|
||||
return replies.get(args, "")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "APP_ROOT", Path(tmp)),
|
||||
patch.object(assets_module, "_run_git_command", fake_git),
|
||||
):
|
||||
result = _resolve()
|
||||
|
||||
self.assertEqual(result, "v2.0.0+gabcdef1")
|
||||
self.assertNotIn(("status", "--porcelain"), calls)
|
||||
|
||||
|
||||
class CacheBehaviourTests(unittest.TestCase):
|
||||
@@ -191,8 +233,7 @@ class CacheBehaviourTests(unittest.TestCase):
|
||||
return "abcdef1" if args == ("rev-parse", "--short", "HEAD") else ""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env = dict(os.environ)
|
||||
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
|
||||
env = _clean_version_env()
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(assets_module, "APP_ROOT", Path(tmp)),
|
||||
|
||||
@@ -722,6 +722,61 @@ class AdminSettingsSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(secret_field["has_value"])
|
||||
self.assertNotIn("super-secret", response.text)
|
||||
|
||||
async def test_admin_settings_exposes_payment_webhook_urls(self):
|
||||
class AsyncSessionFactory:
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return object()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
SHOP_NAME="Visible shop",
|
||||
WEBHOOK_BASE_URL="https://web.tnnl.cc/",
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={"settings": settings, "async_session_factory": AsyncSessionFactory()},
|
||||
headers={},
|
||||
cookies={},
|
||||
admin_telegram_id=1,
|
||||
)
|
||||
request.get = lambda key, default=None: getattr(request, key, default)
|
||||
|
||||
with (
|
||||
patch.object(admin_settings_routes, "_require_admin_user_id", return_value=1),
|
||||
patch.object(
|
||||
admin_api.app_settings_dal,
|
||||
"get_overrides_with_meta",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
):
|
||||
response = await admin_api.admin_settings_get_route(request)
|
||||
|
||||
payload = json.loads(response.text)
|
||||
fields = {
|
||||
field["key"]: field
|
||||
for section in payload["sections"]
|
||||
for field in section["fields"]
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
fields["FREEKASSA_ENABLED"]["webhook_url"],
|
||||
"https://web.tnnl.cc/webhook/freekassa",
|
||||
)
|
||||
self.assertTrue(fields["FREEKASSA_ENABLED"]["webhook_base_url_configured"])
|
||||
self.assertEqual(
|
||||
fields["PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU"]["webhook_url"],
|
||||
"https://web.tnnl.cc/webhook/platega",
|
||||
)
|
||||
self.assertNotIn("webhook_url", fields["PAYMENT_STARS_WEBAPP_LABEL_RU"])
|
||||
|
||||
|
||||
class DatabaseLoggingSecurityTests(unittest.TestCase):
|
||||
def test_database_url_redaction_hides_password(self):
|
||||
|
||||
@@ -364,3 +364,51 @@ def test_support_user_reply_can_send_email_without_telegram_channels():
|
||||
|
||||
assert channels == []
|
||||
assert emails[0]["ticket_id"] == 7
|
||||
|
||||
|
||||
def test_account_merge_notification_goes_to_log_channel():
|
||||
messages = []
|
||||
|
||||
class I18n:
|
||||
def gettext(self, _language, key, **kwargs):
|
||||
if key == "log_open_profile_link":
|
||||
return "Open profile"
|
||||
assert key == "log_account_merged"
|
||||
return (
|
||||
f"merged primary={kwargs['primary_user_id']} "
|
||||
f"removed={kwargs['removed_user_id']} "
|
||||
f"email={kwargs['email']} end={kwargs['final_end_date']}"
|
||||
)
|
||||
|
||||
service = NotificationService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=_settings(LOG_CHAT_ID=-100123, DEFAULT_LANGUAGE="en"),
|
||||
i18n=I18n(),
|
||||
)
|
||||
|
||||
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
|
||||
messages.append((message, thread_id, reply_markup))
|
||||
|
||||
service._send_to_log_channel = send_to_log_channel
|
||||
|
||||
asyncio.run(
|
||||
service.notify_account_merged(
|
||||
primary_user_id=42,
|
||||
removed_user_id=-100,
|
||||
email="paid@example.com",
|
||||
telegram_id=100200300,
|
||||
username="alice",
|
||||
first_name="Alice",
|
||||
final_end_date_text="2026-06-21 10:00",
|
||||
primary_panel_user_uuid="panel-telegram",
|
||||
removed_panel_user_uuid="panel-email",
|
||||
)
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
message, thread_id, reply_markup = messages[0]
|
||||
assert "primary=42" in message
|
||||
assert "removed=-100" in message
|
||||
assert "paid@example.com" in message
|
||||
assert thread_id is None
|
||||
assert reply_markup.inline_keyboard[0][0].url == "tg://user?id=100200300"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -617,3 +618,107 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertIsNone(result)
|
||||
panel_service.get_all_panel_users.assert_not_awaited()
|
||||
|
||||
async def test_missing_panel_subscription_repairs_to_user_panel_uuid(self):
|
||||
panel_service = AsyncMock(spec=PanelApiService)
|
||||
worker = TariffTrafficWorker(
|
||||
settings=SimpleNamespace(),
|
||||
session_factory=SimpleNamespace(),
|
||||
panel_service=panel_service,
|
||||
subscription_service=SimpleNamespace(),
|
||||
)
|
||||
sub = SimpleNamespace(
|
||||
subscription_id=10,
|
||||
user_id=123,
|
||||
panel_user_uuid="old-panel",
|
||||
is_active=True,
|
||||
status_from_panel="ACTIVE",
|
||||
skip_notifications=False,
|
||||
)
|
||||
panel_user = {"uuid": "new-panel", "username": "tg_123"}
|
||||
|
||||
with patch(
|
||||
"bot.services.tariff_worker.user_dal.get_user_by_id",
|
||||
new=AsyncMock(return_value=SimpleNamespace(panel_user_uuid="new-panel")),
|
||||
):
|
||||
result = await worker._repair_missing_panel_user_for_subscription(
|
||||
AsyncMock(),
|
||||
sub,
|
||||
panel_users_by_uuid={"new-panel": panel_user},
|
||||
semaphore=asyncio.Semaphore(1),
|
||||
confirmed_missing=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result, panel_user)
|
||||
self.assertEqual(sub.panel_user_uuid, "new-panel")
|
||||
self.assertTrue(sub.is_active)
|
||||
panel_service.get_user_by_uuid.assert_not_awaited()
|
||||
|
||||
async def test_missing_panel_subscription_deactivates_when_bulk_prefetch_confirms_absent(self):
|
||||
panel_service = AsyncMock(spec=PanelApiService)
|
||||
worker = TariffTrafficWorker(
|
||||
settings=SimpleNamespace(),
|
||||
session_factory=SimpleNamespace(),
|
||||
panel_service=panel_service,
|
||||
subscription_service=SimpleNamespace(),
|
||||
)
|
||||
sub = SimpleNamespace(
|
||||
subscription_id=11,
|
||||
user_id=123,
|
||||
panel_user_uuid="missing-panel",
|
||||
is_active=True,
|
||||
status_from_panel="ACTIVE",
|
||||
skip_notifications=False,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"bot.services.tariff_worker.user_dal.get_user_by_id",
|
||||
new=AsyncMock(return_value=SimpleNamespace(panel_user_uuid="missing-panel")),
|
||||
):
|
||||
result = await worker._repair_missing_panel_user_for_subscription(
|
||||
AsyncMock(),
|
||||
sub,
|
||||
panel_users_by_uuid={},
|
||||
semaphore=asyncio.Semaphore(1),
|
||||
confirmed_missing=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
self.assertFalse(sub.is_active)
|
||||
self.assertTrue(sub.skip_notifications)
|
||||
self.assertEqual(sub.status_from_panel, "PANEL_USER_NOT_FOUND")
|
||||
|
||||
async def test_missing_panel_subscription_only_skips_when_absence_is_not_confirmed(self):
|
||||
panel_service = AsyncMock(spec=PanelApiService)
|
||||
panel_service.get_user_by_uuid = AsyncMock(return_value=None)
|
||||
worker = TariffTrafficWorker(
|
||||
settings=SimpleNamespace(),
|
||||
session_factory=SimpleNamespace(),
|
||||
panel_service=panel_service,
|
||||
subscription_service=SimpleNamespace(),
|
||||
)
|
||||
sub = SimpleNamespace(
|
||||
subscription_id=12,
|
||||
user_id=123,
|
||||
panel_user_uuid="missing-panel",
|
||||
is_active=True,
|
||||
status_from_panel="ACTIVE",
|
||||
skip_notifications=False,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"bot.services.tariff_worker.user_dal.get_user_by_id",
|
||||
new=AsyncMock(return_value=SimpleNamespace(panel_user_uuid="missing-panel")),
|
||||
):
|
||||
result = await worker._repair_missing_panel_user_for_subscription(
|
||||
AsyncMock(),
|
||||
sub,
|
||||
panel_users_by_uuid=None,
|
||||
semaphore=asyncio.Semaphore(1),
|
||||
confirmed_missing=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {})
|
||||
self.assertTrue(sub.is_active)
|
||||
self.assertFalse(sub.skip_notifications)
|
||||
self.assertEqual(sub.status_from_panel, "ACTIVE")
|
||||
|
||||
+107
-1
@@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -131,3 +131,109 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("user_payment_methods", delete_tables)
|
||||
self.assertIn("promo_code_activations", delete_tables)
|
||||
session.delete.assert_awaited_once_with(source)
|
||||
|
||||
async def test_merge_users_moves_active_email_subscription_onto_expired_telegram_account(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
source = SimpleNamespace(
|
||||
user_id=-100,
|
||||
email="paid@example.com",
|
||||
telegram_id=None,
|
||||
panel_user_uuid="panel-email",
|
||||
email_verified_at=before,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
channel_subscription_verified=False,
|
||||
channel_subscription_checked_at=None,
|
||||
channel_subscription_verified_for=None,
|
||||
lifetime_used_traffic_bytes=0,
|
||||
referred_by_id=None,
|
||||
referral_code=None,
|
||||
)
|
||||
target = SimpleNamespace(
|
||||
user_id=42,
|
||||
email=None,
|
||||
telegram_id=42,
|
||||
panel_user_uuid="panel-telegram",
|
||||
email_verified_at=None,
|
||||
username="old",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
telegram_photo_url=None,
|
||||
channel_subscription_verified=False,
|
||||
channel_subscription_checked_at=None,
|
||||
channel_subscription_verified_for=None,
|
||||
lifetime_used_traffic_bytes=0,
|
||||
referred_by_id=None,
|
||||
referral_code=None,
|
||||
)
|
||||
source_active_sub = SimpleNamespace(
|
||||
end_date=before + timedelta(days=30),
|
||||
is_active=True,
|
||||
skip_notifications=False,
|
||||
last_notification_sent=before,
|
||||
status_from_panel="ACTIVE",
|
||||
panel_user_uuid="panel-email",
|
||||
)
|
||||
expired_target_sub = SimpleNamespace(
|
||||
end_date=before - timedelta(days=3),
|
||||
is_active=False,
|
||||
skip_notifications=False,
|
||||
last_notification_sent=before,
|
||||
status_from_panel="EXPIRED",
|
||||
panel_user_uuid="panel-telegram",
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=lambda stmt: FakeResult()),
|
||||
delete=AsyncMock(),
|
||||
flush=AsyncMock(),
|
||||
refresh=AsyncMock(),
|
||||
)
|
||||
|
||||
async def fake_get_user_by_id(_session, user_id):
|
||||
if user_id == source.user_id:
|
||||
return source
|
||||
if user_id == target.user_id:
|
||||
return target
|
||||
return None
|
||||
|
||||
async def fake_get_active_subscription(_session, user_id, panel_user_uuid=None):
|
||||
if user_id == source.user_id and panel_user_uuid == source.panel_user_uuid:
|
||||
return source_active_sub
|
||||
return None
|
||||
|
||||
async def fake_get_latest_subscription(_session, user_id, panel_user_uuid=None, **_kwargs):
|
||||
if user_id == target.user_id and panel_user_uuid == target.panel_user_uuid:
|
||||
return expired_target_sub
|
||||
return None
|
||||
|
||||
with (
|
||||
patch("db.dal.user_dal.get_user_by_id", side_effect=fake_get_user_by_id),
|
||||
patch(
|
||||
"db.dal.user_dal._get_active_subscription_for_user",
|
||||
side_effect=fake_get_active_subscription,
|
||||
),
|
||||
patch(
|
||||
"db.dal.user_dal._get_latest_subscription_for_user",
|
||||
side_effect=fake_get_latest_subscription,
|
||||
),
|
||||
):
|
||||
merged = await user_dal.merge_users(
|
||||
session,
|
||||
source_user_id=source.user_id,
|
||||
target_user_id=target.user_id,
|
||||
)
|
||||
|
||||
self.assertIs(merged, target)
|
||||
self.assertEqual(target.email, "paid@example.com")
|
||||
self.assertTrue(expired_target_sub.is_active)
|
||||
self.assertEqual(expired_target_sub.status_from_panel, "ACTIVE_EXTENDED_BY_MERGE")
|
||||
self.assertIsNone(expired_target_sub.last_notification_sent)
|
||||
self.assertGreater(expired_target_sub.end_date, before + timedelta(days=29))
|
||||
self.assertLess(expired_target_sub.end_date, before + timedelta(days=31))
|
||||
self.assertFalse(source_active_sub.is_active)
|
||||
self.assertTrue(source_active_sub.skip_notifications)
|
||||
self.assertEqual(source_active_sub.status_from_panel, "MERGED_INTO_ACCOUNT")
|
||||
|
||||
@@ -15,6 +15,7 @@ from PIL import Image
|
||||
from bot.app.web import subscription_webapp
|
||||
from bot.app.web.admin_api_impl import themes as admin_themes
|
||||
from bot.app.web.webapp import assets as webapp_assets
|
||||
from bot.app.web.webapp import cache_helpers
|
||||
from config.settings import Settings
|
||||
from config.webapp_themes_config import WebappThemesConfig, builtin_webapp_themes_config
|
||||
|
||||
@@ -449,6 +450,58 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
[{"id": "yookassa", "name": "Bank card", "icon": "WalletCards"}],
|
||||
)
|
||||
|
||||
def test_serialize_payment_methods_includes_wata_from_provider_config(self):
|
||||
from bot.payment_providers import build_provider_configs, get_provider_bundle
|
||||
|
||||
build_provider_configs(force=True)
|
||||
bundle = get_provider_bundle("wata_service")
|
||||
self.assertIsNotNone(bundle)
|
||||
bundle.config.ENABLED = True
|
||||
bundle.config.API_TOKEN = "wata-token"
|
||||
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TARIFFS_CONFIG_PATH="missing-tariffs.json",
|
||||
PAYMENT_METHODS_ORDER="wata",
|
||||
STARS_ENABLED=False,
|
||||
)
|
||||
app = {"wata_service": SimpleNamespace(configured=True)}
|
||||
|
||||
methods = subscription_webapp._serialize_payment_methods(settings, app, "en")
|
||||
|
||||
self.assertEqual(methods, [{"id": "wata", "name": "Wata", "icon": "WalletCards"}])
|
||||
|
||||
async def test_invalidate_all_webapp_user_caches_clears_cached_me_payload(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
REDIS_URL=None,
|
||||
)
|
||||
calls = 0
|
||||
|
||||
async def loader():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"payment_methods": [{"id": f"method-{calls}"}]}
|
||||
|
||||
first = await cache_helpers.webapp_cached_user_payload(settings, "me", 42, 60, loader)
|
||||
second = await cache_helpers.webapp_cached_user_payload(settings, "me", 42, 60, loader)
|
||||
|
||||
self.assertEqual(first, {"payment_methods": [{"id": "method-1"}]})
|
||||
self.assertEqual(second, first)
|
||||
self.assertEqual(calls, 1)
|
||||
|
||||
await cache_helpers.invalidate_all_webapp_user_caches(settings)
|
||||
third = await cache_helpers.webapp_cached_user_payload(settings, "me", 42, 60, loader)
|
||||
|
||||
self.assertEqual(third, {"payment_methods": [{"id": "method-2"}]})
|
||||
self.assertEqual(calls, 2)
|
||||
|
||||
def test_serialize_plans_includes_stars_only_subscription_options(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
@@ -488,7 +541,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
"subscription_webapp.min.22222222.js",
|
||||
)
|
||||
|
||||
def test_resolve_webapp_admin_asset_names_prefer_latest_minified_builds(self):
|
||||
def test_resolve_webapp_admin_asset_names_use_stable_runtime_builds(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
asset_dir = Path(tmpdir)
|
||||
(asset_dir / "subscription_webapp_admin.js").write_text(
|
||||
@@ -513,11 +566,11 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
|
||||
self.assertEqual(
|
||||
subscription_webapp._resolve_webapp_admin_js_asset_name(),
|
||||
"subscription_webapp_admin.min.22222222.js",
|
||||
"subscription_webapp_admin.js",
|
||||
)
|
||||
self.assertEqual(
|
||||
subscription_webapp._resolve_webapp_admin_css_asset_name(),
|
||||
"subscription_webapp_admin.22222222.css",
|
||||
"subscription_webapp_admin.css",
|
||||
)
|
||||
|
||||
async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
return (REPO_ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_telegram_init_data_login_runs_before_manual_logout_gate():
|
||||
source = _read("frontend/src/lib/webapp/webappBoot.js")
|
||||
|
||||
init_data_pos = source.index("const initData = getInitDataForBoot();")
|
||||
manual_logout_pos = source.index("if (isManuallyLoggedOut())")
|
||||
|
||||
assert init_data_pos < manual_logout_pos
|
||||
|
||||
|
||||
def test_logout_button_is_controlled_by_telegram_context():
|
||||
app_source = _read("frontend/src/App.svelte")
|
||||
settings_source = _read("frontend/src/webapp/screens/SettingsScreen.svelte")
|
||||
|
||||
assert "$: telegramMiniAppContext = hasTelegramLaunchParams();" in app_source
|
||||
assert "showLogout={!telegramMiniAppContext}" in app_source
|
||||
assert "export let showLogout = true;" in settings_source
|
||||
assert "{#if showLogout}" in settings_source
|
||||
|
||||
|
||||
def test_logout_handler_is_noop_inside_telegram_mini_app():
|
||||
source = _read("frontend/src/lib/webapp/stores/accountStore.js")
|
||||
|
||||
guard_pos = source.index("if (telegramSdk.hasLaunchParams()) return;")
|
||||
mark_logout_pos = source.index("markManualLogout();")
|
||||
|
||||
assert guard_pos < mark_logout_pos
|
||||
Reference in New Issue
Block a user