standkit 0.3.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ """
2
+ Просмотр лог-файлов стенда со стороны хаба: список файлов источника логов,
3
+ выбор основного (самого свежего) файла, tail содержимого, открытие папки
4
+ логов в файловом менеджере ОС хоста.
5
+
6
+ ДВА ИСТОЧНИКА логов на стенд (``source``, см. ``resolve_logs_dir``):
7
+
8
+ - ``"stand"`` — логи самого стенда (платформа BPMSoft), пишет их движок,
9
+ каталог ``<stand.stand_dir>/logs`` (папка ``logs`` в корне стенда). Это
10
+ ближе всего к тому, что видно в PS-окне/консоли самого стенда — дефолт
11
+ для панели "Текущее состояние".
12
+ - ``"bpmkit"`` — логи BPMkit-ПРОЕКТА (scaffold, ``project_scaffold``): не
13
+ логи стенда и НЕ ``Stand.extra["logs_path"]`` (та запись указывает на тот
14
+ же каталог, что и ``"stand"``, — путать источники не нужно), а подпапка
15
+ ``logs`` внутри папки проекта ``Stand.extra["docs_folder"]``
16
+ (``<docs_folder>/logs``). Туда пишутся логи разработки поверх стенда;
17
+ если ``docs_folder`` в записи стенда не задан (провижининг без
18
+ project_scaffold), источник недоступен целиком.
19
+
20
+ Важно отличать оба источника от ``standkit.lifecycle.log_path``/
21
+ ``standkit.logs`` (см. ``standkit_hub.server._api_stand_logs``) — тот лог
22
+ существует только для стендов, ЗАПУЩЕННЫХ САМИМ standkit
23
+ (``transport=local`` через ``lifecycle.start``), это третий, отдельный канал.
24
+
25
+ STDLIB-ONLY: ``pathlib``, ``subprocess``, ``sys``, ``os`` (только ``os.startfile``
26
+ на Windows).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Optional
37
+
38
+ from standkit.models import Stand
39
+
40
+ # Допустимые значения ``source`` — единственный источник истины для валидации
41
+ # как здесь, так и в standkit_hub.server (query-параметр ``source``).
42
+ LOG_SOURCES = ("stand", "bpmkit")
43
+
44
+ DEFAULT_LOG_SOURCE = "stand"
45
+
46
+
47
+ def raw_logs_path(stand: Stand, source: str = DEFAULT_LOG_SOURCE) -> Optional[str]:
48
+ """
49
+ "Сырой" (не проверенный на существование) путь к каталогу логов для
50
+ выбранного источника — используется только для человекочитаемых сообщений
51
+ ("каталог не найден — <путь>"), когда ``resolve_logs_dir`` вернул ``None``.
52
+
53
+ Бросает ``ValueError`` на неизвестный ``source`` — та же дисциплина, что
54
+ и у ``resolve_logs_dir``.
55
+ """
56
+ if source == "stand":
57
+ return str(Path(stand.stand_dir) / "logs") if stand.stand_dir else None
58
+ if source == "bpmkit":
59
+ docs_folder = stand.extra.get("docs_folder")
60
+ return str(Path(docs_folder) / "logs") if docs_folder else None
61
+ raise ValueError(f"неизвестный источник логов: {source!r}")
62
+
63
+
64
+ def resolve_logs_dir(stand: Stand, source: str = DEFAULT_LOG_SOURCE) -> Optional[Path]:
65
+ """
66
+ Резолвит каталог логов стенда для выбранного источника:
67
+
68
+ - ``source="stand"`` (по умолчанию) — ``<stand.stand_dir>/logs``;
69
+ - ``source="bpmkit"`` — ``<stand.extra["docs_folder"]>/logs`` (логи
70
+ BPMkit-проекта, папка ``logs`` внутри project-scaffold, НЕ
71
+ ``stand.extra["logs_path"]``).
72
+
73
+ Возвращает ``None``, если путь не задан (для "stand" — пуст сам
74
+ ``stand_dir``; для "bpmkit" — не задан ``docs_folder``), либо не
75
+ существует, либо указывает не на каталог —
76
+ вызывающая сторона (хаб) обязана отдать понятное сообщение "лог
77
+ недоступен", а не падать с исключением.
78
+
79
+ Бросает ``ValueError`` на неизвестный ``source`` — это ошибка вызывающего
80
+ кода (например, невалидированный query-параметр), а не штатная ситуация
81
+ "лога нет"; HTTP-слой (``standkit_hub.server``) обязан провалидировать
82
+ ``source`` ДО вызова и вернуть 400, не давая исключению дойти сюда.
83
+ """
84
+ raw = raw_logs_path(stand, source)
85
+ if not raw:
86
+ return None
87
+ p = Path(raw)
88
+ if not p.exists() or not p.is_dir():
89
+ return None
90
+ return p
91
+
92
+
93
+ def list_log_files(logs_dir: Path) -> list[dict]:
94
+ """
95
+ Список лог-файлов каталога (без рекурсии в подкаталоги): имя, размер в
96
+ байтах, mtime (unix timestamp). Отсортирован по mtime по убыванию (самый
97
+ свежий — первым), чтобы фронтенду не нужно было сортировать самому.
98
+ """
99
+ entries: list[dict] = []
100
+ for child in logs_dir.iterdir():
101
+ if not child.is_file():
102
+ continue
103
+ try:
104
+ st = child.stat()
105
+ except OSError:
106
+ continue
107
+ entries.append({"name": child.name, "size": st.st_size, "mtime": st.st_mtime})
108
+ entries.sort(key=lambda e: e["mtime"], reverse=True)
109
+ return entries
110
+
111
+
112
+ def pick_primary_log(logs_dir: Path) -> Optional[Path]:
113
+ """Выбирает "основной" лог каталога — самый свежий по mtime файл."""
114
+ files = list_log_files(logs_dir)
115
+ if not files:
116
+ return None
117
+ return logs_dir / files[0]["name"]
118
+
119
+
120
+ def sanitize_log_filename(logs_dir: Path, name: str) -> Optional[Path]:
121
+ """
122
+ Резолвит имя файла лога СТРОГО внутри ``logs_dir`` (защита от path
123
+ traversal) — тот же принцип, что ``standkit_hub.security.sanitize_static_path``,
124
+ но для произвольного (не фиксированного) каталога логов конкретного стенда.
125
+
126
+ Возвращает ``None``, если путь небезопасен, не существует или указывает
127
+ не на файл.
128
+ """
129
+ if not name or name.startswith("/") or "\\" in name or ".." in Path(name).parts:
130
+ return None
131
+ logs_dir_resolved = logs_dir.resolve()
132
+ candidate = (logs_dir / name).resolve()
133
+ try:
134
+ candidate.relative_to(logs_dir_resolved)
135
+ except ValueError:
136
+ return None
137
+ if not candidate.is_file():
138
+ return None
139
+ return candidate
140
+
141
+
142
+ @dataclass
143
+ class OpenFolderResult:
144
+ """Результат попытки открыть папку логов в файловом менеджере ОС хоста."""
145
+
146
+ ok: bool
147
+ message: str
148
+
149
+
150
+ def open_folder(path: Path) -> OpenFolderResult:
151
+ """
152
+ Открывает каталог в файловом менеджере ОС хоста: Windows — ``os.startfile``
153
+ (штатный способ ОС попросить проводник открыть путь — надёжнее выводит
154
+ открытое окно на передний план, чем спавн ``explorer`` через subprocess,
155
+ который часто просто сворачивает уже открытое окно той же папки в
156
+ таскбар вместо фокуса); macOS — ``open``, остальное — ``xdg-open`` (оба
157
+ через ``subprocess.Popen``, не блокируясь на ожидании закрытия окна).
158
+
159
+ Никогда не бросает исключение наружу — при отсутствии DISPLAY, нужной
160
+ утилиты в PATH и т.п. возвращает ``ok=False`` с текстом причины, чтобы
161
+ хаб мог показать это пользователю, а не упасть 500-й.
162
+ """
163
+ if not path.exists():
164
+ return OpenFolderResult(False, f"каталог не существует: {path}")
165
+ try:
166
+ if sys.platform == "win32":
167
+ os.startfile(str(path)) # type: ignore[attr-defined]
168
+ elif sys.platform == "darwin":
169
+ subprocess.Popen(["open", str(path)])
170
+ else:
171
+ subprocess.Popen(["xdg-open", str(path)])
172
+ return OpenFolderResult(True, f"открыто: {path}")
173
+ except OSError as exc:
174
+ return OpenFolderResult(False, f"не удалось открыть проводник: {exc}")
@@ -0,0 +1,326 @@
1
+ """
2
+ Минимальный STDLIB-ONLY RESP-клиент для очистки БД Redis стенда (``SELECT`` +
3
+ ``FLUSHDB``) — используется кнопкой "Очистить Redis" в таблице стендов.
4
+
5
+ Намеренно НЕ тянет ``redis``/``aioredis`` и т.п. — хаб (как и ядро/агент)
6
+ разворачивается без ``pip install`` чего-либо стороннего. Протокол Redis
7
+ (RESP) в объёме, нужном для двух команд, тривиален: массив bulk-строк на
8
+ запрос, однострочный ``+OK``/``-ERR ...`` на ответ — велосипед оправдан
9
+ отсутствием стороннего клиента в рантайме хаба.
10
+
11
+ Намеренно НЕ разбирает произвольные RESP-ответы (bulk/array/integer) — только
12
+ однострочные (``+``/``-``), этого достаточно для ``SELECT``/``FLUSHDB``.
13
+
14
+ Также здесь живёт ``resolve_redis_from_stand_config`` — BEST-EFFORT резолвер
15
+ параметров подключения к Redis ИЗ КОНФИГА САМОГО СТЕНДА (не реестра), на
16
+ случай, когда ``extra["redis_db"]`` не задан в реестре BPMkit (частый
17
+ случай — реестр вообще не хранит Redis-параметры). Используется как второй
18
+ шаг резолва в ``standkit_hub.server._redis_connect_params``: реестр в
19
+ приоритете, конфиг стенда — фолбэк.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ import socket
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Optional
30
+
31
+ _DEFAULT_TIMEOUT_SEC = 5.0
32
+
33
+
34
+ class RedisClearError(Exception):
35
+ """Ошибка обращения к Redis (недоступен, таймаут, протокольная ошибка)."""
36
+
37
+
38
+ @dataclass
39
+ class RedisClearResult:
40
+ """Результат попытки очистки БД Redis — никогда не бросает исключение наружу."""
41
+
42
+ ok: bool
43
+ message: str
44
+
45
+
46
+ def _encode_command(*args: str) -> bytes:
47
+ """Кодирует команду в формате RESP (массив bulk-строк) — то, что понимает redis-server."""
48
+ parts = [f"*{len(args)}\r\n".encode("ascii")]
49
+ for arg in args:
50
+ raw = str(arg).encode("utf-8")
51
+ parts.append(f"${len(raw)}\r\n".encode("ascii"))
52
+ parts.append(raw)
53
+ parts.append(b"\r\n")
54
+ return b"".join(parts)
55
+
56
+
57
+ def _read_line(sock) -> bytes:
58
+ """
59
+ Читает одну CRLF-терминированную строку ответа Redis (побайтово — простая,
60
+ не самая быстрая реализация, но ответы ``+OK``/``-ERR ...`` короткие,
61
+ производительность не критична для разовой административной операции).
62
+ """
63
+ buf = b""
64
+ while not buf.endswith(b"\r\n"):
65
+ chunk = sock.recv(1)
66
+ if not chunk:
67
+ raise RedisClearError("соединение с Redis закрыто неожиданно (до получения ответа)")
68
+ buf += chunk
69
+ return buf[:-2]
70
+
71
+
72
+ def flush_db(host: str, port: int, db: int, *, timeout: float = _DEFAULT_TIMEOUT_SEC) -> RedisClearResult:
73
+ """
74
+ Подключается к ``host:port``, выполняет ``SELECT <db>`` затем ``FLUSHDB``.
75
+
76
+ Никогда не бросает исключение наружу (сетевые/протокольные ошибки
77
+ заворачиваются в ``RedisClearResult(ok=False, ...)``) — вызывающая сторона
78
+ (HTTP-слой хаба) должна показать понятный текст пользователю, а не 500-ю.
79
+ """
80
+ try:
81
+ sock = socket.create_connection((host, port), timeout=timeout)
82
+ except OSError as exc:
83
+ return RedisClearResult(False, f"не удалось подключиться к Redis {host}:{port}: {exc}")
84
+
85
+ try:
86
+ sock.settimeout(timeout)
87
+
88
+ sock.sendall(_encode_command("SELECT", str(db)))
89
+ reply = _read_line(sock)
90
+ if not reply.startswith(b"+"):
91
+ return RedisClearResult(
92
+ False, f"Redis отклонил SELECT {db}: {reply.decode('utf-8', errors='replace')}"
93
+ )
94
+
95
+ sock.sendall(_encode_command("FLUSHDB"))
96
+ reply = _read_line(sock)
97
+ if not reply.startswith(b"+"):
98
+ return RedisClearResult(
99
+ False, f"Redis отклонил FLUSHDB: {reply.decode('utf-8', errors='replace')}"
100
+ )
101
+
102
+ return RedisClearResult(True, f"Redis db={db} очищен ({host}:{port})")
103
+ except (OSError, RedisClearError) as exc:
104
+ return RedisClearResult(False, f"ошибка обращения к Redis {host}:{port}: {exc}")
105
+ finally:
106
+ try:
107
+ sock.close()
108
+ except OSError:
109
+ pass
110
+
111
+
112
+ # --- best-effort резолвер Redis-параметров из конфига стенда (фолбэк реестра) ---
113
+ #
114
+ # Типичные для BPMSoft/.NET места: <stand_dir>\ConnectionStrings.config
115
+ # (классический .NET connectionStrings-XML), <stand_dir>\appsettings.json
116
+ # (JSON-конфиг ASP.NET Core), либо любой другой *.config/*.json в КОРНЕ
117
+ # каталога стенда (без рекурсии в подпапки — не сканируем весь стенд).
118
+ #
119
+ # Best-effort: любая ошибка чтения/парсинга конкретного файла — файл просто
120
+ # пропускается, никогда не бросаем исключение наружу. Формат Redis-строки у
121
+ # BPMSoft заранее не задокументирован нам достоверно — резолвер ищет
122
+ # правдоподобные пары ключ=значение (host/port/db/$db) в тексте рядом со
123
+ # словом "redis", это эвристика, а не парсер конкретного формата.
124
+
125
+ _REDIS_CONFIG_FILENAMES = ("ConnectionStrings.config", "appsettings.json")
126
+ _REDIS_CONFIG_EXTENSIONS = (".config", ".json")
127
+ # Не читаем большие файлы целиком без нужды — конфиги стенда обычно ↓ 200KB,
128
+ # 2MB — щедрый потолок на случай нетипичного файла.
129
+ _MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024
130
+
131
+ _REDIS_KV_RE = re.compile(r"(?i)(\$db|\bdb\b|\bhost\b|\bport\b)\s*=\s*([^;,\s\"']+)")
132
+
133
+
134
+ def _parse_redis_connection_string(text: str) -> Optional[dict]:
135
+ """
136
+ Ищет пары ``host=``/``port=``/``db=``/``$db=`` в произвольном тексте
137
+ (типично — значение атрибута ``connectionString`` или кусок JSON-строки).
138
+
139
+ ``db`` обязателен — без него результат бесполезен для очистки (нельзя
140
+ угадывать номер БД), при отсутствии возвращает ``None``. ``host``/``port``
141
+ — опциональны, дефолты ``127.0.0.1``/``6379`` (та же дисциплина, что и у
142
+ резолвера из реестра, см. ``standkit_hub.server._redis_from_registry``).
143
+ """
144
+ if not text:
145
+ return None
146
+ found: dict[str, Any] = {}
147
+ for m in _REDIS_KV_RE.finditer(text):
148
+ key = m.group(1).lower().lstrip("$")
149
+ val = m.group(2).strip().rstrip(";,")
150
+ if key == "db" and "db" not in found:
151
+ try:
152
+ found["db"] = int(val)
153
+ except ValueError:
154
+ continue
155
+ elif key == "host" and "host" not in found:
156
+ found["host"] = val
157
+ elif key == "port" and "port" not in found:
158
+ try:
159
+ found["port"] = int(val)
160
+ except ValueError:
161
+ continue
162
+ if "db" not in found:
163
+ return None
164
+ return {"host": found.get("host", "127.0.0.1"), "port": found.get("port", 6379), "db": found["db"]}
165
+
166
+
167
+ def _extract_connection_string_from_config_xml(text: str) -> Optional[str]:
168
+ """
169
+ Ищет ``<add name="..." connectionString="..."/>`` записи, чьё имя/тег
170
+ содержит "redis" (регистронезависимо) — стандартный вид ``<connectionStrings>``
171
+ секции .NET-конфига (``ConnectionStrings.config``/``Web.config``/``App.config``).
172
+ """
173
+ for m in re.finditer(r"<add\b[^>]*/?>", text, re.IGNORECASE):
174
+ tag = m.group(0)
175
+ if "redis" not in tag.lower():
176
+ continue
177
+ cs_m = re.search(r'connectionString\s*=\s*"([^"]*)"', tag, re.IGNORECASE)
178
+ if cs_m and cs_m.group(1):
179
+ return cs_m.group(1)
180
+ return None
181
+
182
+
183
+ def _windowed_redis_search(text: str) -> Optional[dict]:
184
+ """Запасной вариант для произвольного текста: окно ~350 символов вокруг первого упоминания "redis"."""
185
+ lower = text.lower()
186
+ idx = lower.find("redis")
187
+ if idx == -1:
188
+ return None
189
+ window = text[max(0, idx - 50): idx + 300]
190
+ return _parse_redis_connection_string(window)
191
+
192
+
193
+ def _extract_host_port_db_from_json_dict(d: dict) -> Optional[dict]:
194
+ """
195
+ Достаёт host/port/db из JSON-объекта, ПОХОЖЕГО на настройки redis
196
+ (напр. ``{"Host": "127.0.0.1", "Port": 6379, "Db": 2}``), либо, если это
197
+ обёртка над строкой подключения (``{"ConnectionString": "host=..;db=.."}``),
198
+ делегирует в ``_parse_redis_connection_string``.
199
+ """
200
+ lower = {k.lower(): v for k, v in d.items() if isinstance(k, str)}
201
+ db_val = None
202
+ for key in ("db", "database", "number", "redisdb", "redis_db"):
203
+ if key in lower:
204
+ try:
205
+ db_val = int(lower[key])
206
+ break
207
+ except (TypeError, ValueError):
208
+ continue
209
+ if db_val is None:
210
+ for key in ("connectionstring", "connection_string", "value", "url"):
211
+ if key in lower and isinstance(lower[key], str):
212
+ parsed = _parse_redis_connection_string(lower[key])
213
+ if parsed:
214
+ return parsed
215
+ return None
216
+ host = lower.get("host") or lower.get("hostname") or "127.0.0.1"
217
+ port_raw = lower.get("port")
218
+ try:
219
+ port = int(port_raw) if port_raw is not None else 6379
220
+ except (TypeError, ValueError):
221
+ port = 6379
222
+ return {"host": str(host), "port": port, "db": db_val}
223
+
224
+
225
+ def _find_redis_in_json(data: Any) -> Optional[dict]:
226
+ """
227
+ Рекурсивно ищет "redis"-ключ (регистронезависимо) в разобранном JSON
228
+ (dict/list произвольной вложенности — ``appsettings.json`` обычно
229
+ группирует секции, поэтому Redis может лежать не в корне) и пытается
230
+ извлечь host/port/db из значения (строка-подключение ИЛИ объект).
231
+ """
232
+ if isinstance(data, dict):
233
+ for key, value in data.items():
234
+ if isinstance(key, str) and "redis" in key.lower():
235
+ if isinstance(value, str):
236
+ parsed = _parse_redis_connection_string(value)
237
+ if parsed:
238
+ return parsed
239
+ elif isinstance(value, dict):
240
+ parsed = _extract_host_port_db_from_json_dict(value)
241
+ if parsed:
242
+ return parsed
243
+ for value in data.values():
244
+ result = _find_redis_in_json(value)
245
+ if result:
246
+ return result
247
+ elif isinstance(data, list):
248
+ for item in data:
249
+ result = _find_redis_in_json(item)
250
+ if result:
251
+ return result
252
+ return None
253
+
254
+
255
+ def _try_extract_redis_from_file(path: Path) -> Optional[dict]:
256
+ """Best-effort извлечение Redis-параметров из ОДНОГО файла. Никогда не бросает исключение."""
257
+ try:
258
+ if path.stat().st_size > _MAX_CONFIG_FILE_BYTES:
259
+ return None
260
+ text = path.read_text(encoding="utf-8-sig", errors="ignore")
261
+ except OSError:
262
+ return None
263
+
264
+ if path.suffix.lower() == ".json":
265
+ try:
266
+ data = json.loads(text)
267
+ except (json.JSONDecodeError, ValueError, RecursionError):
268
+ return None
269
+ return _find_redis_in_json(data)
270
+
271
+ # .config и прочее текстовое — сначала штатный вид <add .../>, иначе
272
+ # запасной поиск "redis" по окну текста.
273
+ cs = _extract_connection_string_from_config_xml(text)
274
+ if cs:
275
+ parsed = _parse_redis_connection_string(cs)
276
+ if parsed:
277
+ return parsed
278
+ return _windowed_redis_search(text)
279
+
280
+
281
+ def resolve_redis_from_stand_config(stand_dir: Optional[str]) -> Optional[dict]:
282
+ """
283
+ Best-effort резолвер параметров подключения к Redis ИЗ КОНФИГА СТЕНДА —
284
+ фолбэк, когда реестр BPMkit не содержит ``extra["redis_db"]`` (частый
285
+ случай, см. docstring модуля). Возвращает ``{"host": str, "port": int,
286
+ "db": int}`` либо ``None``, если ничего похожего на redis-подключение с
287
+ номером БД не нашлось (или ``stand_dir`` пуст/не существует).
288
+
289
+ Порядок поиска в ``stand_dir`` (без рекурсии в подпапки):
290
+ 1. ``ConnectionStrings.config``, ``appsettings.json`` (типичные для
291
+ BPMSoft/.NET имена) — в этом порядке;
292
+ 2. остальные ``*.config``/``*.json`` файлы в корне каталога стенда.
293
+
294
+ Никогда не бросает исключение — отсутствующий/битый/нечитаемый файл
295
+ просто пропускается (следующий кандидат).
296
+ """
297
+ if not stand_dir:
298
+ return None
299
+ root = Path(stand_dir)
300
+ if not root.is_dir():
301
+ return None
302
+
303
+ candidates: list[Path] = []
304
+ for name in _REDIS_CONFIG_FILENAMES:
305
+ p = root / name
306
+ if p.is_file():
307
+ candidates.append(p)
308
+
309
+ try:
310
+ entries = sorted(root.iterdir())
311
+ except OSError:
312
+ entries = []
313
+ for p in entries:
314
+ try:
315
+ if not p.is_file() or p in candidates:
316
+ continue
317
+ except OSError:
318
+ continue
319
+ if p.suffix.lower() in _REDIS_CONFIG_EXTENSIONS:
320
+ candidates.append(p)
321
+
322
+ for path in candidates:
323
+ result = _try_extract_redis_from_file(path)
324
+ if result:
325
+ return result
326
+ return None
@@ -0,0 +1,146 @@
1
+ """
2
+ Security-примитивы веб-хаба standkit_hub: сессионный токен, извлечение
3
+ предъявленного токена (cookie/заголовок), anti-CSRF/cross-origin проверка
4
+ мутаций, санитайзинг путей статики.
5
+
6
+ По образцу ``standkit_agent/security.py`` (fail-closed bind-проверка,
7
+ ``hmac.compare_digest`` вместо ``==``), но адаптировано под браузерный клиент:
8
+ сессия — HttpOnly-cookie, а мутации (POST/DELETE) дополнительно требуют
9
+ дублирующий заголовок ``X-Standkit-Token`` (double-submit паттерн: сторонний
10
+ сайт может заставить браузер отправить cookie, но не может ни прочитать её,
11
+ ни подделать наш кастомный заголовок для cross-origin запроса) плюс сверку
12
+ ``Origin``/``Referer`` с адресом самого хаба.
13
+
14
+ Хаб — та же RCE-поверхность, что и headless-агент (управляет процессами
15
+ через ``standkit.lifecycle``/``standkit_hub.agent_control``), поэтому
16
+ fail-closed bind-проверка (``validate_bind_security``/``InsecureBindError``/
17
+ ``is_loopback_host``) переиспользуется напрямую из ``standkit_agent.security``
18
+ — дублировать эту логику здесь было бы источником рассинхронизации.
19
+
20
+ STDLIB-ONLY: ``hmac``, ``re``, ``secrets``, ``urllib.parse``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hmac
26
+ import re
27
+ import secrets as _secrets_mod
28
+ from pathlib import Path
29
+ from typing import Optional
30
+ from urllib.parse import urlparse
31
+
32
+ # Реэкспорт — единственный источник fail-closed bind-логики (не дублируем).
33
+ from standkit_agent.security import ( # noqa: F401
34
+ InsecureBindError,
35
+ is_loopback_host,
36
+ validate_bind_security,
37
+ )
38
+
39
+ SESSION_COOKIE_NAME = "standkit_session"
40
+ TOKEN_HEADER_NAME = "X-Standkit-Token"
41
+ TOKEN_QUERY_PARAM = "t"
42
+
43
+ DEFAULT_MAX_BODY_BYTES = 256 * 1024 # 256 КБ — лимит тела JSON-запроса хаба
44
+ DEFAULT_MAX_LOGS_N = 10_000
45
+
46
+ _COOKIE_ATTR_RE = re.compile(r"^" + re.escape(SESSION_COOKIE_NAME) + r"=([^;]*)")
47
+
48
+ # То же множество символов, что допускает реестр/агент — не дублируем правило
49
+ # отдельным regex'ом с шансом разъехаться, просто переиспользуем то же имя.
50
+ _STAND_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
51
+ _SECRET_REF_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
52
+
53
+
54
+ def generate_session_token() -> str:
55
+ """Криптостойкий сессионный токен хаба (генерируется один раз при старте процесса)."""
56
+ return _secrets_mod.token_urlsafe(32)
57
+
58
+
59
+ def extract_cookie_token(cookie_header: str) -> Optional[str]:
60
+ """Достаёт значение cookie ``standkit_session`` из заголовка ``Cookie`` (без внешних зависимостей)."""
61
+ if not cookie_header:
62
+ return None
63
+ for part in cookie_header.split(";"):
64
+ part = part.strip()
65
+ m = _COOKIE_ATTR_RE.match(part)
66
+ if m:
67
+ return m.group(1)
68
+ return None
69
+
70
+
71
+ def tokens_match(presented: Optional[str], expected: str) -> bool:
72
+ """Сравнение токенов ТОЛЬКО через ``hmac.compare_digest`` (защита от timing-атак)."""
73
+ if not presented:
74
+ return False
75
+ return hmac.compare_digest(presented, expected)
76
+
77
+
78
+ def is_local_origin(value: Optional[str], *, expected_port: int) -> bool:
79
+ """
80
+ True, если ``Origin``/``Referer`` указывает на loopback-хост И порт,
81
+ совпадающий с портом самого хаба.
82
+
83
+ Anti-CSRF проверка на мутациях (в дополнение к double-submit токену) —
84
+ сторонний сайт не может ни подделать заголовок ``Origin`` браузера, ни
85
+ прочитать наш HttpOnly-cookie, чтобы продублировать его в заголовок.
86
+ """
87
+ if not value:
88
+ return False
89
+ try:
90
+ parsed = urlparse(value)
91
+ except ValueError:
92
+ return False
93
+ if not is_loopback_host(parsed.hostname or ""):
94
+ return False
95
+ return parsed.port == expected_port
96
+
97
+
98
+ def sanitize_static_path(web_dir: Path, rel_path: str) -> Optional[Path]:
99
+ """
100
+ Резолвит путь внутри ``web_dir`` для статики, отклоняя traversal
101
+ (``..``, абсолютные пути в компонентах). Возвращает ``None``, если путь
102
+ небезопасен, не существует или указывает не на файл.
103
+ """
104
+ if not rel_path or rel_path.startswith("/") or ".." in Path(rel_path).parts:
105
+ return None
106
+ web_dir_resolved = web_dir.resolve()
107
+ candidate = (web_dir / rel_path).resolve()
108
+ try:
109
+ candidate.relative_to(web_dir_resolved)
110
+ except ValueError:
111
+ return None
112
+ if not candidate.is_file():
113
+ return None
114
+ return candidate
115
+
116
+
117
+ def validate_stand_name(name: str) -> bool:
118
+ """Валидация имени стенда из URL — тот же whitelist, что и у агента."""
119
+ return bool(name) and bool(_STAND_NAME_RE.match(name))
120
+
121
+
122
+ def validate_secret_ref(ref: str) -> bool:
123
+ """Валидация ссылки на секрет из URL (буквы/цифры/``_.:-``, кап длины)."""
124
+ return bool(ref) and bool(_SECRET_REF_RE.match(ref))
125
+
126
+
127
+ def clamp_logs_n(raw: str, *, max_n: int = DEFAULT_MAX_LOGS_N) -> int:
128
+ """Парсит и капит параметр ``n`` запроса логов. Бросает ``ValueError`` на некорректный ввод."""
129
+ n = int(raw)
130
+ if n < 0:
131
+ raise ValueError("n не может быть отрицательным")
132
+ return min(n, max_n)
133
+
134
+
135
+ def validate_content_length(header_value: Optional[str], *, max_bytes: int = DEFAULT_MAX_BODY_BYTES) -> int:
136
+ """Разбирает ``Content-Length`` и проверяет лимит тела запроса ДО его фактического чтения из сокета."""
137
+ raw = header_value if header_value not in (None, "") else "0"
138
+ try:
139
+ n = int(raw)
140
+ except (TypeError, ValueError) as exc:
141
+ raise ValueError(f"некорректный Content-Length: {raw!r}") from exc
142
+ if n < 0:
143
+ raise ValueError("Content-Length не может быть отрицательным")
144
+ if n > max_bytes:
145
+ raise ValueError(f"тело запроса превышает лимит {max_bytes} байт")
146
+ return n