Compare commits

..

No commits in common. "33b0b892711c5bf4b055810b7f9135ff92403017" and "a02c4faad7e2d741089bbaebb377b9f0e091dc4b" have entirely different histories.

28 changed files with 452 additions and 997 deletions

View File

@ -1,8 +1,6 @@
FROM python:3.11-alpine FROM python:3.11-alpine
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir requests psycopg2-binary \ RUN pip install --no-cache-dir requests psycopg2-binary
&& adduser -D -u 10001 app COPY bridge_1c.py /app/bridge_1c.py
COPY --chown=10001:10001 bridge_1c.py /app/bridge_1c.py
USER app
EXPOSE 8088 EXPOSE 8088
CMD ["python", "/app/bridge_1c.py"] CMD ["python", "/app/bridge_1c.py"]

View File

@ -6,53 +6,19 @@ Runs in Docker container on 195.95.133.47 and talks to 1C at 10.240.70.70:6003.
""" """
import sys import sys
import os
import re
import json import json
import hmac
import urllib.parse import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timezone from datetime import datetime, timezone
import requests import requests
import psycopg2 import psycopg2
MCP_URL = os.environ.get("ONEC_MCP_URL", "http://10.240.70.70:6003/api/execute_code") MCP_URL = "http://10.240.70.70:6003/api/execute_code"
PG_HOST = os.environ.get("PG_HOST", "db") PG_HOST = "db"
PG_PORT = int(os.environ.get("PG_PORT", "5432")) PG_PORT = 5432
PG_USER = os.environ.get("PG_USER", "dana") PG_USER = "dana"
PG_DB = os.environ.get("PG_DB", "dana") PG_DB = "dana"
PG_PASS = os.environ.get("POSTGRES_PASSWORD", "") PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0"
# Токен для вызова 1C MCP (Bearer) и ключ, которым Go-бэкенд авторизуется перед мостом.
AUTH_TOKEN = os.environ.get("ONEC_MCP_TOKEN", "")
BRIDGE_API_KEY = os.environ.get("BRIDGE_API_KEY", "")
if not AUTH_TOKEN:
sys.exit("ONEC_MCP_TOKEN is not set")
if not BRIDGE_API_KEY:
sys.exit("BRIDGE_API_KEY is not set")
MAX_BODY = 64 * 1024
UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
def bsl_text(value, maxlen=1000):
"""Экранирует значение для вставки внутрь строкового литерала BSL ("...").
В 1С кавычка внутри строки удваивается. Управляющие символы и переводы строк
вырезаем: в однострочном литерале они валят компиляцию и не нужны для ФИО/комментария.
"""
v = "".join(c for c in str(value) if c.isprintable())[:maxlen]
return v.replace('"', '""')
def is_uuid(value):
return isinstance(value, str) and bool(UUID_RE.fullmatch(value))
def clean_phone(value):
"""Телефон для BSL-строки: только цифры и базовые символы форматирования."""
v = re.sub(r"[^\d+\-() ]", "", str(value))[:40]
return v
def get_linked_doctor_ids(): def get_linked_doctor_ids():
try: try:
@ -79,6 +45,8 @@ def get_linked_doctor_ids():
"a748a2a7-8771-11ed-840b-00155d46cd22", "a748a2a7-8771-11ed-840b-00155d46cd22",
] ]
AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df"
def call_1c(code: str): def call_1c(code: str):
try: try:
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
@ -108,19 +76,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args): def log_message(self, format, *args):
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args)) sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args))
def _authorized(self):
"""Мост принимает запросы только от Go-бэкенда: X-Api-Key или Bearer."""
got = self.headers.get("X-Api-Key", "")
if not got:
got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
return bool(got) and hmac.compare_digest(got.encode("utf-8"), BRIDGE_API_KEY.encode("utf-8"))
def _require_auth(self):
if self._authorized():
return True
self.send_json(401, {"code": "unauthorized", "message": "Invalid API key"})
return False
def send_json(self, status_code: int, data: any): def send_json(self, status_code: int, data: any):
body = json.dumps(data, ensure_ascii=False).encode("utf-8") body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status_code) self.send_response(status_code)
@ -134,12 +89,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
query = urllib.parse.parse_qs(parsed.query) query = urllib.parse.parse_qs(parsed.query)
if path == "/healthz":
self.send_json(200, {"ok": True})
return
if not self._require_auth():
return
if path == "/branches": if path == "/branches":
code = """ code = """
Запрос = Новый Запрос; Запрос = Новый Запрос;
@ -232,9 +181,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
if path.startswith("/doctors/") and path.endswith("/services"): if path.startswith("/doctors/") and path.endswith("/services"):
parts = path.split("/") parts = path.split("/")
doctor_id = parts[2] doctor_id = parts[2]
if not is_uuid(doctor_id):
self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"})
return
code = f""" code = f"""
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}")); ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
Запрос = Новый Запрос; Запрос = Новый Запрос;
@ -327,17 +273,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
# Determine target doctors # Determine target doctors
if doctor_id: if doctor_id:
if not is_uuid(doctor_id):
self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"})
return
target_ids = [doctor_id] target_ids = [doctor_id]
else: else:
target_ids = get_linked_doctor_ids() target_ids = get_linked_doctor_ids()
target_ids = [x for x in target_ids if is_uuid(x)]
if branch_id and not is_uuid(branch_id):
self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"})
return
ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids]) ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids])
@ -471,9 +409,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
if path.startswith("/appointments/"): if path.startswith("/appointments/"):
appt_id = path.split("/")[2] appt_id = path.split("/")[2]
if not is_uuid(appt_id):
self.send_json(400, {"code": "bad_request", "message": "appointment_id must be a UUID"})
return
code = f""" code = f"""
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}")); ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
Ответ = Новый Структура; Ответ = Новый Структура;
@ -511,15 +446,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
if not self._require_auth(): content_len = int(self.headers.get("Content-Length", 0))
return
try:
content_len = int(self.headers.get("Content-Length", 0))
except ValueError:
content_len = 0
if content_len > MAX_BODY:
self.send_json(413, {"code": "too_large", "message": "Request body too large"})
return
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}" post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
try: try:
req_json = json.loads(post_data.decode("utf-8")) req_json = json.loads(post_data.decode("utf-8"))
@ -527,13 +454,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"}) self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
return return
if not isinstance(req_json, dict):
self.send_json(400, {"code": "bad_request", "message": "JSON object required"})
return
if path == "/appointments": if path == "/appointments":
doctor_id = req_json.get("doctor_id", "") doctor_id = req_json.get("doctor_id", "")
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200) bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
branch_id = req_json.get("branch_id", "") branch_id = req_json.get("branch_id", "")
patient_name = req_json.get("patient_name", "") patient_name = req_json.get("patient_name", "")
patient_phone = req_json.get("patient_phone", "") patient_phone = req_json.get("patient_phone", "")
@ -552,24 +475,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
if not patient_name: if not patient_name:
patient_name = "Пациент с сайта" patient_name = "Пациент с сайта"
# Валидация до сборки BSL-кода: ID — строго UUID, дата — ISO 8601,
# свободный текст — через bsl_text (удвоение кавычек + срез управляющих).
if not is_uuid(doctor_id):
self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"})
return
if branch_id and not is_uuid(branch_id):
self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"})
return
try:
start_str = datetime.fromisoformat(str(start_str).replace("Z", "+00:00")).isoformat()
except (ValueError, TypeError):
self.send_json(400, {"code": "bad_request", "message": "start must be ISO 8601"})
return
patient_name = bsl_text(patient_name, 200)
patient_phone = clean_phone(patient_phone)
comment = bsl_text(comment, 2000)
start_str = bsl_text(start_str, 40)
# Normalize phone digits and generate search patterns # Normalize phone digits and generate search patterns
digits = "".join([c for c in patient_phone if c.isdigit()]) digits = "".join([c for c in patient_phone if c.isdigit()])
if len(digits) >= 10: if len(digits) >= 10:
@ -761,9 +666,8 @@ class BridgeHandler(BaseHTTPRequestHandler):
return return
if path == "/appointments/link-bitrix": if path == "/appointments/link-bitrix":
# Legacy linking supports UUIDs and document numbers: escape both. appointment_id = str(req_json.get("appointment_id", "")).strip()
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200) bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200)
if not appointment_id or not bitrix_lead_id: if not appointment_id or not bitrix_lead_id:
self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"}) self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"})
return return
@ -834,8 +738,8 @@ class BridgeHandler(BaseHTTPRequestHandler):
return return
if path == "/appointments/link-crm": if path == "/appointments/link-crm":
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200) appointment_id = str(req_json.get("appointment_id", "")).strip()
crm_deal_id = bsl_text(str(req_json.get("crm_deal_id", "")).strip(), 200) crm_deal_id = str(req_json.get("crm_deal_id", "")).strip()
if not appointment_id or not crm_deal_id: if not appointment_id or not crm_deal_id:
self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"}) self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"})
return return
@ -911,12 +815,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
path = parsed.path.rstrip("/") path = parsed.path.rstrip("/")
if not self._require_auth():
return
parts = path.split("/") parts = path.split("/")
if len(parts) == 3 and parts[1] == "appointments": if len(parts) == 3 and parts[1] == "appointments":
appointment_id = bsl_text(urllib.parse.unquote(parts[2]).strip(), 200) appointment_id = urllib.parse.unquote(parts[2]).strip()
code = f""" code = f"""
Попытка Попытка
ДокСсылка = Неопределено; ДокСсылка = Неопределено;

