Compare commits
No commits in common. "33b0b892711c5bf4b055810b7f9135ff92403017" and "a02c4faad7e2d741089bbaebb377b9f0e091dc4b" have entirely different histories.
33b0b89271
...
a02c4faad7
@ -1,8 +1,6 @@
|
||||
FROM python:3.11-alpine
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir requests psycopg2-binary \
|
||||
&& adduser -D -u 10001 app
|
||||
COPY --chown=10001:10001 bridge_1c.py /app/bridge_1c.py
|
||||
USER app
|
||||
RUN pip install --no-cache-dir requests psycopg2-binary
|
||||
COPY bridge_1c.py /app/bridge_1c.py
|
||||
EXPOSE 8088
|
||||
CMD ["python", "/app/bridge_1c.py"]
|
||||
|
||||
@ -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 os
|
||||
import re
|
||||
import json
|
||||
import hmac
|
||||
import urllib.parse
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
import psycopg2
|
||||
|
||||
MCP_URL = os.environ.get("ONEC_MCP_URL", "http://10.240.70.70:6003/api/execute_code")
|
||||
PG_HOST = os.environ.get("PG_HOST", "db")
|
||||
PG_PORT = int(os.environ.get("PG_PORT", "5432"))
|
||||
PG_USER = os.environ.get("PG_USER", "dana")
|
||||
PG_DB = os.environ.get("PG_DB", "dana")
|
||||
PG_PASS = os.environ.get("POSTGRES_PASSWORD", "")
|
||||
|
||||
# Токен для вызова 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
|
||||
MCP_URL = "http://10.240.70.70:6003/api/execute_code"
|
||||
PG_HOST = "db"
|
||||
PG_PORT = 5432
|
||||
PG_USER = "dana"
|
||||
PG_DB = "dana"
|
||||
PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0"
|
||||
|
||||
def get_linked_doctor_ids():
|
||||
try:
|
||||
@ -79,6 +45,8 @@ def get_linked_doctor_ids():
|
||||
"a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||
]
|
||||
|
||||
AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df"
|
||||
|
||||
def call_1c(code: str):
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
|
||||
@ -108,19 +76,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, 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):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
@ -134,12 +89,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
path = parsed.path.rstrip("/")
|
||||
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":
|
||||
code = """
|
||||
Запрос = Новый Запрос;
|
||||
@ -232,9 +181,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
if path.startswith("/doctors/") and path.endswith("/services"):
|
||||
parts = path.split("/")
|
||||
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"""
|
||||
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
|
||||
Запрос = Новый Запрос;
|
||||
@ -327,17 +273,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# Determine target doctors
|
||||
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]
|
||||
else:
|
||||
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])
|
||||
|
||||
@ -471,9 +409,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
|
||||
if path.startswith("/appointments/"):
|
||||
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"""
|
||||
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
|
||||
Ответ = Новый Структура;
|
||||
@ -511,15 +446,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
if not self._require_auth():
|
||||
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
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
|
||||
try:
|
||||
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"})
|
||||
return
|
||||
|
||||
if not isinstance(req_json, dict):
|
||||
self.send_json(400, {"code": "bad_request", "message": "JSON object required"})
|
||||
return
|
||||
|
||||
if path == "/appointments":
|
||||
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", "")
|
||||
patient_name = req_json.get("patient_name", "")
|
||||
patient_phone = req_json.get("patient_phone", "")
|
||||
@ -552,24 +475,6 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
if not 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
|
||||
digits = "".join([c for c in patient_phone if c.isdigit()])
|
||||
if len(digits) >= 10:
|
||||
@ -761,9 +666,8 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
if path == "/appointments/link-bitrix":
|
||||
# Legacy linking supports UUIDs and document numbers: escape both.
|
||||
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200)
|
||||
bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200)
|
||||
appointment_id = str(req_json.get("appointment_id", "")).strip()
|
||||
bitrix_lead_id = str(req_json.get("bitrix_lead_id", "")).strip()
|
||||
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"})
|
||||
return
|
||||
@ -834,8 +738,8 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
if path == "/appointments/link-crm":
|
||||
appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200)
|
||||
crm_deal_id = bsl_text(str(req_json.get("crm_deal_id", "")).strip(), 200)
|
||||
appointment_id = str(req_json.get("appointment_id", "")).strip()
|
||||
crm_deal_id = str(req_json.get("crm_deal_id", "")).strip()
|
||||
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"})
|
||||
return
|
||||
@ -911,12 +815,9 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
if not self._require_auth():
|
||||
return
|
||||
|
||||
parts = path.split("/")
|
||||
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"""
|
||||
Попытка
|
||||
ДокСсылка = Неопределено;
|
||||
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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.
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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.'
|
||||
@ -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)
|
||||
@ -48,55 +48,6 @@ services:
|
||||
security_opt:
|
||||
- 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:
|
||||
db-data:
|
||||
media:
|
||||
|
||||
# Существующая сеть уже проверена: 172.20.0.0/16. external запрещает Compose
|
||||
# пересоздавать её при добавлении моста и затрагивать работающие api/db.
|
||||
# При развёртывании на новом хосте сначала создайте эту сеть с нужной подсетью.
|
||||
networks:
|
||||
default:
|
||||
external: true
|
||||
name: dana-medical_default
|
||||
|
||||
327
internal/api/waitcall.go.root-backup
Normal 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"))
|
||||
}
|
||||
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 152 KiB |
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,6 @@ import { Outlet, useLocation } from 'react-router-dom';
|
||||
import Header from './Header.jsx';
|
||||
import Footer from './Footer.jsx';
|
||||
import CookieBanner from './CookieBanner.jsx';
|
||||
import FloatingBookingButton from './FloatingBookingButton.jsx';
|
||||
import { SiteDataProvider } from '../context/SiteData.jsx';
|
||||
import { LeadModalProvider } from '../context/LeadModalContext.jsx';
|
||||
import { getConsent, hit, startMetrika } from '../lib/metrika.js';
|
||||
@ -34,10 +33,7 @@ export default function Layout() {
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
<div className="booking-dock">
|
||||
<FloatingBookingButton />
|
||||
<CookieBanner />
|
||||
</div>
|
||||
<CookieBanner />
|
||||
</div>
|
||||
</LeadModalProvider>
|
||||
</SiteDataProvider>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 cutParents from '../assets/checkups/hero-zdorovye-roditeli.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) — давление и родители
|
||||
'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' },
|
||||
'mne-god': { photo: ckYear, icon: icoBaby, cutout: cutKidsYear, cutoutRatio: 0.9587 },
|
||||
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground, cutout: cutKidsKindergarten, cutoutRatio: 0.93 },
|
||||
pervoklashka: { photo: ckSchool, icon: icoSchool, cutout: cutKidsSchool, cutoutRatio: 0.9025 },
|
||||
'chek-ap-ya-podrostok': { photo: ckTeen, icon: icoTeen, cutout: cutKidsTeen, cutoutRatio: 0.9886 },
|
||||
detskiy: { photo: ckMini, icon: icoStethoscope, cutout: cutKidsMini, cutoutRatio: 0.9587 },
|
||||
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach, cutout: cutKidsGut, cutoutRatio: 0.7147 },
|
||||
'mne-god': { photo: ckYear, icon: icoBaby },
|
||||
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground },
|
||||
pervoklashka: { photo: ckSchool, icon: icoSchool },
|
||||
'chek-ap-ya-podrostok': { photo: ckTeen, icon: icoTeen },
|
||||
detskiy: { photo: ckMini, icon: icoStethoscope },
|
||||
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach },
|
||||
// программ ниже в макете нет — берём близкие по смыслу фото макета
|
||||
'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-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 },
|
||||
'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) {
|
||||
@ -140,7 +132,7 @@ export function checkupMedia(checkup) {
|
||||
// вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки
|
||||
cutout: m.cutout || '',
|
||||
// свой цвет фона шапки, если в макете он не коралловый
|
||||
heroBg: m.heroBg || (checkup.audience === 'kids' && m.cutout ? 'var(--orange)' : ''),
|
||||
heroBg: m.heroBg || '',
|
||||
// ширина/высота файла фигуры: по ней шапка понимает, поместится ли фигура справа от текста
|
||||
cutoutRatio: m.cutoutRatio || 0,
|
||||
};
|
||||
|
||||
@ -516,13 +516,6 @@
|
||||
-webkit-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) {
|
||||
|
||||
@ -41,7 +41,7 @@ export default function CheckupPage() {
|
||||
return (
|
||||
<div className="checkup-page">
|
||||
<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}
|
||||
>
|
||||
<div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}>
|
||||
|
||||
@ -27,7 +27,58 @@
|
||||
.doctors-filters__chips {
|
||||
display: flex;
|
||||
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 {
|
||||
@ -110,6 +161,30 @@
|
||||
}
|
||||
.doctors-filters__chips {
|
||||
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 {
|
||||
flex-basis: auto;
|
||||
|
||||
@ -1,14 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { DoctorRow } from '../components/DoctorCard.jsx';
|
||||
import SpecialtySelect from '../components/SpecialtySelect.jsx';
|
||||
import { api } from '../api.js';
|
||||
import { useApi } from '../lib/useApi.js';
|
||||
import { specialtyPath } from '../lib/links.js';
|
||||
import { usePageTitle } from '../lib/usePageTitle.js';
|
||||
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() {
|
||||
const { section } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@ -38,7 +56,7 @@ export default function DoctorsPage() {
|
||||
|
||||
const specialtyOptions = [
|
||||
{ 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) => {
|
||||
@ -70,7 +88,7 @@ export default function DoctorsPage() {
|
||||
<div className="doctors-filters">
|
||||
<div className="container doctors-filters__row">
|
||||
<div className="doctors-filters__chips">
|
||||
<SpecialtySelect
|
||||
<FilterSelect
|
||||
label="Специализация"
|
||||
value={section && activeSpecialty ? section : ''}
|
||||
onChange={onSpecialty}
|
||||
|
||||