Merge pull request #1 from 3252a8/dev

Stabilize account linking, auth, and admin settings
This commit is contained in:
3252a8
2026-05-22 22:24:07 +03:00
committed by GitHub
31 changed files with 1728 additions and 213 deletions
@@ -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"] = [
{
+33 -38
View File
@@ -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
+48 -23
View File
@@ -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:
+143 -2
View File
@@ -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:
+35 -1
View File
@@ -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
+53 -19
View File
@@ -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",
]
+40
View File
@@ -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():
+81 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+12 -3
View File
@@ -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 {
+5 -5
View File
@@ -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();
+79 -32
View File
@@ -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
+55
View File
@@ -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>
+6
View File
@@ -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",
+6
View File
@@ -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": "Синхронизация запущена",
+343
View File
@@ -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
View File
@@ -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
+95 -54
View File
@@ -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)),
+55
View File
@@ -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):
+48
View File
@@ -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"
+105
View File
@@ -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
View File
@@ -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")
+56 -3
View File
@@ -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):
+35
View File
@@ -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