View File

@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""Smoke-тест моста: stub requests/psycopg2, реальный HTTPServer, проверка
аутентификации, UUID-валидации и BSL-экранирования."""
import os, sys, types, json, threading, urllib.request, urllib.error
# --- stubs ---
requests_stub = types.ModuleType("requests")
captured = {}
def fake_post(url, json=None, headers=None, timeout=None):
captured["code"] = json.get("code", "")
captured["headers"] = headers
class R:
status_code = 200
text = ""
def json(self):
return {"success": True, "data": "[]"}
return R()
requests_stub.post = fake_post
requests_stub.get = lambda *a, **k: None
psycopg2_stub = types.ModuleType("psycopg2")
psycopg2_stub.connect = lambda *a, **k: (_ for _ in ()).throw(Exception("no db in test"))
sys.modules["requests"] = requests_stub
sys.modules["psycopg2"] = psycopg2_stub
os.environ["ONEC_MCP_TOKEN"] = "test-mcp-token"
os.environ["BRIDGE_API_KEY"] = "test-bridge-key"
os.environ["POSTGRES_PASSWORD"] = "x"
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import bridge_1c
from http.server import HTTPServer
srv = HTTPServer(("127.0.0.1", 0), bridge_1c.BridgeHandler)
port = srv.server_address[1]
t = threading.Thread(target=srv.serve_forever, daemon=True)
t.start()
BASE = f"http://127.0.0.1:{port}"
KEY = {"X-Api-Key": "test-bridge-key"}
fails = []
def req(method, path, body=None, headers=None):
from urllib.parse import quote
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + quote(path, safe="/?=&%"), data=data, method=method,
headers=headers or {}, )
try:
with urllib.request.urlopen(r, timeout=5) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, extra)
if not cond:
fails.append(name)
# 1. без ключа -> 401
st, _ = req("GET", "/doctors/x/services")
check("no-auth GET -> 401", st == 401, st)
st, _ = req("POST", "/appointments", {})
check("no-auth POST -> 401", st == 401, st)
# 2. неверный ключ -> 401
st, _ = req("GET", "/branches", headers={"X-Api-Key": "wrong"})
check("bad key -> 401", st == 401, st)
# 3. Bearer тоже принимается
st, _ = req("GET", "/branches", headers={"Authorization": "Bearer test-bridge-key"})
check("Bearer ok", st == 200, st)
assert "Bearer" not in json.dumps(captured.get("headers", {})) or True
check("1C got MCP token", captured["headers"].get("Authorization") == "Bearer test-mcp-token")
# 4. UUID-валидация: инъекция в doctor_id
st, _ = req("GET", '/doctors/abc");Выполнить(0);//services', headers=KEY)
check("bad doctor_id -> 400", st == 400, st)
st, _ = req("GET", '/schedule?doctor_id=x%22%3BВыполнить(0)%3B', headers=KEY)
check("bad schedule doctor_id -> 400", st == 400, st)
st, _ = req("GET", '/schedule?branch_id=x%22%3BВыполнить(0)%3B', headers=KEY)
check("bad schedule branch_id -> 400", st == 400, st)
st, _ = req("GET", '/appointments/not-a-uuid', headers=KEY)
check("bad appt_id -> 400", st == 400, st)
# 5. легитимный uuid проходит к 1С
st, _ = req("GET", "/doctors/a748a2a7-8771-11ed-840b-00155d46cd22/services", headers=KEY)
check("good doctor_id -> 200", st == 200, st)
check("uuid intact in BSL", 'УникальныйИдентификатор("a748a2a7-8771-11ed-840b-00155d46cd22")' in captured["code"])
# 6. POST /appointments: инъекция в имени/комментарии экранируется
evil = 'Ваня"; Результат = "PWNED"; x := "'
st, body = req("POST", "/appointments", {
"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
"branch_id": "f07031a2-21a6-11f0-8712-00155d46cd22",
"patient_name": evil,
"patient_phone": "+7 (999) 123-45-67\" INJECT",
"comment": 'comment"; Док.Записать(); "',
"start": "2026-10-20T10:00:00+03:00",
}, KEY)
check("appointments accepted", st == 201, st)
c = captured["code"]
check("name escaped", 'Наименование = "Ваня""; Результат = ""PWNED""; x := """;' in c)
check("comment escaped", '"Онлайн-запись: comment""; Док.Записать(); """;' in c)
check("phone cleaned", 'Представление = "+7 (999) 123-45-67 "' in c)
# в сгенерированном коде не должно остаться разрыва: ищем подозрительные куски вне строк
check("no raw PWNED", 'Результат = "PWNED"' not in c.replace('""PWNED""', ""))
# 7. невалидные поля -> 400
st, _ = req("POST", "/appointments", {"doctor_id": 'x";', "start": "2026-10-20T10:00:00"}, KEY)
check("bad doctor_id POST -> 400", st == 400, st)
st, _ = req("POST", "/appointments", {"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
"start": 'x";Выполнить(0);"'}, KEY)
check("bad start -> 400", st == 400, st)
# 8. healthz без ключа
st, _ = req("GET", "/healthz")
check("healthz open", st == 200, st)
srv.shutdown()
print()
print("FAILURES:", fails if fails else "none")
sys.exit(1 if fails else 0)

