diff --git a/backend/bot/app/web/admin_api_impl/settings.py b/backend/bot/app/web/admin_api_impl/settings.py index 5f13ee4..8b3299d 100644 --- a/backend/bot/app/web/admin_api_impl/settings.py +++ b/backend/bot/app/web/admin_api_impl/settings.py @@ -13,6 +13,7 @@ async def admin_settings_get_route(request: web.Request) -> web.Response: overrides_by_key = {entry["key"]: entry for entry in overrides} fields = manifest_payload() + webhook_base_url = str(settings.WEBHOOK_BASE_URL or "").strip().rstrip("/") sections: Dict[str, Dict[str, Any]] = {} for field in fields: key = field["key"] @@ -34,6 +35,14 @@ async def admin_settings_get_route(request: web.Request) -> web.Response: } if is_secret: response_field["has_value"] = bool(value) + webhook_path = str(response_field.get("webhook_path") or "").strip() + if webhook_path: + if not webhook_path.startswith("/"): + webhook_path = f"/{webhook_path}" + response_field["webhook_path"] = webhook_path + response_field["webhook_base_url_configured"] = bool(webhook_base_url) + if webhook_base_url: + response_field["webhook_url"] = f"{webhook_base_url}{webhook_path}" sections[section_id]["fields"].append(response_field) ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 447c9fd..0130564 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -507,7 +507,11 @@ def manifest_payload() -> List[dict]: same value so existing UIs that only read ``placeholder`` also show the hint inside the empty input. """ - from bot.payment_providers import find_manifest_owner, manifest_field_default + from bot.payment_providers import ( + find_manifest_owner, + manifest_field_default, + provider_webhook_metadata, + ) sections_order = { "general": 1, @@ -531,10 +535,12 @@ def manifest_payload() -> List[dict]: ) default_value: Optional[str] = None + webhook_metadata: Optional[dict] = None owner = find_manifest_owner(field.key) if owner is not None: spec, manifest_field = owner default_value = manifest_field_default(spec, manifest_field) + webhook_metadata = provider_webhook_metadata(spec) placeholder = field.placeholder if not placeholder and default_value: @@ -561,6 +567,8 @@ def manifest_payload() -> List[dict]: } if default_value is not None: item["default"] = default_value + if webhook_metadata: + item.update(webhook_metadata) if field.choices: item["choices"] = [ { diff --git a/backend/bot/payment_providers/__init__.py b/backend/bot/payment_providers/__init__.py index 041440e..5c39619 100644 --- a/backend/bot/payment_providers/__init__.py +++ b/backend/bot/payment_providers/__init__.py @@ -25,6 +25,7 @@ from .registry import ( provider_emoji_map, provider_label_map, provider_telegram_button_text, + provider_webhook_metadata, resolve_provider_presentation, ) @@ -53,5 +54,6 @@ __all__ = [ "provider_telegram_button_text", "provider_emoji_map", "provider_label_map", + "provider_webhook_metadata", "resolve_provider_presentation", ] diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 0ae2b86..194aa62 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -333,6 +333,46 @@ def find_manifest_owner(key: str) -> Optional[tuple[PaymentProviderSpec, Provide return None +def _webhook_spec_for(spec: PaymentProviderSpec) -> Optional[PaymentProviderSpec]: + if spec.webhook_path and spec.webhook_route: + return spec + if not spec.service_key: + return None + for candidate in PAYMENT_PROVIDER_SPECS: + if ( + candidate.service_key == spec.service_key + and candidate.webhook_path + and candidate.webhook_route + ): + return candidate + return None + + +def provider_webhook_metadata(spec: PaymentProviderSpec) -> Optional[Dict[str, Any]]: + """Return admin-manifest webhook metadata for a provider SPEC. + + Some visible payment buttons share one backing service and webhook route + (for example Platega SBP and Platega Crypto), so presentation-only specs + inherit the route from their service sibling. + """ + webhook_spec = _webhook_spec_for(spec) + if webhook_spec is None or not webhook_spec.webhook_path: + return None + try: + path = str(webhook_spec.webhook_path(None) or "").strip() + except Exception: + return None + if not path: + return None + return { + "provider_id": spec.id, + "provider_label": spec.label, + "webhook_provider_id": webhook_spec.id, + "webhook_path": path, + "webhook_requires_base_url": bool(webhook_spec.webhook_requires_base_url), + } + + def manifest_field_default( spec: PaymentProviderSpec, manifest_field: ProviderManifestField, diff --git a/frontend/src/admin/sections/SettingsSection.svelte b/frontend/src/admin/sections/SettingsSection.svelte index 1c01d90..da0b650 100644 --- a/frontend/src/admin/sections/SettingsSection.svelte +++ b/frontend/src/admin/sections/SettingsSection.svelte @@ -1,5 +1,5 @@ +{#snippet renderWebhookHint(webhook)} + {@const displayValue = webhook.url || webhook.path} +
+
+ {at("settings_provider_webhook_url", {}, "Webhook URL")} + + {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}.` + )} + +
+
+ {displayValue} + copyWebhookUrl(webhook)} + > + {#if copiedWebhookKey === webhook.key} + + {at("copied", {}, "Copied")} + {:else} + + {at("copy", {}, "Copy")} + {/if} + +
+
+{/snippet} + {#snippet renderField(field)} {@const revealed = isSecretRevealed(field.key)}
@@ -420,6 +524,9 @@ {@const labelGroups = groups.filter((g) => g.label)}
{#if rootGroup} + {#if rootGroup.webhook} + {@render renderWebhookHint(rootGroup.webhook)} + {/if} {#each rootGroup.fields as field} {@render renderField(field)} {/each} @@ -463,6 +570,9 @@
+ {#if group.webhook} + {@render renderWebhookHint(group.webhook)} + {/if} {#each group.fields as field} {@render renderField(field)} {/each} diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index d6b5688..c92a658 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -1889,6 +1889,65 @@ align-items: center; } +.admin-webhook-hint { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 16px; + align-items: center; + min-width: 0; + padding: 14px 18px; + border-bottom: 1px solid var(--admin-border); + background: color-mix(in srgb, var(--info) 7%, transparent); +} + +.admin-webhook-hint-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-webhook-hint-meta strong { + color: var(--admin-text); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-webhook-hint-meta small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.admin-webhook-value { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.admin-webhook-value code { + flex: 1 1 auto; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2)); + color: var(--admin-text); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-webhook-copy { + flex: 0 0 auto; +} + .admin-setting:last-child { border-bottom: 0; } @@ -2625,6 +2684,26 @@ padding: 14px 14px; } + .admin-webhook-hint { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + padding: 14px; + } + + .admin-webhook-value { + align-items: stretch; + flex-direction: column; + } + + .admin-webhook-value code { + white-space: normal; + overflow-wrap: anywhere; + } + + .admin-webhook-copy { + align-self: flex-start; + } + .admin-card-head { padding: 12px 14px; } diff --git a/locales/en.json b/locales/en.json index 9b9d698..3832487 100644 --- a/locales/en.json +++ b/locales/en.json @@ -956,6 +956,11 @@ "admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_wata": "Wata", "admin_settings_subsection_heleket": "Heleket", + "admin_settings_provider_webhook_url": "Webhook URL", + "admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.", + "admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.", + "admin_copy": "Copy", + "admin_copied": "Copied", "admin_settings_validation_errors": "Errors: {errors}", "admin_settings_save_error": "Error: {error}", "admin_sync_started": "Synchronization started", diff --git a/locales/ru.json b/locales/ru.json index bf7efc2..d77da6f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -956,6 +956,11 @@ "admin_settings_subsection_cryptopay": "CryptoPay", "admin_settings_subsection_wata": "Wata", "admin_settings_subsection_heleket": "Heleket", + "admin_settings_provider_webhook_url": "Webhook URL", + "admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.", + "admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.", + "admin_copy": "Копировать", + "admin_copied": "Скопировано", "admin_settings_validation_errors": "Ошибки: {errors}", "admin_settings_save_error": "Ошибка: {error}", "admin_sync_started": "Синхронизация запущена", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 546afad..cfb00e8 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -68,3 +68,13 @@ def test_subscription_purchase_description_settings_i18n_keys_exist(): assert field["section"] == "pricing" assert field["i18n_label_key"] in messages assert field["i18n_description_key"] in messages + + +def test_payment_provider_settings_include_webhook_metadata(): + manifest = _manifest_by_key() + + assert manifest["FREEKASSA_ENABLED"]["webhook_path"] == "/webhook/freekassa" + assert manifest["FREEKASSA_ENABLED"]["provider_id"] == "freekassa" + assert manifest["PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU"]["webhook_path"] == "/webhook/platega" + assert manifest["YOOKASSA_SHOP_ID"]["webhook_requires_base_url"] is True + assert "webhook_path" not in manifest["PAYMENT_STARS_WEBAPP_LABEL_RU"] diff --git a/tests/test_security.py b/tests/test_security.py index 2b15d38..61cfb6c 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -722,6 +722,61 @@ class AdminSettingsSecurityTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(secret_field["has_value"]) self.assertNotIn("super-secret", response.text) + async def test_admin_settings_exposes_payment_webhook_urls(self): + class AsyncSessionFactory: + def __call__(self): + return self + + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + SHOP_NAME="Visible shop", + WEBHOOK_BASE_URL="https://web.tnnl.cc/", + ) + request = SimpleNamespace( + app={"settings": settings, "async_session_factory": AsyncSessionFactory()}, + headers={}, + cookies={}, + admin_telegram_id=1, + ) + request.get = lambda key, default=None: getattr(request, key, default) + + with ( + patch.object(admin_settings_routes, "_require_admin_user_id", return_value=1), + patch.object( + admin_api.app_settings_dal, + "get_overrides_with_meta", + AsyncMock(return_value=[]), + ), + ): + response = await admin_api.admin_settings_get_route(request) + + payload = json.loads(response.text) + fields = { + field["key"]: field + for section in payload["sections"] + for field in section["fields"] + } + + self.assertEqual( + fields["FREEKASSA_ENABLED"]["webhook_url"], + "https://web.tnnl.cc/webhook/freekassa", + ) + self.assertTrue(fields["FREEKASSA_ENABLED"]["webhook_base_url_configured"]) + self.assertEqual( + fields["PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU"]["webhook_url"], + "https://web.tnnl.cc/webhook/platega", + ) + self.assertNotIn("webhook_url", fields["PAYMENT_STARS_WEBAPP_LABEL_RU"]) + class DatabaseLoggingSecurityTests(unittest.TestCase): def test_database_url_redaction_hides_password(self):