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}
fields = manifest_payload()
webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/")
sections: Dict[str, Dict[str, Any]] = {}
for field in fields:
key = field["key"]
@@ -53,6 +54,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
response_field["read_error"] = read_error
if is_secret:
response_field["has_value"] = bool(value)
webhook_path = str(response_field.get("webhook_path") or "").strip()
if webhook_path:
if not webhook_path.startswith("/"):
webhook_path = f"/{webhook_path}"
response_field["webhook_path"] = webhook_path
response_field["webhook_base_url_configured"] = bool(webhook_base_url)
if webhook_base_url:
response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}"
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
@@ -562,7 +562,11 @@ def manifest_payload() -> List[dict]:
same value so existing UIs that only read ``placeholder`` also show the
hint inside the empty input.
"""
from bot.payment_providers import find_manifest_owner, manifest_field_default
from bot.payment_providers import (
find_manifest_owner,
manifest_field_default,
provider_webhook_metadata,
)
sections_order = {
"general": 1,
@@ -587,10 +591,12 @@ def manifest_payload() -> List[dict]:
)
default_value: Optional[str] = None
webhook_metadata: Optional[dict] = None
owner = find_manifest_owner(field.key)
if owner is not None:
spec, manifest_field = owner
default_value = manifest_field_default(spec, manifest_field)
webhook_metadata = provider_webhook_metadata(spec)
placeholder = field.placeholder
if not placeholder and default_value:
@@ -617,6 +623,8 @@ def manifest_payload() -> List[dict]:
}
if default_value is not None:
item["default"] = default_value
if webhook_metadata:
item.update(webhook_metadata)
if field.choices:
item["choices"] = [
{
+33 -38
View File
@@ -2,7 +2,11 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
from .auth import _hash_email_password
from .auth import (
_hash_email_password,
_notify_account_merged,
_sync_merged_panel_identity_for_user,
)
from .common import _invalidate_webapp_user_caches
@@ -109,7 +113,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
if not merge_notice:
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user)
@@ -122,28 +127,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
await _sync_merged_panel_identity_for_user(
request,
current_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -178,6 +168,16 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_email_linked:
try:
from bot.services.notification_service import NotificationService
@@ -345,28 +345,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
await _sync_panel_identity_for_user(
await _sync_merged_panel_identity_for_user(
request,
db_user,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get(
"subscription_service"
)
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -401,6 +386,16 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed")
await _invalidate_webapp_user_caches(settings, user_id, final_user_id, include_devices=True)
if merge_notice:
await _notify_account_merged(
request,
settings,
merge_notice=merge_notice,
email=final_email,
telegram_id=final_telegram_id,
username=final_username,
first_name=final_first_name,
)
if should_notify_telegram_linked and final_telegram_id:
try:
from bot.services.notification_service import NotificationService
+48 -23
View File
@@ -834,6 +834,45 @@ def _run_git_command(*args: str) -> str:
return result.stdout.strip()
def _normalize_version_branch(raw_branch: str) -> str:
branch = str(raw_branch or "").strip()
for prefix in ("refs/heads/", "refs/remotes/origin/", "origin/"):
if branch.startswith(prefix):
branch = branch[len(prefix) :]
break
if branch == "HEAD":
return ""
return re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-")[:48]
def _resolve_version_branch() -> str:
for env_name in (
"REMNAWAVE_MINISHOP_BRANCH",
"GIT_BRANCH",
"BRANCH_NAME",
"GITHUB_REF_NAME",
"CI_COMMIT_REF_NAME",
):
branch = _normalize_version_branch(os.getenv(env_name, ""))
if branch:
return branch
return _normalize_version_branch(
_run_git_command("branch", "--show-current")
or _run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
)
def _format_app_version(tag: str, sha: str, branch: str) -> str:
branch_suffix = "" if not branch or branch == "main" else f"-{branch}"
if tag and sha:
return f"{tag}{branch_suffix}+g{sha}"
if sha:
return f"dev{branch_suffix}+g{sha}"
if tag:
return f"{tag}{branch_suffix}"
return f"dev{branch_suffix}+unknown"
def _resolve_app_version() -> str:
global _APP_VERSION_CACHE
if _APP_VERSION_CACHE:
@@ -855,21 +894,8 @@ def _resolve_app_version() -> str:
tag = _run_git_command("describe", "--tags", "--abbrev=0")
sha = _run_git_command("rev-parse", "--short", "HEAD")
dirty = bool(_run_git_command("status", "--porcelain"))
if tag and sha:
commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count")
if commits_since_tag and commits_since_tag != "0":
version = f"{tag}+{commits_since_tag}.g{sha}"
else:
version = tag
elif sha:
version = f"dev+g{sha}"
else:
version = "dev+unknown"
if dirty:
version = f"{version}-dirty"
branch = _resolve_version_branch()
version = _format_app_version(tag, sha, branch)
_APP_VERSION_CACHE = version
return version
@@ -1372,10 +1398,10 @@ def _resolve_webapp_js_asset_name() -> str:
def _resolve_webapp_admin_js_asset_name() -> str:
return _resolve_hashed_js_asset_name(
kind="admin-js",
base_name="subscription_webapp_admin",
)
# The admin bundle is lazy-loaded from the already running Mini App. In
# deployments where nginx serves static files in front of aiohttp, stale
# hashed admin filenames can 404 even though the runtime build asset exists.
return _set_cached_asset_name("admin-js", "subscription_webapp_admin.js")
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
@@ -1405,10 +1431,9 @@ def _resolve_webapp_css_asset_name() -> str:
def _resolve_webapp_admin_css_asset_name() -> str:
return _resolve_hashed_css_asset_name(
kind="admin-css",
base_name="subscription_webapp_admin",
)
# Keep the lazy-loaded admin stylesheet on the stable build filename for
# the same reason as the JS bundle above.
return _set_cached_asset_name("admin-css", "subscription_webapp_admin.css")
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
+143 -2
View File
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
redirect_path = "/settings" if purpose == "link" else "/"
async_session_factory: sessionmaker = request.app["async_session_factory"]
final_user_id: Optional[int] = None
source_user_id_for_cache: Optional[int] = None
linked_user_for_panel: Optional[User] = None
link_source_panel_uuid: Optional[str] = None
link_final_panel_uuid: Optional[str] = None
link_merge_notice: Optional[Dict[str, Any]] = None
async with async_session_factory() as session:
try:
if purpose == "link":
current_user_id = int(state.get("user_id") or 0)
source_user_id_for_cache = current_user_id
current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
link_source_panel_uuid = (
current_user_before_link.panel_user_uuid if current_user_before_link else None
)
db_user = await _link_telegram_to_user(
request,
session,
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
telegram_user=telegram_user,
settings=settings,
)
if int(db_user.user_id) != current_user_id:
link_final_panel_uuid = db_user.panel_user_uuid
link_merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=current_user_id,
source_panel_uuid=link_source_panel_uuid,
settings=settings,
)
linked_user_for_panel = db_user
else:
db_user = await _ensure_user_from_telegram(
session,
@@ -388,6 +408,34 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
raise redirect(redirect_path, "failed")
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
await _invalidate_webapp_user_caches(
settings,
source_user_id_for_cache,
final_user_id,
include_devices=True,
)
if purpose == "link" and link_merge_notice and linked_user_for_panel:
merge_end_date_raw = link_merge_notice.get("final_end_date")
merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
await _sync_merged_panel_identity_for_user(
request,
linked_user_for_panel,
source_panel_uuid=link_source_panel_uuid,
final_panel_uuid=link_final_panel_uuid,
expire_at=merge_end_date,
)
await _notify_account_merged(
request,
settings,
merge_notice=link_merge_notice,
email=linked_user_for_panel.email,
telegram_id=_telegram_id_for_user(linked_user_for_panel),
username=linked_user_for_panel.username,
first_name=linked_user_for_panel.first_name,
)
token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response)
@@ -974,6 +1022,14 @@ def _panel_description_for_user(user: User) -> str:
return "\n".join(line for line in lines if line).strip()
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
raw_value = telegram_user.get("photo_url")
if not raw_value:
return None
value = str(raw_value).strip()
return value or None
async def _sync_panel_identity_for_user(
request: web.Request,
user: User,
@@ -995,7 +1051,11 @@ async def _sync_panel_identity_for_user(
if user.email:
payload["email"] = user.email
if expire_at is not None:
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if expire_at > datetime.now(timezone.utc):
payload["status"] = "ACTIVE"
try:
await subscription_service.panel_service.update_user_details_on_panel(
@@ -1013,6 +1073,53 @@ async def _sync_panel_identity_for_user(
return False
async def _delete_merged_source_panel_user(
request: web.Request,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
) -> bool:
if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
return True
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return False
try:
return bool(
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
return False
async def _sync_merged_panel_identity_for_user(
request: web.Request,
user: User,
*,
source_panel_uuid: Optional[str],
final_panel_uuid: Optional[str],
expire_at: Optional[datetime] = None,
) -> bool:
# Remnawave keeps email/telegramId unique. Remove the losing panel identity
# before patching the surviving one so merged accounts can accept both IDs.
await _delete_merged_source_panel_user(
request,
source_panel_uuid=source_panel_uuid,
final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
)
return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
async def _build_account_merge_notice(
session: AsyncSession,
*,
@@ -1050,6 +1157,42 @@ async def _build_account_merge_notice(
}
async def _notify_account_merged(
request: web.Request,
settings: Settings,
*,
merge_notice: Optional[Dict[str, Any]],
email: Optional[str],
telegram_id: Optional[int],
username: Optional[str],
first_name: Optional[str],
) -> None:
if not merge_notice:
return
try:
from bot.services.notification_service import NotificationService
bot: Bot = request.app["bot"]
notification_service = NotificationService(
bot,
settings,
request.app.get("i18n"),
)
await notification_service.notify_account_merged(
primary_user_id=int(merge_notice.get("primary_user_id") or 0),
removed_user_id=int(merge_notice.get("removed_user_id") or 0),
email=email,
telegram_id=telegram_id,
username=username,
first_name=first_name,
final_end_date_text=str(merge_notice.get("final_end_date_text") or ""),
primary_panel_user_uuid=merge_notice.get("primary_panel_user_uuid"),
removed_panel_user_uuid=merge_notice.get("removed_panel_user_uuid"),
)
except Exception:
logger.exception("Failed to send account merged notification")
def _apply_telegram_profile_to_user(
user: User,
telegram_user: Dict[str, Any],
@@ -1102,7 +1245,6 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
@@ -1134,7 +1276,6 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
+23 -5
View File
@@ -75,15 +75,25 @@ def invalidate_local_webapp_user_payload(
def invalidate_all_local_webapp_user_payloads(
settings: Settings,
namespace: Optional[str] = None,
*,
include_devices: bool = False,
include_devices: Optional[bool] = 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(
_WEBAPP_USER_PAYLOAD_CACHES.items()
):
if settings_id == id(settings) and cache_namespace in namespaces:
cache.invalidate()
if settings_id != id(settings):
continue
if namespaces is not None and cache_namespace not in namespaces:
continue
cache.invalidate()
async def invalidate_webapp_user_caches(
@@ -117,10 +127,18 @@ async def invalidate_all_webapp_user_payloads(
*,
include_devices: bool = False,
) -> None:
invalidate_all_local_webapp_user_payloads(settings, include_devices=include_devices)
for namespace in _payload_namespaces(include_devices):
invalidate_all_local_webapp_user_payloads(settings, namespace=namespace)
try:
pattern = redis_key(settings, "cache", "webapp", namespace, "*")
await cache_delete_pattern(settings, pattern)
except Exception:
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)