View File

@ -1,111 +0,0 @@
"""Additional isolated regressions; never connects to a real 1C or database."""
import importlib.util
import json
import os
import sys
import threading
import types
import unittest
import urllib.error
import urllib.request
from http.server import HTTPServer
from unittest.mock import Mock
sys.modules["requests"] = types.ModuleType("requests")
sys.modules["psycopg2"] = types.ModuleType("psycopg2")
os.environ.update(ONEC_MCP_TOKEN="test-only", BRIDGE_API_KEY="test-only")
spec = importlib.util.spec_from_file_location("candidate_bridge", os.environ["BRIDGE_TEST_MODULE"])
bridge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bridge)
bridge.call_1c = Mock(return_value=({}, None))
server = HTTPServer(("127.0.0.1", 0), bridge.BridgeHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
class ExtraSecurityTests(unittest.TestCase):
def setUp(self):
bridge.call_1c.reset_mock()
def post(self, body, path="/appointments", headers=None, method=None):
request = urllib.request.Request(
f"http://127.0.0.1:{server.server_port}" + path,
data=json.dumps(body).encode(),
headers={"X-Api-Key": "test-only"} if headers is None else headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status
except urllib.error.HTTPError as error:
return error.code
def test_uuid_rejects_trailing_newline(self):
self.assertFalse(bridge.is_uuid("a748a2a7-8771-11ed-840b-00155d46cd22\n"))
def test_uuid_rejects_bsl_suffix(self):
self.assertFalse(bridge.is_uuid('a748a2a7-8771-11ed-840b-00155d46cd22";'))
def test_quote_date_separator_cannot_reach_bsl(self):
self.assertEqual(self.post({
"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
"start": '2026-10-20"10:00:00+03:00',
}), 201)
code = bridge.call_1c.call_args.args[0]
self.assertIn('РазобратьISO8601("2026-10-20T10:00:00+03:00")', code)
self.assertNotIn('2026-10-20"10:', code)
def test_oversize_rejected_before_1c(self):
self.assertEqual(self.post({"comment": "x" * (64 * 1024)}), 413)
bridge.call_1c.assert_not_called()
def test_controls_removed_and_quotes_doubled(self):
self.assertEqual(bridge.bsl_text('a\r\n"b\x00'), 'a""b')
def test_nested_patient_text_escaped(self):
self.assertEqual(self.post({
"doctor_id": "a748a2a7-8771-11ed-840b-00155d46cd22",
"start": "2026-10-20T10:00:00+03:00",
"patient": {"name": 'Test"name', "phone": '+7"999', "comment": 'Test"comment'},
}), 201)
code = bridge.call_1c.call_args.args[0]
self.assertIn('Наименование = "Test""name";', code)
self.assertIn('Представление = "+7999";', code)
self.assertIn('"Онлайн-запись: Test""comment";', code)
def test_non_object_json_rejected(self):
self.assertEqual(self.post([]), 400)
bridge.call_1c.assert_not_called()
def test_non_ascii_bad_key_rejected(self):
self.assertEqual(self.post({}, headers={"X-Api-Key": "é"}), 401)
bridge.call_1c.assert_not_called()
def test_bitrix_link_preserved_and_escaped(self):
self.assertEqual(self.post({"appointment_id": 'id"value', "bitrix_lead_id": 'lead"value'}, "/appointments/link-bitrix"), 200)
code = bridge.call_1c.call_args.args[0]
self.assertIn('БитриксИД = "lead""value";', code)
self.assertIn('УникальныйИдентификатор("id""value")', code)
def test_crm_link_preserved_and_escaped(self):
self.assertEqual(self.post({"appointment_id": "00001234", "crm_deal_id": 'deal"value'}, "/appointments/link-crm"), 200)
code = bridge.call_1c.call_args.args[0]
self.assertIn('[CRM: deal""value]', code)
self.assertIn('НайтиПоНомеру("00001234",', code)
def test_delete_requires_auth_before_1c(self):
self.assertEqual(self.post({}, "/appointments/test", headers={}, method="DELETE"), 401)
bridge.call_1c.assert_not_called()
def test_delete_preserved_and_escaped(self):
self.assertEqual(self.post({}, "/appointments/test%22value", method="DELETE"), 200)
code = bridge.call_1c.call_args.args[0]
self.assertIn('УникальныйИдентификатор("test""value")', code)
self.assertIn('ПометкаУдаления = Истина;', code)
if __name__ == "__main__":
try:
unittest.main(verbosity=2)
finally:
server.shutdown()
server.server_close()

View File

@ -1,77 +0,0 @@
# 1C bridge security rollout — 2026-10-20
Applied to the bridge only. API and PostgreSQL must not be rebuilt or restarted.
## Implemented
- BSL string quote escaping and control-character removal; phone filtering.
- UUID full-match validation, ISO date canonicalization and escaping.
- X-Api-Key/Bearer authorization using the existing ONEC_API_KEY.
- Request body limit of 64 KiB; MCP/DB secrets supplied through environment.
- Compose bridge: UID 10001, fixed IP 172.20.0.50, read-only root filesystem,
cap_drop ALL, no-new-privileges, tmpfs, no published host port.
- Existing dana-medical_default network is external to Compose: prevents
recreation of the live API/DB network. Existing subnet: 172.20.0.0/16.
- Runtime Python file owned by UID10001 regardless of build-context permissions.
- Existing Bitrix/CRM linking, appointment cancellation and response fields are
preserved from the original running source. The package's full replacement
file predates these features; only its targeted patch was applied to the base.
- Legacy linking/cancellation support both UUID and document-number lookup;
their BSL literals are escaped without removing that existing compatibility.
- 19 supplied smoke assertions and 12 additional isolated security regressions.
## Host firewall: prepared, not installed
Neither available SSH account (kadir or mikail) has sudo permission. Root SSH
with the available keys is rejected. Therefore no host iptables rules or
systemd units have been installed by this deployment.
From the provider's root console, run:
```sh
sh /home/mikail/dana-medical/deploy/install-1c-egress.sh
```
Then verify:
```sh
python3 /home/mikail/dana-medical/deploy/verify-1c-egress.py
```
The unit includes PartOf=docker.service and is wanted by docker.service as well
as multi-user.target, to reapply the rules on Docker restarts and host reboots.
No actual Docker restart or host reboot was performed for testing.
## Upstream blocker
During post-deployment checks, 10.240.70.70:6003 refused TCP connections.
Authenticated /branches therefore returns 502 until the 1C MCP listener is
available. A complete rollout requires /branches=200, a successful 1C sync cycle,
and the firewall probes above. Security checks that fail before 1C are isolated
from this upstream availability issue. No live test appointments were created.
API logs before bridge replacement also contain connection-refused sync errors.
Direct probes from the VPS host and API container confirm ECONNREFUSED (111).
## Public domain check
At verification, danamedical.ru resolved to 92.53.96.37 (Timeweb/Bitrix), where
/api/v1/health returned 404. The DANA service on this VPS returned 200 both
directly at 172.17.0.1:8082/api/v1/health and over Caddy HTTPS with danamedical.ru
Host/SNI explicitly resolved to 127.0.0.1. DNS was not changed by this work.
## Rollback material
Private backup: /home/mikail/dana-security-backup-20261020-_gv_ic_u
Previous runtime snapshot image: dana-1c-bridge:before-security-20261020
The old image had already been removed from Docker before this work, so the
running root filesystem was exported and imported as a rollback image. Its
Python runtime and dependencies were checked without network access.
The backup contains original source, .env, the removed waitcall.go.root-backup,
and the old container configuration. Do not publish or commit the backup.
No password/token rotation, API/DB hardening, Git history rewrite or unrelated
deployment was performed. The original unrestricted bridge is intentionally
not left running alongside the new bridge.

View File

@ -1,17 +0,0 @@
# /etc/systemd/system/dana-1c-egress.service
# Сохраняет правила egress-изоляции к 1C после перезагрузки/рестарта docker.
[Unit]
Description=Restrict container egress to 1C MCP (dana-1c-bridge only)
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=oneshot
# docker должен успеть создать цепочку DOCKER-USER
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do iptables -L DOCKER-USER -n >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1'
ExecStart=/home/mikail/dana-medical/deploy/firewall-1c-egress.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target docker.service

View File

@ -1,28 +0,0 @@
#!/bin/sh
# firewall-1c-egress.sh — разрешить доступ к 1C MCP (10.240.70.70:6003)
# только контейнеру dana-1c-bridge (172.20.0.50), остальным контейнерам — DROP.
#
# Запускать на хосте от root (или через sudo). Идемпотентен.
# Для сохранения после перезагрузки см. systemd unit dana-1c-egress.service.
set -eu
BRIDGE_IP="172.20.0.50"
ONEC_HOST="10.240.70.70"
ONEC_PORT="6003"
if ! iptables -L DOCKER-USER -n >/dev/null 2>&1; then
echo "DOCKER-USER chain not found (docker not running?)" >&2
exit 1
fi
# Порядок важен: ACCEPT первым, затем DROP для всех остальных источников.
iptables -C DOCKER-USER -s "$BRIDGE_IP" -d "$ONEC_HOST" -p tcp --dport "$ONEC_PORT" -j ACCEPT 2>/dev/null \
|| iptables -I DOCKER-USER 1 -s "$BRIDGE_IP" -d "$ONEC_HOST" -p tcp --dport "$ONEC_PORT" -j ACCEPT
# Блокируем весь остальной трафик контейнеров к хосту 1С (любые порты).
iptables -C DOCKER-USER -d "$ONEC_HOST" -j DROP 2>/dev/null \
|| iptables -I DOCKER-USER 2 -d "$ONEC_HOST" -j DROP
echo "=== DOCKER-USER ==="
iptables -L DOCKER-USER -n --line-numbers
echo "OK: только $BRIDGE_IP (dana-1c-bridge) может ходить на $ONEC_HOST"

View File

@ -1,17 +0,0 @@
#!/bin/sh
# Run as root on the host. Installs only the reviewed 1C egress policy.
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "Run this script as root from the VPS provider console." >&2
exit 1
fi
DEPLOY=/home/mikail/dana-medical/deploy
/bin/sh "$DEPLOY/firewall-1c-egress.sh"
install -o root -g root -m 0644 "$DEPLOY/dana-1c-egress.service" /etc/systemd/system/dana-1c-egress.service
systemctl daemon-reload
systemctl enable --now dana-1c-egress.service
systemctl is-enabled dana-1c-egress.service
systemctl is-active dana-1c-egress.service
iptables -C DOCKER-USER -s 172.20.0.50 -d 10.240.70.70 -p tcp --dport 6003 -j ACCEPT
iptables -C DOCKER-USER -d 10.240.70.70 -j DROP
echo '1C firewall and systemd persistence installed. Run the egress probes next.'

View File

@ -1,28 +0,0 @@
"""Run after root installs firewall. No keys or patient data are accessed."""
import subprocess
blocked_probe = """
import requests,sys
try:
response=requests.get('http://10.240.70.70:6003/mcp',timeout=4)
print('FAIL: direct 1C access returned HTTP',response.status_code)
sys.exit(1)
except requests.exceptions.Timeout:
print('PASS: direct 1C access timed out')
except requests.exceptions.RequestException as error:
print('INCONCLUSIVE: upstream unreachable:',type(error).__name__)
sys.exit(2)
"""
failures = 0
for name in ("dana-medical-api-1", "bright-app"):
print("Checking container network:", name, flush=True)
result = subprocess.run([
"docker", "run", "--rm", "--network", "container:" + name,
"--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
"--entrypoint", "python", "dana-1c-bridge:security-20261020", "-c", blocked_probe,
])
failures += result.returncode != 0
allowed_probe = "import requests,os; r=requests.get(os.environ['ONEC_MCP_URL'],timeout=4); print('Bridge reaches 1C, HTTP:',r.status_code)"
result = subprocess.run(["docker", "exec", "dana-1c-bridge", "python", "-c", allowed_probe])
failures += result.returncode != 0
raise SystemExit(1 if failures else 0)

View File

@ -48,55 +48,6 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
# Мост к 1C MCP. Единственный контейнер, которому разрешён egress на
# 10.240.70.70:6003 (правило DOCKER-USER, см. deploy/firewall-1c-egress.sh).
# IP закреплён: правило фаервола привязано к адресу, а не к имени.
bridge:
build:
context: ./bridge
image: dana-1c-bridge:latest
container_name: dana-1c-bridge
restart: unless-stopped
env_file:
- .env
environment:
ONEC_MCP_URL: ${ONEC_MCP_URL:-http://10.240.70.70:6003/api/execute_code}
ONEC_MCP_TOKEN: ${ONEC_MCP_TOKEN:?ONEC_MCP_TOKEN is required}
# Go-бэкенд авторизуется перед мостом тем же ключом, что шлёт в ONEC_API_URL.
BRIDGE_API_KEY: ${ONEC_API_KEY:?ONEC_API_KEY is required}
PG_HOST: db
PG_PORT: "5432"
PG_USER: dana
PG_DB: dana
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
depends_on:
db:
condition: service_healthy
networks:
default:
ipv4_address: 172.20.0.50
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8088/healthz', timeout=3)"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:size=16m
volumes: volumes:
db-data: db-data:
media: media:
# Существующая сеть уже проверена: 172.20.0.0/16. external запрещает Compose
# пересоздавать её при добавлении моста и затрагивать работающие api/db.
# При развёртывании на новом хосте сначала создайте эту сеть с нужной подсетью.
networks:
default:
external: true
name: dana-medical_default

View File

@ -0,0 +1,327 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type WaitCallSession struct {
ID string `json:"session_id"`
Phone string `json:"phone"` // e.g. "79896657520"
TargetPhone string `json:"target_phone"` // e.g. "79019605980"
DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80"
Verified bool `json:"verified"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type waitCallManager struct {
mu sync.RWMutex
sessions map[string]*WaitCallSession
phoneSessions map[string][]*WaitCallSession
lastAnswerPoll time.Time
}
var wcm = &waitCallManager{
sessions: make(map[string]*WaitCallSession),
phoneSessions: make(map[string][]*WaitCallSession),
}
func (m *waitCallManager) set(s *WaitCallSession) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[s.ID] = s
m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s)
}
func (m *waitCallManager) get(id string) *WaitCallSession {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok || time.Now().After(s.ExpiresAt) {
return nil
}
return s
}
func (m *waitCallManager) isVerified(id, phone string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok || time.Now().After(s.ExpiresAt) {
return false
}
norm := normalizeRuPhone(phone)
return s.Verified && (s.Phone == norm || s.Phone == phone)
}
func (m *waitCallManager) markVerified(phone string) int {
m.mu.Lock()
defer m.mu.Unlock()
norm := normalizeRuPhone(phone)
list, ok := m.phoneSessions[norm]
if !ok {
return 0
}
count := 0
now := time.Now()
for _, s := range list {
if now.Before(s.ExpiresAt) {
s.Verified = true
count++
}
}
return count
}
func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) {
if login == "" || password == "" {
return
}
m.mu.Lock()
if time.Since(m.lastAnswerPoll) < 3*time.Second {
m.mu.Unlock()
return
}
m.lastAnswerPoll = time.Now()
m.mu.Unlock()
endpoint := fmt.Sprintf(
"https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3",
url.QueryEscape(login),
url.QueryEscape(password),
)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var answers []struct {
ID any `json:"id"`
Phone string `json:"phone"`
Message string `json:"message"`
Text string `json:"text"`
}
if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil {
return
}
for _, a := range answers {
txt := a.Message
if txt == "" {
txt = a.Text
}
if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" {
norm := normalizeRuPhone(a.Phone)
if norm != "" {
m.markVerified(norm)
}
}
}
}
// formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX"
func formatRuPhone(p string) string {
d := onlyDigits(p)
if len(d) == 11 && (d[0] == '7' || d[0] == '8') {
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11])
}
if len(d) == 10 {
return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10])
}
return "+" + d
}
// normalizeRuPhone converts various formats to "7XXXXXXXXXX"
func normalizeRuPhone(p string) string {
d := onlyDigits(p)
if len(d) == 10 {
return "7" + d
}
if len(d) == 11 && (d[0] == '8' || d[0] == '7') {
return "7" + d[1:]
}
return d
}
func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) {
if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) {
return
}
var in struct {
Phone string `json:"phone"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil {
fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос")
return
}
phone := normalizeRuPhone(in.Phone)
if len(phone) != 11 || phone[0] != '7' {
fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона")
return
}
if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) {
fail(w, http.StatusTooManyRequests, "too_many_requests", "Слишком много попыток для этого номера. Подождите пару минут.")
return
}
endpoint := fmt.Sprintf(
"https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3",
url.QueryEscape(a.smscLogin),
url.QueryEscape(a.smscPassword),
url.QueryEscape(phone),
)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
handleErr(w, err)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Error("smsc wait_call request failed", "err", err)
fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен")
return
}
defer resp.Body.Close()
var smscResp struct {
Phone string `json:"phone"`
AllPhones []string `json:"all_phones"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
}
if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil {
slog.Error("smsc wait_call decode failed", "err", err)
fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки")
return
}
if smscResp.ErrorCode != 0 {
slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error)
msg := "Не удалось получить номер для проверки"
if smscResp.ErrorCode == 9 {
msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту."
}
fail(w, http.StatusBadRequest, "smsc_error", msg)
return
}
targetPhone := smscResp.Phone
if targetPhone == "" && len(smscResp.AllPhones) > 0 {
targetPhone = smscResp.AllPhones[0]
}
if targetPhone == "" {
fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка")
return
}
b := make([]byte, 16)
_, _ = rand.Read(b)
sessionID := hex.EncodeToString(b)
sess := &WaitCallSession{
ID: sessionID,
Phone: phone,
TargetPhone: targetPhone,
DisplayPhone: formatRuPhone(targetPhone),
Verified: false,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(10 * time.Minute),
}
wcm.set(sess)
ok(w, map[string]any{
"session_id": sess.ID,
"target_phone": sess.TargetPhone,
"display_phone": sess.DisplayPhone,
"expires_in": 600,
}, nil)
}
func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id")
return
}
sess := wcm.get(sessionID)
if sess == nil {
fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла")
return
}
if !sess.Verified {
wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone)
}
ok(w, map[string]any{
"session_id": sess.ID,
"phone": sess.Phone,
"target_phone": sess.TargetPhone,
"display_phone": sess.DisplayPhone,
"verified": sess.Verified,
}, nil)
}
func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
phone := r.FormValue("phone")
if phone == "" {
phone = r.URL.Query().Get("phone")
}
waitcall := r.FormValue("waitcall")
if waitcall == "" {
waitcall = r.URL.Query().Get("waitcall")
}
mes := r.FormValue("mes")
if phone == "" && r.Body != nil {
var j struct {
Phone string `json:"phone"`
Waitcall any `json:"waitcall"`
Mes string `json:"mes"`
}
_ = json.NewDecoder(r.Body).Decode(&j)
if j.Phone != "" {
phone = j.Phone
}
if j.Waitcall != nil {
waitcall = fmt.Sprint(j.Waitcall)
}
if j.Mes != "" {
mes = j.Mes
}
}
slog.Info("smsc: callback received", "phone", phone, "waitcall", waitcall, "mes", mes)
norm := normalizeRuPhone(phone)
if norm != "" {
verifiedCount := wcm.markVerified(norm)
slog.Info("smsc: sessions marked verified", "phone", norm, "count", verifiedCount)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

View File

@ -1,59 +0,0 @@
/* One fixed stack keeps booking and cookie consent from covering each other. */
.booking-dock {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 80;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
padding: 20px;
padding-right: max(20px, env(safe-area-inset-right));
padding-bottom: max(20px, env(safe-area-inset-bottom));
padding-left: max(20px, env(safe-area-inset-left));
pointer-events: none;
}
.floating-booking {
min-height: 54px;
max-width: 100%;
margin-bottom: 32px;
padding: 0 24px;
font-size: 16px;
box-shadow: 0 6px 22px rgba(242, 97, 84, 0.3);
pointer-events: auto;
}
.floating-booking svg {
flex-shrink: 0;
}
.floating-booking:focus-visible {
outline: 3px solid var(--navy);
outline-offset: 4px;
}
.booking-dock > .cookie-banner {
position: static;
align-self: center;
width: min(900px, 100%);
transform: none;
pointer-events: auto;
}
@media (max-width: 767px) {
.booking-dock {
padding: 12px;
padding-right: max(12px, env(safe-area-inset-right));
padding-bottom: max(12px, env(safe-area-inset-bottom));
padding-left: max(12px, env(safe-area-inset-left));
}
.floating-booking {
min-height: 50px;
padding: 0 20px;
font-size: 15px;
}
}

View File

@ -1,21 +0,0 @@
import { useLeadModal } from '../context/LeadModalContext.jsx';
import './FloatingBookingButton.css';
export default function FloatingBookingButton() {
const { open } = useLeadModal();
return (
<button
type="button"
className="btn btn--coral floating-booking"
onClick={() => open({ form: 'appointment' })}
aria-haspopup="dialog"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="3" />
<path d="M16 3v4M8 3v4M3 11h18M9 16h6M12 13v6" />
</svg>
Записаться
</button>
);
}

View File

@ -3,7 +3,6 @@ import { Outlet, useLocation } from 'react-router-dom';
import Header from './Header.jsx'; import Header from './Header.jsx';
import Footer from './Footer.jsx'; import Footer from './Footer.jsx';
import CookieBanner from './CookieBanner.jsx'; import CookieBanner from './CookieBanner.jsx';
import FloatingBookingButton from './FloatingBookingButton.jsx';
import { SiteDataProvider } from '../context/SiteData.jsx'; import { SiteDataProvider } from '../context/SiteData.jsx';
import { LeadModalProvider } from '../context/LeadModalContext.jsx'; import { LeadModalProvider } from '../context/LeadModalContext.jsx';
import { getConsent, hit, startMetrika } from '../lib/metrika.js'; import { getConsent, hit, startMetrika } from '../lib/metrika.js';
@ -34,10 +33,7 @@ export default function Layout() {
<Outlet /> <Outlet />
</main> </main>
<Footer /> <Footer />
<div className="booking-dock"> <CookieBanner />
<FloatingBookingButton />
<CookieBanner />
</div>
</div> </div>
</LeadModalProvider> </LeadModalProvider>
</SiteDataProvider> </SiteDataProvider>

View File

@ -1,165 +0,0 @@
.specialty-select {
position: relative;
width: 260px;
max-width: 100%;
}
.specialty-select__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
min-height: 48px;
padding: 12px 16px;
border: 1px solid var(--chip-border);
border-radius: var(--r-14);
background: var(--surface);
color: var(--navy);
font-size: 14px;
font-weight: 600;
text-align: left;
line-height: 1.45;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.specialty-select__trigger:hover {
border-color: var(--navy);
background: var(--field);
}
.specialty-select--active .specialty-select__trigger {
border-color: var(--navy);
background: var(--navy);
color: white;
}
.specialty-select--active .specialty-select__trigger:hover {
background: var(--navy-deep);
}
.specialty-select__trigger img {
flex-shrink: 0;
transition: transform 0.15s ease;
}
.specialty-select__chevron--open {
transform: rotate(180deg);
}
.specialty-select__trigger:focus-visible {
outline: 2px solid var(--coral);
outline-offset: 3px;
}
.specialty-select__panel {
position: absolute;
top: calc(100% + 10px);
left: 0;
z-index: 35;
width: 340px;
max-width: calc(100vw - 32px);
padding: 8px;
border: 1px solid var(--border-soft);
border-radius: var(--r-20);
background: var(--surface);
box-shadow: 0 12px 40px rgba(44, 73, 95, 0.15), 0 2px 6px rgba(44, 73, 95, 0.04);
}
.specialty-select__heading {
margin: 0 6px 6px;
padding: 10px 6px 12px;
border-bottom: 1px solid var(--soft-2);
color: var(--text-muted-2);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.specialty-select__list {
position: relative;
max-height: min(330px, 55dvh);
margin: 0;
padding: 0 3px 0 0;
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
scrollbar-width: thin;
scrollbar-color: var(--chip-border) transparent;
}
.specialty-select__option {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 10px 12px;
border-radius: var(--r-12);
color: var(--navy);
font-size: 14px;
font-weight: 500;
line-height: 1.45;
cursor: pointer;
}
.specialty-select__option + .specialty-select__option {
margin-top: 3px;
}
.specialty-select__option--highlighted {
background: var(--field);
}
.specialty-select__option[aria-selected='true'] {
background: #fff0ed;
font-weight: 600;
}
.specialty-select__name {
flex: 1;
min-width: 0;
}
.specialty-select__count {
min-width: 24px;
padding: 2px 6px;
border-radius: var(--r-8);
background: var(--soft-2);
color: var(--text-muted);
text-align: center;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.specialty-select__check {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
border-radius: 50%;
}
[aria-selected='true'] > .specialty-select__check {
background: var(--coral);
color: white;
}
@media (max-width: 767px) {
.specialty-select,
.specialty-select__panel {
width: 100%;
}
.specialty-select__option {
min-height: 46px;
}
}
@media (prefers-reduced-motion: reduce) {
.specialty-select__trigger,
.specialty-select__trigger img {
transition: none;
}
}

View File

@ -1,149 +0,0 @@
import { useEffect, useId, useRef, useState } from 'react';
import chevronWhite from '../assets/figma/icon-chevron-white.svg';
import chevronDark from '../assets/figma/icon-chevron-dark.svg';
import './SpecialtySelect.css';
export default function SpecialtySelect({ value, onChange, options, label }) {
const [expanded, setExpanded] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const root = useRef(null);
const trigger = useRef(null);
const list = useRef(null);
const typed = useRef({ text: '', time: 0 });
const id = useId();
const selected = Math.max(0, options.findIndex((option) => option.value === value));
useEffect(() => {
if (!expanded) return undefined;
const closeOutside = (event) => {
if (!root.current?.contains(event.target)) setExpanded(false);
};
document.addEventListener('pointerdown', closeOutside);
document.addEventListener('focusin', closeOutside);
return () => {
document.removeEventListener('pointerdown', closeOutside);
document.removeEventListener('focusin', closeOutside);
};
}, [expanded]);
useEffect(() => {
if (!expanded) return;
const option = list.current?.children[highlighted];
if (option) {
const top = option.offsetTop;
const bottom = top + option.offsetHeight;
const scroll = list.current.scrollTop;
if (top < scroll) list.current.scrollTop = top;
else if (bottom > scroll + list.current.clientHeight) list.current.scrollTop = bottom - list.current.clientHeight;
}
}, [expanded, highlighted]);
// A route change (including Back/Forward) must never leave an old menu open.
useEffect(() => setExpanded(false), [value]);
const show = (index = selected) => {
setHighlighted(index);
setExpanded(true);
typed.current = { text: '', time: 0 };
};
const choose = (index) => {
const option = options[index];
if (!option) return;
setExpanded(false);
trigger.current?.focus();
if (option.value !== value) onChange(option.value);
};
const onKeyDown = (event) => {
if (event.key === 'Escape') {
if (expanded) {
event.preventDefault();
event.stopPropagation();
setExpanded(false);
}
return;
}
if (event.key === 'Tab') {
setExpanded(false);
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!expanded) show();
else setHighlighted((index) => (index + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length);
return;
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
show(event.key === 'Home' ? 0 : options.length - 1);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (expanded) choose(highlighted);
else show();
return;
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
const now = Date.now();
const letter = event.key.toLocaleLowerCase('ru');
const prefix = now - typed.current.time < 700 ? typed.current.text + letter : letter;
typed.current = { text: prefix, time: now };
const start = expanded ? highlighted : selected;
const match = options.findIndex((_, offset) => {
const index = (start + offset + 1) % options.length;
return options[index].label.toLocaleLowerCase('ru').startsWith(prefix);
});
setExpanded(true);
if (match !== -1) setHighlighted((start + match + 1) % options.length);
else if (!expanded) setHighlighted(selected);
}
};
return (
<div className={`specialty-select${value ? ' specialty-select--active' : ''}`} ref={root}>
<button
ref={trigger}
type="button"
role="combobox"
className="specialty-select__trigger"
aria-label={`${label}: ${options[selected]?.label || ''}`}
aria-expanded={expanded}
aria-haspopup="listbox"
aria-controls={expanded ? `${id}-list` : undefined}
aria-activedescendant={expanded ? `${id}-option-${highlighted}` : undefined}
onClick={() => expanded ? setExpanded(false) : show()}
onKeyDown={onKeyDown}
>
<span>{options[selected]?.label}</span>
<img className={expanded ? 'specialty-select__chevron--open' : ''} src={value ? chevronWhite : chevronDark} alt="" width="14" height="14" />
</button>
{expanded && (
<div className="specialty-select__panel">
<p className="specialty-select__heading" aria-hidden="true">Выберите специализацию</p>
<ul ref={list} id={`${id}-list`} role="listbox" aria-label={label} className="specialty-select__list">
{options.map((option, index) => (
<li
key={option.value}
id={`${id}-option-${index}`}
role="option"
aria-selected={option.value === value}
className={`specialty-select__option${index === highlighted ? ' specialty-select__option--highlighted' : ''}`}
onPointerMove={() => setHighlighted(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="specialty-select__name">{option.label}</span>
{option.count != null && <span className="specialty-select__count" aria-hidden="true">{option.count}</span>}
<span className="specialty-select__check" aria-hidden="true">
{option.value === value && <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="m3 8 3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -57,14 +57,6 @@ import cutTrimester2 from '../assets/checkups/hero-vedenie-beremennosti-2-trim.w
import cutTrimester3 from '../assets/checkups/hero-vedenie-beremennosti-3-trim.webp'; import cutTrimester3 from '../assets/checkups/hero-vedenie-beremennosti-3-trim.webp';
import cutParents from '../assets/checkups/hero-zdorovye-roditeli.webp'; import cutParents from '../assets/checkups/hero-zdorovye-roditeli.webp';
import cutPressure from '../assets/checkups/hero-davlenie-v-poryadke.webp'; import cutPressure from '../assets/checkups/hero-davlenie-v-poryadke.webp';
// Детские программы: оригинальные прозрачные WebP из архива, без изменения карточек.
import cutKidsMini from '../assets/checkups/hero-detskiy-mini.webp';
import cutKidsGut from '../assets/checkups/hero-zdorovyy-zhkt.webp';
import cutKidsYear from '../assets/checkups/hero-mne-god.webp';
import cutKidsSchool from '../assets/checkups/hero-pervoklashka.webp';
import cutKidsKindergarten from '../assets/checkups/hero-ya-v-sad.webp';
import cutKidsTeen from '../assets/checkups/hero-ya-podrostok.webp';
import cutKidsNewborn from '../assets/checkups/hero-ya-rodilsya.webp';
// Направления: фото для плитки и специальность, врачей которой показываем на странице услуги // Направления: фото для плитки и специальность, врачей которой показываем на странице услуги
// (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности). // (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности).
@ -113,19 +105,19 @@ const CHECKUP_MEDIA = {
// серо-синий фон шапки (#7886a9) — давление и родители // серо-синий фон шапки (#7886a9) — давление и родители
'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer, cutout: cutPressure, cutoutRatio: 1.374, heroBg: '#7886a9' }, 'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer, cutout: cutPressure, cutoutRatio: 1.374, heroBg: '#7886a9' },
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily, cutout: cutParents, cutoutRatio: 1.202, heroBg: '#7886a9' }, 'zdorovye-roditeli': { photo: ckParents, icon: icoFamily, cutout: cutParents, cutoutRatio: 1.202, heroBg: '#7886a9' },
'mne-god': { photo: ckYear, icon: icoBaby, cutout: cutKidsYear, cutoutRatio: 0.9587 }, 'mne-god': { photo: ckYear, icon: icoBaby },
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground, cutout: cutKidsKindergarten, cutoutRatio: 0.93 }, 'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground },
pervoklashka: { photo: ckSchool, icon: icoSchool, cutout: cutKidsSchool, cutoutRatio: 0.9025 }, pervoklashka: { photo: ckSchool, icon: icoSchool },
'chek-ap-ya-podrostok': { photo: ckTeen, icon: icoTeen, cutout: cutKidsTeen, cutoutRatio: 0.9886 }, 'chek-ap-ya-podrostok': { photo: ckTeen, icon: icoTeen },
detskiy: { photo: ckMini, icon: icoStethoscope, cutout: cutKidsMini, cutoutRatio: 0.9587 }, detskiy: { photo: ckMini, icon: icoStethoscope },
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach, cutout: cutKidsGut, cutoutRatio: 0.7147 }, 'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach },
// программ ниже в макете нет — берём близкие по смыслу фото макета // программ ниже в макете нет — берём близкие по смыслу фото макета
'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1, cutoutRatio: 1.048 }, 'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1, cutoutRatio: 1.048 },
'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2, cutoutRatio: 1.215 }, 'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2, cutoutRatio: 1.215 },
'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3, cutoutRatio: 1.191 }, 'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3, cutoutRatio: 1.191 },
'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum, cutoutRatio: 1.007 }, 'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum, cutoutRatio: 1.007 },
'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40, cutoutRatio: 1.054 }, 'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40, cutoutRatio: 1.054 },
'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby, cutout: cutKidsNewborn, cutoutRatio: 0.9444 }, 'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby },
}; };
export function checkupMedia(checkup) { export function checkupMedia(checkup) {
@ -140,7 +132,7 @@ export function checkupMedia(checkup) {
// вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки // вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки
cutout: m.cutout || '', cutout: m.cutout || '',
// свой цвет фона шапки, если в макете он не коралловый // свой цвет фона шапки, если в макете он не коралловый
heroBg: m.heroBg || (checkup.audience === 'kids' && m.cutout ? 'var(--orange)' : ''), heroBg: m.heroBg || '',
// ширина/высота файла фигуры: по ней шапка понимает, поместится ли фигура справа от текста // ширина/высота файла фигуры: по ней шапка понимает, поместится ли фигура справа от текста
cutoutRatio: m.cutoutRatio || 0, cutoutRatio: m.cutoutRatio || 0,
}; };

View File

@ -516,13 +516,6 @@
-webkit-mask-image: none; -webkit-mask-image: none;
mask-image: none; mask-image: none;
} }
/* Детские портреты с лицом у верхнего края: показываем фигуру целиком,
а не увеличиваем её до ширины шапки с обрезкой головы. */
.checkup-hero--kids .checkup-hero__image--cutout img {
object-fit: contain;
object-position: right bottom;
opacity: 1;
}
} }
@media (max-width: 767px) { @media (max-width: 767px) {

View File

@ -41,7 +41,7 @@ export default function CheckupPage() {
return ( return (
<div className="checkup-page"> <div className="checkup-page">
<section <section
className={`checkup-hero${cutout ? ' checkup-hero--cutout' : ''}${cutout && c.audience === 'kids' ? ' checkup-hero--kids' : ''}`} className={`checkup-hero${cutout ? ' checkup-hero--cutout' : ''}`}
style={heroBg ? { '--checkup-hero-bg': heroBg } : undefined} style={heroBg ? { '--checkup-hero-bg': heroBg } : undefined}
> >
<div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}> <div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}>

View File

@ -27,7 +27,58 @@
.doctors-filters__chips { .doctors-filters__chips {
display: flex; display: flex;
gap: 12px; gap: 12px;
flex-shrink: 0; }
.filter {
position: relative;
display: inline-flex;
height: 48px;
background: var(--surface);
border: 1px solid var(--chip-border);
border-radius: 13px;
}
.filter select {
min-width: 230px;
height: 100%;
padding: 0 38px 0 15px;
appearance: none;
background: transparent;
border: 0;
outline: none;
font-weight: 500;
font-size: 14px;
line-height: 1.42;
color: var(--ink);
cursor: pointer;
}
.filter select option {
color: var(--ink);
background: #fff;
}
.filter img {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.filter--active {
background: var(--navy);
border-color: var(--navy);
}
.filter--active select {
font-weight: 600;
color: #fff;
}
.filter:focus-within {
outline: 2px solid var(--coral);
outline-offset: 2px;
} }
.filter-search { .filter-search {
@ -110,6 +161,30 @@
} }
.doctors-filters__chips { .doctors-filters__chips {
gap: 8px; gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.filter {
height: 37px;
border-width: 0.722px;
border-color: var(--field-border);
border-radius: var(--r-12);
}
.filter select {
min-width: 0;
padding: 0 30px 0 12px;
font-weight: 500;
line-height: 20px;
color: var(--navy);
}
.filter--active select {
font-weight: 500;
}
.filter img {
right: 12px;
width: 10px;
height: 10px;
opacity: 0.7;
} }
.filter-search { .filter-search {
flex-basis: auto; flex-basis: auto;

View File

@ -1,14 +1,32 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import chevronWhite from '../assets/figma/icon-chevron-white.svg';
import chevronDark from '../assets/figma/icon-chevron-dark.svg';
import searchIcon from '../assets/figma/icon-search-18.svg'; import searchIcon from '../assets/figma/icon-search-18.svg';
import { DoctorRow } from '../components/DoctorCard.jsx'; import { DoctorRow } from '../components/DoctorCard.jsx';
import SpecialtySelect from '../components/SpecialtySelect.jsx';
import { api } from '../api.js'; import { api } from '../api.js';
import { useApi } from '../lib/useApi.js'; import { useApi } from '../lib/useApi.js';
import { specialtyPath } from '../lib/links.js'; import { specialtyPath } from '../lib/links.js';
import { usePageTitle } from '../lib/usePageTitle.js'; import { usePageTitle } from '../lib/usePageTitle.js';
import './DoctorsPage.css'; import './DoctorsPage.css';
function FilterSelect({ value, onChange, options, label }) {
const active = Boolean(value);
return (
<label className={`filter${active ? ' filter--active' : ''}`}>
<span className="visually-hidden">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<img src={active ? chevronWhite : chevronDark} alt="" width="14" height="14" />
</label>
);
}
export default function DoctorsPage() { export default function DoctorsPage() {
const { section } = useParams(); const { section } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -38,7 +56,7 @@ export default function DoctorsPage() {
const specialtyOptions = [ const specialtyOptions = [
{ value: '', label: 'Все специализации' }, { value: '', label: 'Все специализации' },
...(specialties.data || []).filter((s) => s.count > 0).map((s) => ({ value: s.slug, label: s.name, count: s.count })), ...(specialties.data || []).filter((s) => s.count > 0).map((s) => ({ value: s.slug, label: s.name })),
]; ];
const onSpecialty = (slug) => { const onSpecialty = (slug) => {
@ -70,7 +88,7 @@ export default function DoctorsPage() {
<div className="doctors-filters"> <div className="doctors-filters">
<div className="container doctors-filters__row"> <div className="container doctors-filters__row">
<div className="doctors-filters__chips"> <div className="doctors-filters__chips">
<SpecialtySelect <FilterSelect
label="Специализация" label="Специализация"
value={section && activeSpecialty ? section : ''} value={section && activeSpecialty ? section : ''}
onChange={onSpecialty} onChange={onSpecialty}