Merge branch 'dev' into feature/install-page

# Conflicts:
#	backend/bot/app/web/admin_api_impl/settings.py
#	backend/bot/app/web/webapp/cache_helpers.py
#	frontend/src/admin/sections/SettingsSection.svelte
#	tests/test_admin_settings_manifest_i18n.py
This commit is contained in:
3252a8
2026-05-22 22:57:24 +03:00
31 changed files with 2093 additions and 219 deletions
@@ -19,6 +19,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
overrides_by_key = {entry["key"]: entry for entry in overrides} overrides_by_key = {entry["key"]: entry for entry in overrides}
fields = manifest_payload() fields = manifest_payload()
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
sections: Dict[str, Dict[str, Any]] = {} sections: Dict[str, Dict[str, Any]] = {}
for field in fields: for field in fields:
key = field["key"] key = field["key"]
@@ -53,6 +54,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
response_field["read_error"] = read_error response_field["read_error"] = read_error
if is_secret: if is_secret:
response_field["has_value"] = bool(value) 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) sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
@@ -562,7 +562,11 @@ def manifest_payload() -> List[dict]:
same value so existing UIs that only read ``placeholder`` also show the same value so existing UIs that only read ``placeholder`` also show the
hint inside the empty input. 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 = { sections_order = {
"general": 1, "general": 1,
@@ -587,10 +591,12 @@ def manifest_payload() -> List[dict]:
) )
default_value: Optional[str] = None default_value: Optional[str] = None
webhook_metadata: Optional[dict] = None
owner = find_manifest_owner(field.key) owner = find_manifest_owner(field.key)
if owner is not None: if owner is not None:
spec, manifest_field = owner spec, manifest_field = owner
default_value = manifest_field_default(spec, manifest_field) default_value = manifest_field_default(spec, manifest_field)
webhook_metadata = provider_webhook_metadata(spec)
placeholder = field.placeholder placeholder = field.placeholder
if not placeholder and default_value: if not placeholder and default_value:
@@ -617,6 +623,8 @@ def manifest_payload() -> List[dict]:
} }
if default_value is not None: if default_value is not None:
item["default"] = default_value item["default"] = default_value
if webhook_metadata:
item.update(webhook_metadata)
if field.choices: if field.choices:
item["choices"] = [ item["choices"] = [
{ {
+33 -38
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405 from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload 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 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 = email
current_user.email_verified_at = datetime.now(timezone.utc) 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() await session.commit()
final_user_id = int(current_user.user_id) final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user) 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 = ( merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None 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, request,
current_user, current_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date, 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") email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email: 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") return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) 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: if should_notify_email_linked:
try: try:
from bot.services.notification_service import NotificationService 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 = ( merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None 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, request,
db_user, db_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date, 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") email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email: 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") return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True) 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: if should_notify_telegram_linked and final_telegram_id:
try: try:
from bot.services.notification_service import NotificationService 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() 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: def _resolve_app_version() -> str:
global _APP_VERSION_CACHE global _APP_VERSION_CACHE
if _APP_VERSION_CACHE: if _APP_VERSION_CACHE:
@@ -855,21 +894,8 @@ def _resolve_app_version() -> str:
tag = _run_git_command("describe", "--tags", "--abbrev=0") tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD") sha = _run_git_command("rev-parse", "--short", "HEAD")
dirty = bool(_run_git_command("status", "--porcelain")) branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
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"
_APP_VERSION_CACHE = version _APP_VERSION_CACHE = version
return version return version
@@ -1372,10 +1398,10 @@ def _resolve_webapp_js_asset_name() -> str:
def _resolve_webapp_admin_js_asset_name() -> str: def _resolve_webapp_admin_js_asset_name() -> str:
return _resolve_hashed_js_asset_name( # The admin bundle is lazy-loaded from the already running Mini App. In
kind="admin-js", # deployments where nginx serves static files in front of aiohttp, stale
base_name="subscription_webapp_admin", # 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: 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: def _resolve_webapp_admin_css_asset_name() -> str:
return _resolve_hashed_css_asset_name( # Keep the lazy-loaded admin stylesheet on the stable build filename for
kind="admin-css", # the same reason as the JS bundle above.
base_name="subscription_webapp_admin", return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
)
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str: 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 "/" redirect_path = "/settings" if purpose == "link" else "/"
async_session_factory: sessionmaker = request.app["async_session_factory"] async_session_factory: sessionmaker = request.app["async_session_factory"]
final_user_id: Optional[int] = None 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: async with async_session_factory() as session:
try: try:
if purpose == "link": if purpose == "link":
current_user_id = int(state.get("user_id") or 0) 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( db_user = await _link_telegram_to_user(
request, request,
session, session,
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
telegram_user=telegram_user, telegram_user=telegram_user,
settings=settings, 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: else:
db_user = await _ensure_user_from_telegram( db_user = await _ensure_user_from_telegram(
session, session,
@@ -388,6 +408,34 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
raise redirect(redirect_path, "failed") raise redirect(redirect_path, "failed")
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True) 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)) token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response) _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() 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( async def _sync_panel_identity_for_user(
request: web.Request, request: web.Request,
user: User, user: User,
@@ -995,7 +1051,11 @@ async def _sync_panel_identity_for_user(
if user.email: if user.email:
payload["email"] = user.email payload["email"] = user.email
if expire_at is not None: 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") payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if expire_at > datetime.now(timezone.utc):
payload["status"] = "ACTIVE"
try: try:
await subscription_service.panel_service.update_user_details_on_panel( await subscription_service.panel_service.update_user_details_on_panel(
@@ -1013,6 +1073,53 @@ async def _sync_panel_identity_for_user(
return False 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( async def _build_account_merge_notice(
session: AsyncSession, 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( def _apply_telegram_profile_to_user(
user: User, user: User,
telegram_user: Dict[str, Any], 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) _apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush() await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0: 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) _apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush() await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id: if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
+23 -5
View File
@@ -75,15 +75,25 @@ def invalidate_local_webapp_user_payload(
def invalidate_all_local_webapp_user_payloads( def invalidate_all_local_webapp_user_payloads(
settings: Settings, settings: Settings,
namespace: Optional[str] = None,
*, *,
include_devices: bool = False, include_devices: Optional[bool] = None,
) -> None: ) -> None:
namespaces = set(_payload_namespaces(include_devices)) if include_devices is not None:
namespaces: Optional[set[str]] = set(_payload_namespaces(include_devices))
elif namespace is not None:
namespaces = {namespace}
else:
namespaces = None
for (settings_id, cache_namespace, _ttl), cache in tuple( for (settings_id, cache_namespace, _ttl), cache in tuple(
_WEBAPP_USER_PAYLOAD_CACHES.items() _WEBAPP_USER_PAYLOAD_CACHES.items()
): ):
if settings_id == id(settings) and cache_namespace in namespaces: if settings_id != id(settings):
cache.invalidate() continue
if namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
async def invalidate_webapp_user_caches( async def invalidate_webapp_user_caches(
@@ -117,10 +127,18 @@ async def invalidate_all_webapp_user_payloads(
*, *,
include_devices: bool = False, include_devices: bool = False,
) -> None: ) -> None:
invalidate_all_local_webapp_user_payloads(settings, include_devices=include_devices)
for namespace in _payload_namespaces(include_devices): for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try: try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*") pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern) await cache_delete_pattern(settings, pattern)
except Exception: except Exception:
continue continue
async def invalidate_all_webapp_user_caches(
settings: Settings,
*,
include_devices: bool = False,
) -> None:
await invalidate_all_webapp_user_payloads(settings, include_devices=include_devices)
+329 -21
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Optional, Union from typing import Any, Optional, Union
from aiogram import Bot, Router, types from aiogram import Bot, Router, types
@@ -42,8 +42,55 @@ def _normalize_description(value: Optional[str]) -> str:
return "\n".join((value or "").split()).strip() 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: 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: def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
@@ -61,6 +108,21 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc) return value.astimezone(timezone.utc)
def _panel_expire_at(panel_user: dict[str, Any]) -> Optional[datetime]:
raw_value = panel_user.get("expireAt")
if not raw_value:
return None
try:
return datetime.fromisoformat(str(raw_value).replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
def _panel_subscription_uuid(panel_user: dict[str, Any]) -> Optional[str]:
value = panel_user.get("subscriptionUuid") or panel_user.get("shortUuid")
return str(value) if value else None
def _should_update_lifetime_used_traffic( def _should_update_lifetime_used_traffic(
existing_user, existing_user,
lifetime_used: int, lifetime_used: int,
@@ -303,6 +365,211 @@ async def _bind_panel_email_to_user(
return existing_user, True return existing_user, True
async def _merge_local_duplicate_panel_user_if_needed(
session: AsyncSession,
*,
existing_user,
duplicate_panel_uuid: str,
):
duplicate_local_user = await user_dal.get_user_by_panel_uuid(session, duplicate_panel_uuid)
if not duplicate_local_user or duplicate_local_user.user_id == existing_user.user_id:
return existing_user, True
try:
merged_user = await user_dal.merge_users(
session,
source_user_id=duplicate_local_user.user_id,
target_user_id=existing_user.user_id,
)
logging.info(
"Sync: merged local duplicate user %s into %s for duplicate panel UUID %s.",
duplicate_local_user.user_id,
merged_user.user_id,
duplicate_panel_uuid,
)
return merged_user, True
except Exception as exc:
logging.warning(
"Sync: could not merge local duplicate user %s into %s for panel UUID %s: %s",
duplicate_local_user.user_id,
existing_user.user_id,
duplicate_panel_uuid,
exc,
)
return existing_user, False
def _panel_identity_payload_with_expiry(
user,
*,
expire_at: datetime,
) -> dict[str, Any]:
description_text = "\n".join(
line
for line in [
user.email or "",
user.username or "",
user.first_name or "",
user.last_name or "",
]
if line
)
payload = _panel_identity_update_payload(user, description_text)
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if expire_at > datetime.now(timezone.utc):
payload["status"] = "ACTIVE"
return payload
async def _absorb_duplicate_panel_identity(
session: AsyncSession,
*,
panel_service: PanelApiService,
existing_user,
keep_panel_uuid: str,
keep_panel_user: Optional[dict[str, Any]],
duplicate_panel_user: dict[str, Any],
settings: Settings,
subscriptions_by_panel_uuid: dict[str, Subscription],
active_subscriptions_by_user_panel: dict[tuple[int, str], Subscription],
) -> dict[str, int | bool]:
duplicate_panel_uuid = str(duplicate_panel_user.get("uuid") or "")
if not duplicate_panel_uuid:
return {"resolved": False, "subscriptions_created": 0, "subscriptions_updated": 0}
subscriptions_created = 0
subscriptions_updated = 0
now = datetime.now(timezone.utc)
duplicate_expire_at = _panel_expire_at(duplicate_panel_user)
duplicate_status = str(duplicate_panel_user.get("status") or "").upper()
duplicate_is_active = bool(
duplicate_expire_at and duplicate_status == "ACTIVE" and duplicate_expire_at > now
)
keep_subscription_uuid = _panel_subscription_uuid(keep_panel_user or {})
target_sub = (
subscriptions_by_panel_uuid.get(keep_subscription_uuid) if keep_subscription_uuid else None
)
if not target_sub:
target_sub = active_subscriptions_by_user_panel.get(
(int(existing_user.user_id), keep_panel_uuid)
)
final_end_date: Optional[datetime] = None
if duplicate_is_active and duplicate_expire_at:
source_remaining = max(timedelta(0), duplicate_expire_at - now)
if target_sub:
target_end = _as_utc(target_sub.end_date)
base_end = target_end if target_end > now else now
final_end_date = base_end + source_remaining
update_payload: dict[str, Any] = {
"user_id": int(existing_user.user_id),
"panel_user_uuid": keep_panel_uuid,
"end_date": final_end_date,
"is_active": True,
"status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE",
}
if keep_subscription_uuid:
update_payload["panel_subscription_uuid"] = keep_subscription_uuid
update_delta = _subscription_update_delta(target_sub, update_payload)
if update_delta:
await subscription_dal.update_subscription(
session,
target_sub.subscription_id,
update_delta,
)
for key, value in update_delta.items():
setattr(target_sub, key, value)
subscriptions_updated += 1
elif keep_subscription_uuid:
final_end_date = now + (duplicate_expire_at - now)
created_sub = await subscription_dal.upsert_subscription(
session,
{
"user_id": int(existing_user.user_id),
"panel_user_uuid": keep_panel_uuid,
"panel_subscription_uuid": keep_subscription_uuid,
"start_date": None,
"end_date": final_end_date,
"duration_months": None,
"is_active": True,
"status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE",
"traffic_limit_bytes": getattr(settings, "user_traffic_limit_bytes", 0),
"auto_renew_enabled": False,
},
)
subscriptions_by_panel_uuid[keep_subscription_uuid] = created_sub
active_subscriptions_by_user_panel[
(int(created_sub.user_id), created_sub.panel_user_uuid)
] = created_sub
subscriptions_created += 1
duplicate_subscription_uuid = _panel_subscription_uuid(duplicate_panel_user)
duplicate_sub = (
subscriptions_by_panel_uuid.get(duplicate_subscription_uuid)
if duplicate_subscription_uuid
else None
)
if duplicate_sub and duplicate_sub is not target_sub:
await subscription_dal.update_subscription(
session,
duplicate_sub.subscription_id,
{
"user_id": int(existing_user.user_id),
"is_active": False,
"skip_notifications": True,
"status_from_panel": "MERGED_PANEL_DUPLICATE",
},
)
duplicate_sub.user_id = int(existing_user.user_id)
duplicate_sub.is_active = False
duplicate_sub.skip_notifications = True
duplicate_sub.status_from_panel = "MERGED_PANEL_DUPLICATE"
subscriptions_updated += 1
elif not duplicate_sub:
await session.execute(
update(Subscription)
.where(Subscription.panel_user_uuid == duplicate_panel_uuid)
.values(
user_id=int(existing_user.user_id),
is_active=False,
skip_notifications=True,
status_from_panel="MERGED_PANEL_DUPLICATE",
)
)
if final_end_date:
await panel_service.update_user_details_on_panel(
keep_panel_uuid,
_panel_identity_payload_with_expiry(existing_user, expire_at=final_end_date),
log_response=False,
)
deleted = await panel_service.delete_user_from_panel(
duplicate_panel_uuid,
log_response=False,
)
if deleted:
logging.info(
"Sync: absorbed duplicate panel UUID %s into kept panel UUID %s for user %s.",
duplicate_panel_uuid,
keep_panel_uuid,
existing_user.user_id,
)
else:
logging.warning(
"Sync: failed to delete duplicate panel UUID %s after absorbing it into %s.",
duplicate_panel_uuid,
keep_panel_uuid,
)
return {
"resolved": bool(deleted),
"subscriptions_created": subscriptions_created,
"subscriptions_updated": subscriptions_updated,
}
async def perform_sync( async def perform_sync(
panel_service: PanelApiService, panel_service: PanelApiService,
session: AsyncSession, session: AsyncSession,
@@ -383,6 +650,11 @@ async def _perform_sync_impl(
subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"] subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"]
active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"] active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"]
panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"] panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"]
panel_users_by_uuid = {
str(panel_user["uuid"]): panel_user
for panel_user in panel_users_data
if panel_user.get("uuid")
}
for panel_user_dict in panel_users_data: for panel_user_dict in panel_users_data:
try: try:
@@ -532,12 +804,61 @@ async def _perform_sync_impl(
) )
if linked_uuid_still_present: if linked_uuid_still_present:
is_duplicate_panel_identity = True is_duplicate_panel_identity = True
(
existing_user,
can_absorb_duplicate_panel_user,
) = await _merge_local_duplicate_panel_user_if_needed(
session,
existing_user=existing_user,
duplicate_panel_uuid=panel_uuid,
)
if not can_absorb_duplicate_panel_user:
logging.warning(
"Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s because local duplicate merge failed.", # noqa: E501
telegram_id_from_panel,
linked_uuid,
panel_uuid,
)
continue
actual_user_id = existing_user.user_id
users_by_panel_uuid[linked_uuid] = existing_user
if existing_user.telegram_id is not None:
users_by_telegram_id[int(existing_user.telegram_id)] = existing_user
users_by_user_id[int(existing_user.user_id)] = existing_user
if existing_user.email:
users_by_email[existing_user.email.strip().lower()] = existing_user
merge_result = await _absorb_duplicate_panel_identity(
session,
panel_service=panel_service,
existing_user=existing_user,
keep_panel_uuid=str(linked_uuid),
keep_panel_user=panel_users_by_uuid.get(str(linked_uuid)),
duplicate_panel_user=panel_user_dict,
settings=settings,
subscriptions_by_panel_uuid=subscriptions_by_panel_uuid,
active_subscriptions_by_user_panel=(
active_subscriptions_by_user_panel
),
)
subscriptions_created += int(merge_result["subscriptions_created"])
subscriptions_updated += int(merge_result["subscriptions_updated"])
subscriptions_synced_count += int(
merge_result["subscriptions_created"]
) + int(merge_result["subscriptions_updated"])
if merge_result["resolved"]:
users_updated += 1
users_uuid_updated += 1
panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
str(panel_uuid)
)
users_by_panel_uuid.pop(str(panel_uuid), None)
logging.warning( logging.warning(
"Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s.", # noqa: E501 "Sync: duplicate panel users share telegramId %s; kept local panel UUID %s and processed duplicate panel UUID %s.", # noqa: E501
telegram_id_from_panel, telegram_id_from_panel,
linked_uuid, linked_uuid,
panel_uuid, panel_uuid,
) )
continue
else: else:
existing_user.panel_user_uuid = panel_uuid existing_user.panel_user_uuid = panel_uuid
user_was_updated = True user_was_updated = True
@@ -589,28 +910,15 @@ async def _perform_sync_impl(
if line if line
) )
# Update description only when it differs from the current one on panel # 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() desired_description = description_text.strip()
if desired_description and not _description_matches( if desired_description and not _panel_identity_matches_user(
current_panel_description, desired_description panel_user_dict,
existing_user,
desired_description,
): ):
await panel_service.update_user_details_on_panel( await panel_service.update_user_details_on_panel(
panel_uuid, panel_uuid,
{ _panel_identity_update_payload(existing_user, description_text),
"description": description_text,
**(
{"email": existing_user.email}
if existing_user.email
else {}
),
**(
{"telegramId": existing_user.telegram_id}
if existing_user.telegram_id
else {}
),
},
) )
except Exception as e_desc: except Exception as e_desc:
logging.warning( logging.warning(
@@ -25,6 +25,7 @@ from .registry import (
provider_emoji_map, provider_emoji_map,
provider_label_map, provider_label_map,
provider_telegram_button_text, provider_telegram_button_text,
provider_webhook_metadata,
resolve_provider_presentation, resolve_provider_presentation,
) )
@@ -53,5 +54,6 @@ __all__ = [
"provider_telegram_button_text", "provider_telegram_button_text",
"provider_emoji_map", "provider_emoji_map",
"provider_label_map", "provider_label_map",
"provider_webhook_metadata",
"resolve_provider_presentation", "resolve_provider_presentation",
] ]
+40
View File
@@ -333,6 +333,46 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide
return None 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( def manifest_field_default(
spec: PaymentProviderSpec, spec: PaymentProviderSpec,
manifest_field: ProviderManifestField, manifest_field: ProviderManifestField,
@@ -690,6 +690,51 @@ class NotificationService:
profile_keyboard = self._build_profile_keyboard(_, telegram_id) profile_keyboard = self._build_profile_keyboard(_, telegram_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard) 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: def _format_traffic_gb_admin(self, traffic_gb: float) -> str:
value = float(traffic_gb) value = float(traffic_gb)
if value.is_integer(): 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)) cached_panel_user = panel_users_by_uuid.get(str(sub.panel_user_uuid))
if cached_panel_user is not None: if cached_panel_user is not None:
return cached_panel_user 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: async with semaphore:
try: try:
@@ -167,12 +174,22 @@ class TariffTrafficWorker:
sub.panel_user_uuid, sub.panel_user_uuid,
) )
return {} 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): for chunk_start in range(0, len(subs), TARIFF_WORKER_BATCH_SIZE):
chunk = subs[chunk_start : chunk_start + 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)) panel_payloads = await asyncio.gather(*(_fetch_panel(s) for s in chunk))
for sub, panel_data in zip(chunk, panel_payloads): for sub, panel_data in zip(chunk, panel_payloads):
if not panel_data:
continue
try: try:
tariff = self.settings.tariffs_config.require(sub.tariff_key) tariff = self.settings.tariffs_config.require(sub.tariff_key)
except Exception: except Exception:
@@ -255,6 +272,69 @@ class TariffTrafficWorker:
) )
return by_uuid 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( async def _ensure_period_reset_strategy(
self, self,
sub: Subscription, sub: Subscription,
+30 -17
View File
@@ -1,32 +1,45 @@
# Resolve the application version from .git at build time and emit a single # Resolve the application version from .git at build time and emit a tiny
# tiny ``.build-version`` file. The .git tree is consumed in this throwaway # .build-version file. The .git tree is consumed in this throwaway stage and
# stage and never copied into the runtime image only the resulting one-line # never copied into the runtime image; only the tag + commit version string
# version string ships. This matches the runtime fallback chain in # ships. Non-main builds include the branch name so they are visibly distinct
# ``_resolve_app_version`` (REMNAWAVE_MINISHOP_VERSION env > .build-version # from release builds. This matches the runtime fallback chain in _resolve_app_version
# file > live git > "dev+unknown") so the admin sidebar always shows a tag / # (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
# sha even though the runtime images have no git tooling and no .git tree. # "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 FROM alpine:3.20 AS version-builder
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /repo WORKDIR /repo
ARG REMNAWAVE_MINISHOP_BRANCH=""
ARG GIT_BRANCH=""
ARG BRANCH_NAME=""
ARG GITHUB_REF_NAME=""
ARG CI_COMMIT_REF_NAME=""
COPY .git ./.git COPY .git ./.git
RUN set -eu; \ RUN set -eu; \
git config --global --add safe.directory /repo; \ git config --global --add safe.directory /repo; \
tag=$(git describe --tags --abbrev=0 2>/dev/null || true); \ tag=$(git describe --tags --abbrev=0 2>/dev/null || true); \
sha=$(git rev-parse --short HEAD 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 \ if [ -n "$tag" ] && [ -n "$sha" ]; then \
commits_since_tag=$(git rev-list "$tag..HEAD" --count 2>/dev/null || true); \ version="${tag}${branch_suffix}+g${sha}"; \
if [ -n "$commits_since_tag" ] && [ "$commits_since_tag" != "0" ]; then \
version="${tag}+${commits_since_tag}.g${sha}"; \
else \
version="$tag"; \
fi; \
elif [ -n "$sha" ]; then \ elif [ -n "$sha" ]; then \
version="dev+g${sha}"; \ version="dev${branch_suffix}+g${sha}"; \
elif [ -n "$tag" ]; then \
version="${tag}${branch_suffix}"; \
else \ else \
version="dev+unknown"; \ version="dev${branch_suffix}+unknown"; \
fi; \ fi; \
if [ -n "$dirty" ]; then version="${version}-dirty"; fi; \
printf '%s' "$version" > /build-version; \ printf '%s' "$version" > /build-version; \
printf '%s' "${tag:-unknown}" > /build-tag; \ printf '%s' "${tag:-unknown}" > /build-tag; \
printf '%s' "${sha:-unknown}" > /build-commit printf '%s' "${sha:-unknown}" > /build-commit
+43 -6
View File
@@ -200,7 +200,7 @@
showLogin, showLogin,
telegramSdk, telegramSdk,
getTg: () => tg, getTg: () => tg,
telegramOAuthClientId, telegramOAuthClientId: () => telegramOAuthClientId,
currentLang: () => currentLang, currentLang: () => currentLang,
normalizeLangCode, normalizeLangCode,
updateLocalData: (updatedLanguage) => { updateLocalData: (updatedLanguage) => {
@@ -405,6 +405,7 @@
$: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0);
$: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); $: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
$: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData); $: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData);
$: telegramMiniAppContext = hasTelegramLaunchParams();
$: telegramLoginUnavailable = $: telegramLoginUnavailable =
!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"; !telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading";
$: telegramLoginChecking = $: telegramLoginChecking =
@@ -660,7 +661,10 @@
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = href; link.href = href;
link.onload = () => resolve(); 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); document.head.appendChild(link);
}); });
} }
@@ -673,11 +677,34 @@
script.src = src; script.src = src;
script.async = true; script.async = true;
script.onload = () => resolve(); 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); 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() { function readAdminBundleApi() {
const bundle = window.SubscriptionWebAppAdmin; const bundle = window.SubscriptionWebAppAdmin;
return bundle?.mount ? bundle : null; return bundle?.mount ? bundle : null;
@@ -697,8 +724,16 @@
adminBundlePromise = (async () => { adminBundlePromise = (async () => {
const cssHref = resolveWebappAssetPath(CFG.adminCssAsset, "subscription_webapp_admin.css"); const cssHref = resolveWebappAssetPath(CFG.adminCssAsset, "subscription_webapp_admin.css");
const jsSrc = resolveWebappAssetPath(CFG.adminJsAsset, "subscription_webapp_admin.js"); const jsSrc = resolveWebappAssetPath(CFG.adminJsAsset, "subscription_webapp_admin.js");
await appendStylesheetOnce("subscription-webapp-admin-css", cssHref); await appendStylesheetWithFallback(
await appendScriptOnce("subscription-webapp-admin-js", jsSrc); "subscription-webapp-admin-css",
cssHref,
"subscription_webapp_admin.css",
);
await appendScriptWithFallback(
"subscription-webapp-admin-js",
jsSrc,
"subscription_webapp_admin.js",
);
const loaded = readAdminBundleApi(); const loaded = readAdminBundleApi();
if (!loaded) throw new Error("admin_bundle_missing_mount"); if (!loaded) throw new Error("admin_bundle_missing_mount");
adminBundleApi = loaded; adminBundleApi = loaded;
@@ -1513,7 +1548,9 @@
{user} {user}
{userAgreementUrl} {userAgreementUrl}
{userLanguage} {userLanguage}
linkTelegramAccount={accountStore.linkTelegramAccount} showLogout={!telegramMiniAppContext}
linkTelegramAccount={() =>
accountStore.linkTelegramAccount(() => telegramMiniAppInitData)}
logout={accountStore.logout} logout={accountStore.logout}
{openAdminPanel} {openAdminPanel}
{openExternalLink} {openExternalLink}
@@ -1,5 +1,14 @@
<script> <script>
import { ChevronRight, Eye, EyeOff, FileText, Search, X } from "$components/ui/icons.js"; import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
FileText,
Search,
X,
} from "$components/ui/icons.js";
import * as UiIcons from "$components/ui/icons.js"; import * as UiIcons from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js"; import { Accordion, Switch } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
@@ -9,7 +18,7 @@
AdminEmptyState, AdminEmptyState,
AdminSelect, AdminSelect,
} from "$components/patterns/admin/index.js"; } from "$components/patterns/admin/index.js";
import { getContext, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
export let at; export let at;
export let onSettingsSaved; export let onSettingsSaved;
@@ -26,6 +35,8 @@
let revealedSecrets = new Set(); let revealedSecrets = new Set();
let iconPickerField = null; let iconPickerField = null;
let iconPickerSearch = ""; let iconPickerSearch = "";
let copiedWebhookKey = "";
let copiedWebhookTimer = null;
$: settingsAllOpen = $: settingsAllOpen =
visibleSettingsSections.length > 0 && visibleSettingsSections.length > 0 &&
@@ -48,6 +59,12 @@
}); });
}); });
onDestroy(() => {
if (copiedWebhookTimer && typeof window !== "undefined") {
window.clearTimeout(copiedWebhookTimer);
}
});
function toggleAllSections() { function toggleAllSections() {
if (settingsOpenSections.length === visibleSettingsSections.length) { if (settingsOpenSections.length === visibleSettingsSections.length) {
settingsOpenSections = []; settingsOpenSections = [];
@@ -134,6 +151,60 @@
} }
} }
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) { function groupSectionFields(section) {
const groups = new Map(); const groups = new Map();
for (const field of section.fields || []) { for (const field of section.fields || []) {
@@ -151,6 +222,7 @@
id, id,
label: id === "_root" ? null : id, label: id === "_root" ? null : id,
i18nLabelKey: group.i18nLabelKey, i18nLabelKey: group.i18nLabelKey,
webhook: groupWebhook(group.fields),
fields: group.fields, fields: group.fields,
})); }));
} }
@@ -231,6 +303,47 @@
} }
</script> </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)} {#snippet renderField(field)}
{@const revealed = isSecretRevealed(field.key)} {@const revealed = isSecretRevealed(field.key)}
<div class="admin-setting" class:is-overridden={isOverridden(field)}> <div class="admin-setting" class:is-overridden={isOverridden(field)}>
@@ -467,6 +580,9 @@
{@const labelGroups = groups.filter((g) => g.label)} {@const labelGroups = groups.filter((g) => g.label)}
<div class="admin-settings-fields"> <div class="admin-settings-fields">
{#if rootGroup} {#if rootGroup}
{#if rootGroup.webhook}
{@render renderWebhookHint(rootGroup.webhook)}
{/if}
{#each rootGroup.fields as field} {#each rootGroup.fields as field}
{@render renderField(field)} {@render renderField(field)}
{/each} {/each}
@@ -510,6 +626,9 @@
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="admin-accordion-content"> <Accordion.Content class="admin-accordion-content">
<div class="admin-settings-subsection-body"> <div class="admin-settings-subsection-body">
{#if group.webhook}
{@render renderWebhookHint(group.webhook)}
{/if}
{#each group.fields as field} {#each group.fields as field}
{@render renderField(field)} {@render renderField(field)}
{/each} {/each}
+12 -3
View File
@@ -145,6 +145,12 @@ export function createAccountStore({
clearPasswordCooldownTimer(); clearPasswordCooldownTimer();
} }
function getTelegramOAuthClientId() {
const value =
typeof telegramOAuthClientId === "function" ? telegramOAuthClientId() : telegramOAuthClientId;
return Number(value || 0);
}
function closeSetPasswordDialog() { function closeSetPasswordDialog() {
state.update((s) => ({ state.update((s) => ({
...s, ...s,
@@ -318,19 +324,21 @@ export function createAccountStore({
} }
} }
async function linkTelegramAccount(getTelegramMiniAppInitData) { async function linkTelegramAccount(getTelegramMiniAppInitData = () => "") {
const s = get(state); const s = get(state);
if (s.linkTelegramBusy) return; if (s.linkTelegramBusy) return;
const readTelegramMiniAppInitData =
typeof getTelegramMiniAppInitData === "function" ? getTelegramMiniAppInitData : () => "";
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams(); const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
if (isTelegramMiniAppAttempt) { if (isTelegramMiniAppAttempt) {
await telegramSdk.ensureForAction(); await telegramSdk.ensureForAction();
} }
const initData = getTelegramMiniAppInitData(); const initData = readTelegramMiniAppInitData();
if (initData) { if (initData) {
await linkTelegramAccountWithPayload({ init_data: initData }); await linkTelegramAccountWithPayload({ init_data: initData });
return; return;
} }
if (!telegramOAuthClientId) { if (!getTelegramOAuthClientId()) {
showToast(t("wa_auth_telegram_not_configured")); showToast(t("wa_auth_telegram_not_configured"));
return; return;
} }
@@ -362,6 +370,7 @@ export function createAccountStore({
} }
async function logout() { async function logout() {
if (telegramSdk.hasLaunchParams()) return;
markManualLogout(); markManualLogout();
clearToken(); clearToken();
try { try {
+5 -5
View File
@@ -61,11 +61,6 @@ export async function runWebappBoot({
); );
} }
if (isManuallyLoggedOut()) {
showLogin();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData(); const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return; if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
@@ -78,6 +73,11 @@ export async function runWebappBoot({
} }
} }
if (isManuallyLoggedOut()) {
showLogin();
return;
}
if (getToken() || getCsrfToken()) { if (getToken() || getCsrfToken()) {
try { try {
await loadData(); await loadData();
+79 -32
View File
@@ -1889,6 +1889,65 @@
align-items: center; 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 { .admin-setting:last-child {
border-bottom: 0; border-bottom: 0;
} }
@@ -2625,6 +2684,26 @@
padding: 14px 14px; 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 { .admin-card-head {
padding: 12px 14px; 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 shadcn-svelte primitives: Tabs / Select / Switch / Label
bits-ui exposes data-state attributes; styling here matches bits-ui exposes data-state attributes; styling here matches
+55
View File
@@ -1287,6 +1287,11 @@ a {
gap: 8px; gap: 8px;
} }
.settings-admin-block {
display: grid;
gap: 8px;
}
.settings-divider { .settings-divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
@@ -1329,6 +1334,56 @@ a {
font-size: 12px; 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 { .settings-row-linked {
grid-template-columns: 28px minmax(0, 1fr); grid-template-columns: 28px minmax(0, 1fr);
border-color: var(--success-border); border-color: var(--success-border);
@@ -34,6 +34,7 @@
export let user = {}; export let user = {};
export let userAgreementUrl = ""; export let userAgreementUrl = "";
export let userLanguage = ""; export let userLanguage = "";
export let showLogout = true;
export let linkTelegramAccount = () => {}; export let linkTelegramAccount = () => {};
export let logout = () => {}; export let logout = () => {};
@@ -187,10 +188,12 @@
<ArrowRight size={17} /> <ArrowRight size={17} />
</button> </button>
{/if} {/if}
<button class="settings-row settings-row-logout" type="button" onclick={logout}> {#if showLogout}
<UserRound size={21} /> <button class="settings-row settings-row-logout" type="button" onclick={logout}>
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span> <UserRound size={21} />
<ArrowRight size={17} /> <span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
</button> <ArrowRight size={17} />
</button>
{/if}
</div> </div>
</main> </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_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_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_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": "{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_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}", "log_payment_traffic_purchase_line": "<b>{gb} GB</b> · {kind}",
@@ -956,6 +957,11 @@
"admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_cryptopay": "CryptoPay",
"admin_settings_subsection_wata": "Wata", "admin_settings_subsection_wata": "Wata",
"admin_settings_subsection_heleket": "Heleket", "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_validation_errors": "Errors: {errors}",
"admin_settings_save_error": "Error: {error}", "admin_settings_save_error": "Error: {error}",
"admin_sync_started": "Synchronization started", "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_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_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_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": "{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_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}", "log_payment_traffic_purchase_line": "<b>{gb} ГБ</b> · {kind}",
@@ -956,6 +957,11 @@
"admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_cryptopay": "CryptoPay",
"admin_settings_subsection_wata": "Wata", "admin_settings_subsection_wata": "Wata",
"admin_settings_subsection_heleket": "Heleket", "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_validation_errors": "Ошибки: {errors}",
"admin_settings_save_error": "Ошибка: {error}", "admin_settings_save_error": "Ошибка: {error}",
"admin_sync_started": "Синхронизация запущена", "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)
@@ -93,3 +93,13 @@ def test_subscription_guide_settings_i18n_keys_exist():
field = manifest[setting_key] field = manifest[setting_key]
assert field["i18n_label_key"] in messages assert field["i18n_label_key"] in messages
assert field["i18n_description_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"]
+112
View File
@@ -1,7 +1,10 @@
import asyncio
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from bot.handlers.admin.sync_admin import ( from bot.handlers.admin.sync_admin import (
_absorb_duplicate_panel_identity,
_coerce_panel_telegram_id, _coerce_panel_telegram_id,
_description_matches, _description_matches,
_should_update_lifetime_used_traffic, _should_update_lifetime_used_traffic,
@@ -14,6 +17,20 @@ def test_description_match_ignores_whitespace_shape():
assert _description_matches("email@example.com username", "email@example.com\nusername") 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(): def test_panel_telegram_id_is_coerced_to_int():
assert _coerce_panel_telegram_id("12345") == 12345 assert _coerce_panel_telegram_id("12345") == 12345
assert _coerce_panel_telegram_id("") is None assert _coerce_panel_telegram_id("") is None
@@ -122,3 +139,98 @@ def test_lifetime_traffic_update_allows_large_delta_and_skips_duplicate_panel_id
settings=settings, settings=settings,
is_duplicate_panel_identity=True, is_duplicate_panel_identity=True,
) )
def test_absorb_duplicate_panel_identity_extends_kept_user_and_deletes_duplicate():
now = datetime.now(timezone.utc)
target_sub = SimpleNamespace(
subscription_id=10,
user_id=42,
panel_user_uuid="panel-keep",
panel_subscription_uuid="sub-keep",
end_date=now - timedelta(days=2),
is_active=False,
status_from_panel="EXPIRED",
)
duplicate_sub = SimpleNamespace(
subscription_id=11,
user_id=42,
panel_user_uuid="panel-duplicate",
panel_subscription_uuid="sub-duplicate",
end_date=now + timedelta(days=30),
is_active=True,
skip_notifications=False,
status_from_panel="ACTIVE",
)
panel_service = SimpleNamespace(
update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-keep"}),
delete_user_from_panel=AsyncMock(return_value=True),
)
session = SimpleNamespace(execute=AsyncMock())
settings = SimpleNamespace(user_traffic_limit_bytes=0)
user = SimpleNamespace(
user_id=42,
panel_user_uuid="panel-keep",
telegram_id=969808056,
email="paid@example.com",
username="alice",
first_name="Alice",
last_name=None,
)
async def update_subscription(_session, subscription_id, update_data):
sub = target_sub if subscription_id == target_sub.subscription_id else duplicate_sub
for key, value in update_data.items():
setattr(sub, key, value)
return sub
with patch(
"bot.handlers.admin.sync_admin.subscription_dal.update_subscription",
AsyncMock(side_effect=update_subscription),
):
result = asyncio.run(
_absorb_duplicate_panel_identity(
session,
panel_service=panel_service,
existing_user=user,
keep_panel_uuid="panel-keep",
keep_panel_user={
"uuid": "panel-keep",
"subscriptionUuid": "sub-keep",
"status": "EXPIRED",
"expireAt": (now - timedelta(days=2)).isoformat(),
},
duplicate_panel_user={
"uuid": "panel-duplicate",
"subscriptionUuid": "sub-duplicate",
"telegramId": 969808056,
"status": "ACTIVE",
"expireAt": (now + timedelta(days=30)).isoformat(),
},
settings=settings,
subscriptions_by_panel_uuid={
"sub-keep": target_sub,
"sub-duplicate": duplicate_sub,
},
active_subscriptions_by_user_panel={},
)
)
assert result["resolved"]
assert result["subscriptions_updated"] == 2
assert target_sub.is_active
assert target_sub.status_from_panel == "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE"
assert target_sub.panel_user_uuid == "panel-keep"
assert target_sub.end_date > now + timedelta(days=29)
assert not duplicate_sub.is_active
assert duplicate_sub.skip_notifications
assert duplicate_sub.status_from_panel == "MERGED_PANEL_DUPLICATE"
panel_service.update_user_details_on_panel.assert_awaited_once()
update_uuid, update_payload = panel_service.update_user_details_on_panel.await_args.args[:2]
assert update_uuid == "panel-keep"
assert update_payload["status"] == "ACTIVE"
assert update_payload["telegramId"] == 969808056
panel_service.delete_user_from_panel.assert_awaited_once_with(
"panel-duplicate",
log_response=False,
)
+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 The admin sidebar shows ``{appVersion}`` next to a "remnawave-minishop" GitHub
link. That string is rendered by link. Release builds from ``main`` should render the latest reachable tag and
``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through the commit sha, without a dirty suffix. Builds from other branches include the
following precedence chain: 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; 1. ``REMNAWAVE_MINISHOP_VERSION`` env var: manual override;
2. ``$APP_ROOT/.build-version`` file baked at Docker build time by the 2. ``$APP_ROOT/.build-version`` file: baked at Docker build time by the
``version-builder`` stage in ``deploy/docker/Dockerfile`` (consumes .git ``version-builder`` stage in ``deploy/docker/Dockerfile``;
in a throwaway stage and ships only this one tiny file); 3. live ``git describe`` / ``rev-parse``: local dev fallback where .git is
3. live ``git describe`` / ``rev-parse`` works in local dev where .git present;
is present;
4. ``"dev+unknown"`` as the ultimate fallback. 4. ``"dev+unknown"`` as the ultimate fallback.
Before the build-time bake, the admin footer in production silently fell back The Docker image carries no git binary and no .git tree at runtime, so the
to step 4 because the Docker image carries no .git tree and no git binary. baked ``.build-version`` file is the production source for the sidebar.
""" """
import importlib import importlib
@@ -28,11 +29,20 @@ from unittest.mock import patch
import bot.app.web.subscription_webapp # noqa: F401 import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import assets as assets_module 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: def _reset_cache() -> None:
# The resolver memoizes the first result in a module-level global. # The resolver memoizes the first result in a module-level global.
assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined] 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 = importlib.import_module("bot.app.web.webapp._runtime")
runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined] runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined]
@@ -41,6 +51,13 @@ def _resolve():
return assets_module._resolve_app_version() 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): class EnvOverrideTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
_reset_cache() _reset_cache()
@@ -62,12 +79,13 @@ class EnvOverrideTests(unittest.TestCase):
self.assertEqual(called["git"], 0) self.assertEqual(called["git"], 0)
def test_blank_env_var_falls_through(self): def test_blank_env_var_falls_through(self):
env = {"REMNAWAVE_MINISHOP_VERSION": " "} env = _clean_version_env()
env["REMNAWAVE_MINISHOP_VERSION"] = " "
with ( with (
patch.dict(os.environ, env), patch.dict(os.environ, env, clear=True),
patch.object(assets_module, "_run_git_command", lambda *a: ""), 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") self.assertEqual(_resolve(), "dev+unknown")
@@ -80,24 +98,22 @@ class BuildVersionFileTests(unittest.TestCase):
def test_reads_baked_version_file(self): def test_reads_baked_version_file(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".build-version").write_text("v3.4.5+12.gabcdef1", encoding="utf-8") (Path(tmp) / ".build-version").write_text("v3.4.5+gabcdef1", encoding="utf-8")
env = dict(os.environ) env = _clean_version_env()
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
with ( with (
patch.dict(os.environ, env, clear=True), patch.dict(os.environ, env, clear=True),
patch.object(assets_module, "APP_ROOT", Path(tmp)), 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: ""), 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): def test_strips_trailing_whitespace_and_newlines_in_file(self):
# Shell ``printf '%s'`` writes no newline, but earlier helpers used # Shell ``printf '%s'`` writes no newline, but older helpers may have
# ``echo`` which appends one. Both must produce the same result. # used ``echo``. Both must produce the same result.
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".build-version").write_text("v1.2.3\n\n", encoding="utf-8") (Path(tmp) / ".build-version").write_text("v1.2.3\n\n", encoding="utf-8")
env = dict(os.environ) env = _clean_version_env()
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
with ( with (
patch.dict(os.environ, env, clear=True), patch.dict(os.environ, env, clear=True),
patch.object(assets_module, "APP_ROOT", Path(tmp)), 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): def test_empty_file_falls_through_to_git_then_unknown(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / ".build-version").write_text("", encoding="utf-8") (Path(tmp) / ".build-version").write_text("", encoding="utf-8")
env = dict(os.environ) env = _clean_version_env()
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
with ( with (
patch.dict(os.environ, env, clear=True), patch.dict(os.environ, env, clear=True),
patch.object(assets_module, "APP_ROOT", Path(tmp)), patch.object(assets_module, "APP_ROOT", Path(tmp)),
patch.object(assets_module, "_run_git_command", lambda *a: ""), 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") self.assertEqual(_resolve(), "dev+unknown")
@@ -126,56 +141,83 @@ class LiveGitFallbackTests(unittest.TestCase):
_reset_cache() _reset_cache()
self.addCleanup(_reset_cache) self.addCleanup(_reset_cache)
def _run_with_git(self, replies: dict, *, dirty: bool = False) -> str: def _run_with_git(self, replies: dict) -> str:
# Map (subcommand, *args) tuples to canned stdout values.
def fake_git(*args):
return replies.get(args, "")
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
# ``.build-version`` deliberately absent so we fall through. # ``.build-version`` deliberately absent so we fall through.
env = dict(os.environ) env = _clean_version_env()
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
base = { base = {
("describe", "--tags", "--abbrev=0"): replies.get("tag", ""), ("describe", "--tags", "--abbrev=0"): replies.get("tag", ""),
("rev-parse", "--short", "HEAD"): replies.get("sha", ""), ("rev-parse", "--short", "HEAD"): replies.get("sha", ""),
("status", "--porcelain"): "M file\n" if dirty else "", ("branch", "--show-current"): replies.get("branch", ""),
("rev-list", f"{replies.get('tag', '')}..HEAD", "--count"): replies.get(
"commits_since_tag", "0"
),
} }
def real_fake_git(*args): def fake_git(*args):
return base.get(args, "") return base.get(args, "")
with ( with (
patch.dict(os.environ, env, clear=True), patch.dict(os.environ, env, clear=True),
patch.object(assets_module, "APP_ROOT", Path(tmp)), 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() return _resolve()
def test_tag_with_zero_commits_since_returns_bare_tag(self): def test_tag_with_sha_returns_tag_plus_sha(self):
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}) result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1"})
self.assertEqual(result, "v2.0.0") 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"}) 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): def test_sha_only_when_no_tag(self):
result = self._run_with_git({"sha": "abcdef1"}) result = self._run_with_git({"sha": "abcdef1"})
self.assertEqual(result, "dev+gabcdef1") 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): def test_neither_tag_nor_sha_is_unknown(self):
result = self._run_with_git({}) result = self._run_with_git({})
self.assertEqual(result, "dev+unknown") self.assertEqual(result, "dev+unknown")
def test_dirty_suffix_is_appended(self): def test_dirty_suffix_is_not_appended_or_queried(self):
result = self._run_with_git( calls = []
{"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"},
dirty=True, def fake_git(*args):
) calls.append(args)
self.assertEqual(result, "v2.0.0-dirty") 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): class CacheBehaviourTests(unittest.TestCase):
@@ -191,8 +233,7 @@ class CacheBehaviourTests(unittest.TestCase):
return "abcdef1" if args == ("rev-parse", "--short", "HEAD") else "" return "abcdef1" if args == ("rev-parse", "--short", "HEAD") else ""
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
env = dict(os.environ) env = _clean_version_env()
env.pop("REMNAWAVE_MINISHOP_VERSION", None)
with ( with (
patch.dict(os.environ, env, clear=True), patch.dict(os.environ, env, clear=True),
patch.object(assets_module, "APP_ROOT", Path(tmp)), 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.assertTrue(secret_field["has_value"])
self.assertNotIn("super-secret", response.text) 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): class DatabaseLoggingSecurityTests(unittest.TestCase):
def test_database_url_redaction_hides_password(self): 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 channels == []
assert emails[0]["ticket_id"] == 7 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 json
import tempfile import tempfile
import unittest import unittest
@@ -617,3 +618,107 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result) self.assertIsNone(result)
panel_service.get_all_panel_users.assert_not_awaited() 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 import unittest
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -131,3 +131,109 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("user_payment_methods", delete_tables) self.assertIn("user_payment_methods", delete_tables)
self.assertIn("promo_code_activations", delete_tables) self.assertIn("promo_code_activations", delete_tables)
session.delete.assert_awaited_once_with(source) 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 import subscription_webapp
from bot.app.web.admin_api_impl import themes as admin_themes 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 assets as webapp_assets
from bot.app.web.webapp import cache_helpers
from config.settings import Settings from config.settings import Settings
from config.webapp_themes_config import WebappThemesConfig, builtin_webapp_themes_config 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"}], [{"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): def test_serialize_plans_includes_stars_only_subscription_options(self):
settings = Settings( settings = Settings(
_env_file=None, _env_file=None,
@@ -488,7 +541,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"subscription_webapp.min.22222222.js", "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: with tempfile.TemporaryDirectory() as tmpdir:
asset_dir = Path(tmpdir) asset_dir = Path(tmpdir)
(asset_dir / "subscription_webapp_admin.js").write_text( (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): with patch.object(webapp_assets, "ASSET_DIR", asset_dir):
self.assertEqual( self.assertEqual(
subscription_webapp._resolve_webapp_admin_js_asset_name(), subscription_webapp._resolve_webapp_admin_js_asset_name(),
"subscription_webapp_admin.min.22222222.js", "subscription_webapp_admin.js",
) )
self.assertEqual( self.assertEqual(
subscription_webapp._resolve_webapp_admin_css_asset_name(), 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): 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