SilverElixir commited on
Commit
bae36ef
·
1 Parent(s): 8cc0538

TikTok hardening: DNS off-loop, manual redirects, JSON cap, N/A and WebM guards (A7-2..A7-4, A7-6..A7-9, A7-12)

Browse files
lumen_tiktok.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
  import asyncio
10
  import contextlib
11
  import ipaddress
 
12
  import logging
13
  import os
14
  import re
@@ -93,8 +94,8 @@ def _chunk_tiktok_media_items(items: list, chunk_size: int = TELEGRAM_MEDIA_GROU
93
 
94
 
95
  def _looks_like_video_bytes(data: bytes) -> bool:
96
- """Видео или картинка — по "ftyp" на смещении 4: Content-Type от TikWM недостоверен, поле в JSON не помечено."""
97
- return len(data) >= 12 and data[4:8] == b"ftyp"
98
 
99
 
100
  def _slideshow_slide_urls(media_data: dict, images_to_fetch: list[str]) -> list[str]:
@@ -120,7 +121,10 @@ def _tiktok_video_candidates(media_data: dict) -> list[dict[str, Any]]:
120
  raw_url = media_data.get(url_key)
121
  if not raw_url:
122
  continue
123
- if not raw_url.startswith("http"):
 
 
 
124
  raw_url = "https://www.tikwm.com" + raw_url
125
  try:
126
  size_bytes = int(media_data.get(size_key) or 0)
@@ -153,6 +157,28 @@ async def _tikwm_throttle() -> None:
153
  _tikwm_last_request_ts = now
154
 
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  async def _fetch_tikwm_media_data(
157
  session: aiohttp.ClientSession, resolved_url: str, headers: dict, *, hd: bool = True,
158
  proxy_base_url: str = "",
@@ -185,7 +211,14 @@ async def _fetch_tikwm_media_data(
185
  async with session.get(api_url, timeout=12, headers=api_headers) as r:
186
  if r.status == 200:
187
  all_attempts_403_empty = False
188
- res = await r.json(content_type=None)
 
 
 
 
 
 
 
189
  if res.get("code") == 0 and isinstance(res.get("data"), dict):
190
  log.info("[tikwm] Successfully fetched media data from %s", api_url)
191
  return res.get("data")
@@ -254,7 +287,9 @@ def _write_mp3_tags(path: str, title: str, artist: str, cover: bytes | None) ->
254
  def _env_number_or_default(name: str, default: int) -> int:
255
  try:
256
  import bot
257
- return int(bot._env_number(name, default, cast=int, min_value=1024))
 
 
258
  except Exception:
259
  return default
260
  TIKTOK_DOWNLOAD_MAX_BYTES = _env_number_or_default("TIKTOK_DOWNLOAD_MAX_BYTES", 75 * 1024 * 1024)
@@ -319,7 +354,7 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
319
  if scheme not in ("http", "https"):
320
  log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
321
  return None
322
- if not _host_resolves_to_public(urllib.parse.urlsplit(url).hostname):
323
  log.warning("[download] Refusing non-public host for URL %r.", url)
324
  return None
325
  if headers is None:
@@ -333,7 +368,8 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
333
  for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
334
  async with session.get(current_url, headers=headers, timeout=60, allow_redirects=False) as resp:
335
  if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
336
- next_url = _checked_redirect_url(current_url, resp.headers.get("Location"))
 
337
  if next_url is None:
338
  return None
339
  current_url = next_url
@@ -345,7 +381,7 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
345
  # проверен выше. Проверку оставляем как последний рубеж.
346
  final_url = getattr(resp, "url", None)
347
  final_host = urllib.parse.urlsplit(str(final_url)).hostname if final_url is not None else None
348
- if final_host is not None and not _host_resolves_to_public(final_host):
349
  log.warning("[download] Refusing redirect to non-public host %r.", final_host)
350
  return None
351
  # Content-Length — быстрый отказ ДО скачивания (сервер может соврать/не прислать — ниже та же проверка потоково по факту).
@@ -436,9 +472,14 @@ async def _probe_video_dimensions(path: str) -> tuple[int, int, int]:
436
  for line in stdout.decode(errors="replace").splitlines():
437
  line = line.strip()
438
  if line.startswith("width="):
439
- width = int(float(line.split("=", 1)[1] or 0))
 
 
 
440
  elif line.startswith("height="):
441
- height = int(float(line.split("=", 1)[1] or 0))
 
 
442
  elif line.startswith("duration="):
443
  raw = line.split("=", 1)[1]
444
  if raw and raw != "N/A":
@@ -482,13 +523,18 @@ async def _probe_and_thumbnail_from_path(path: str) -> tuple[int, int, int, byte
482
  return duration, width, height, thumb_bytes
483
 
484
 
 
 
 
 
 
485
  async def _probe_and_thumbnail_from_bytes(video_bytes: bytes) -> tuple[int, int, int, bytes | None]:
486
  """То же для байтов видео в памяти (видео-слайды): ffprobe/ffmpeg умеют только файлы — пишем во временный файл."""
487
  try:
488
  with tempfile.TemporaryDirectory() as tdir:
489
  raw_path = os.path.join(tdir, "slide.mp4")
490
- with open(raw_path, "wb") as f:
491
- f.write(video_bytes)
492
  return await _probe_and_thumbnail_from_path(raw_path)
493
  except Exception as probe_exc:
494
  log.warning("[tiktok] Video-slide metadata probe failed, sending without: %s", probe_exc)
@@ -511,25 +557,47 @@ async def _resolve_tiktok_short(session: aiohttp.ClientSession, url: str) -> str
511
  "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
512
  "Upgrade-Insecure-Requests": "1"
513
  }
514
- try:
515
- async with session.head(url, allow_redirects=True, timeout=8, headers=headers) as resp:
516
- if resp.status < 400:
517
- resolved = str(resp.url)
518
- if _looks_like_resolved_tiktok_url(resolved):
519
- return resolved
520
- except Exception as e:
521
- log.warning("[tiktok] HEAD resolution failed: %s", e)
 
 
 
 
 
 
 
 
 
 
 
 
522
 
 
523
  try:
524
- async with session.get(url, allow_redirects=True, timeout=10, headers=headers) as resp:
525
- resolved = str(resp.url)
526
- if not _looks_like_resolved_tiktok_url(resolved):
527
- # Битый URL после GET тоже логируем — иначе остаётся только малопонятный 403 уже на стороне TikWM.
528
- log.warning(
529
- "[tiktok] Resolving short link %s didn't yield anything that looks like a post URL via either HEAD or GET (result: %s) — passing it through as-is, TikWM may reject it.", url, resolved,
530
- )
531
- return resolved
 
 
 
 
 
 
 
 
 
532
  except Exception as e:
533
- log.warning("[tiktok] GET resolution failed: %s", e)
534
 
535
  return url
 
9
  import asyncio
10
  import contextlib
11
  import ipaddress
12
+ import json
13
  import logging
14
  import os
15
  import re
 
94
 
95
 
96
  def _looks_like_video_bytes(data: bytes) -> bool:
97
+ """Видео или картинка — по "ftyp" на смещении 4 (MP4) или сигнатуре EBML в начале (WebM): Content-Type от TikWM недостоверен, поле в JSON не помечено."""
98
+ return len(data) >= 12 and (data[4:8] == b"ftyp" or data[:4] == b"\x1a\x45\xdf\xa3")
99
 
100
 
101
  def _slideshow_slide_urls(media_data: dict, images_to_fetch: list[str]) -> list[str]:
 
121
  raw_url = media_data.get(url_key)
122
  if not raw_url:
123
  continue
124
+ if raw_url.startswith("//"):
125
+ # Протокол-относительный URL чиним как https, иначе ниже склеилось бы в битый "https://www.tikwm.com//host/path".
126
+ raw_url = "https:" + raw_url
127
+ elif not raw_url.startswith("http"):
128
  raw_url = "https://www.tikwm.com" + raw_url
129
  try:
130
  size_bytes = int(media_data.get(size_key) or 0)
 
157
  _tikwm_last_request_ts = now
158
 
159
 
160
+ async def _read_capped_body(resp: aiohttp.ClientResponse, url: str) -> bytes | None:
161
+ # JSON TikWM обязан быть маленьким: читаем с тем же капом, что бинарные
162
+ # скачивания, иначе скомпрометированный прокси льёт в память без края.
163
+ content_length = resp.headers.get("Content-Length")
164
+ if content_length is not None:
165
+ try:
166
+ if int(content_length) > TIKTOK_DOWNLOAD_MAX_BYTES:
167
+ log.warning("[tikwm] Refusing %s: Content-Length %s exceeds the %d byte cap.", url, content_length, TIKTOK_DOWNLOAD_MAX_BYTES)
168
+ return None
169
+ except ValueError:
170
+ pass
171
+ chunks: list[bytes] = []
172
+ total = 0
173
+ async for chunk in resp.content.iter_chunked(65536):
174
+ total += len(chunk)
175
+ if total > TIKTOK_DOWNLOAD_MAX_BYTES:
176
+ log.warning("[tikwm] Aborting %s: exceeded the %d byte cap mid-stream.", url, TIKTOK_DOWNLOAD_MAX_BYTES)
177
+ return None
178
+ chunks.append(chunk)
179
+ return b"".join(chunks)
180
+
181
+
182
  async def _fetch_tikwm_media_data(
183
  session: aiohttp.ClientSession, resolved_url: str, headers: dict, *, hd: bool = True,
184
  proxy_base_url: str = "",
 
211
  async with session.get(api_url, timeout=12, headers=api_headers) as r:
212
  if r.status == 200:
213
  all_attempts_403_empty = False
214
+ raw_body = await _read_capped_body(r, api_url)
215
+ if raw_body is None:
216
+ continue
217
+ try:
218
+ res = json.loads(raw_body.decode("utf-8"))
219
+ except Exception as e:
220
+ log.warning("[tikwm] Endpoint %s returned undecodable JSON: %s", api_url, e)
221
+ continue
222
  if res.get("code") == 0 and isinstance(res.get("data"), dict):
223
  log.info("[tikwm] Successfully fetched media data from %s", api_url)
224
  return res.get("data")
 
287
  def _env_number_or_default(name: str, default: int) -> int:
288
  try:
289
  import bot
290
+ # bot._env_number уже кастует и режет мусор дефолтом; здесь только
291
+ # отложенный импорт (bot.py импортирует этот модуль) и страховка.
292
+ return bot._env_number(name, default, cast=int, min_value=1024)
293
  except Exception:
294
  return default
295
  TIKTOK_DOWNLOAD_MAX_BYTES = _env_number_or_default("TIKTOK_DOWNLOAD_MAX_BYTES", 75 * 1024 * 1024)
 
354
  if scheme not in ("http", "https"):
355
  log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
356
  return None
357
+ if not await asyncio.to_thread(_host_resolves_to_public, urllib.parse.urlsplit(url).hostname):
358
  log.warning("[download] Refusing non-public host for URL %r.", url)
359
  return None
360
  if headers is None:
 
368
  for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
369
  async with session.get(current_url, headers=headers, timeout=60, allow_redirects=False) as resp:
370
  if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
371
+ # Резолв хопа тоже в потоке: внутри getaddrinfo, loop не стопорим.
372
+ next_url = await asyncio.to_thread(_checked_redirect_url, current_url, resp.headers.get("Location"))
373
  if next_url is None:
374
  return None
375
  current_url = next_url
 
381
  # проверен выше. Проверку оставляем как последний рубеж.
382
  final_url = getattr(resp, "url", None)
383
  final_host = urllib.parse.urlsplit(str(final_url)).hostname if final_url is not None else None
384
+ if final_host is not None and not await asyncio.to_thread(_host_resolves_to_public, final_host):
385
  log.warning("[download] Refusing redirect to non-public host %r.", final_host)
386
  return None
387
  # Content-Length — быстрый отказ ДО скачивания (сервер может соврать/не прислать — ниже та же проверка потоково по факту).
 
472
  for line in stdout.decode(errors="replace").splitlines():
473
  line = line.strip()
474
  if line.startswith("width="):
475
+ raw_w = line.split("=", 1)[1]
476
+ # "N/A" раньше ронял float() и вместе с ним валидный duration всего пробинга.
477
+ if raw_w and raw_w != "N/A":
478
+ width = int(float(raw_w))
479
  elif line.startswith("height="):
480
+ raw_h = line.split("=", 1)[1]
481
+ if raw_h and raw_h != "N/A":
482
+ height = int(float(raw_h))
483
  elif line.startswith("duration="):
484
  raw = line.split("=", 1)[1]
485
  if raw and raw != "N/A":
 
523
  return duration, width, height, thumb_bytes
524
 
525
 
526
+ def _write_file_sync(path: str, data: bytes) -> None:
527
+ with open(path, "wb") as f:
528
+ f.write(data)
529
+
530
+
531
  async def _probe_and_thumbnail_from_bytes(video_bytes: bytes) -> tuple[int, int, int, bytes | None]:
532
  """То же для байтов видео в памяти (видео-слайды): ffprobe/ffmpeg умеют только файлы — пишем во временный файл."""
533
  try:
534
  with tempfile.TemporaryDirectory() as tdir:
535
  raw_path = os.path.join(tdir, "slide.mp4")
536
+ # Запись до 50МБ в потоке: в loop стопорила всех.
537
+ await asyncio.to_thread(_write_file_sync, raw_path, video_bytes)
538
  return await _probe_and_thumbnail_from_path(raw_path)
539
  except Exception as probe_exc:
540
  log.warning("[tiktok] Video-slide metadata probe failed, sending without: %s", probe_exc)
 
557
  "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
558
  "Upgrade-Insecure-Requests": "1"
559
  }
560
+ # Редиректы разбираем вручную через _checked_redirect_url: aiohttp
561
+ # с allow_redirects=True сходил бы на следующий хоп до проверки хоста.
562
+ current_url = url
563
+ for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
564
+ try:
565
+ async with session.head(current_url, allow_redirects=False, timeout=8, headers=headers) as resp:
566
+ if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
567
+ next_url = await asyncio.to_thread(_checked_redirect_url, current_url, resp.headers.get("Location"))
568
+ if next_url is None:
569
+ break
570
+ current_url = next_url
571
+ continue
572
+ if resp.status < 400:
573
+ resolved = str(resp.url)
574
+ if _looks_like_resolved_tiktok_url(resolved):
575
+ return resolved
576
+ break
577
+ except Exception as e:
578
+ log.warning("[tiktok] HEAD resolution failed: %s", e)
579
+ break
580
 
581
+ current_url = url
582
  try:
583
+ for _ in range(_DOWNLOAD_MAX_REDIRECTS + 1):
584
+ async with session.get(current_url, allow_redirects=False, timeout=10, headers=headers) as resp:
585
+ if resp.status in _DOWNLOAD_REDIRECT_STATUSES:
586
+ next_url = await asyncio.to_thread(_checked_redirect_url, current_url, resp.headers.get("Location"))
587
+ if next_url is None:
588
+ break
589
+ current_url = next_url
590
+ continue
591
+ # Тело осознанно не читаем: нужен только итоговый URL, соединение
592
+ # просто закроется — капа не требуется, байты не качаем.
593
+ resolved = str(resp.url)
594
+ if not _looks_like_resolved_tiktok_url(resolved):
595
+ # Битый URL после GET тоже логируем — иначе остаётся только малопонятный 403 уже на стороне TikWM.
596
+ log.warning(
597
+ "[tiktok] Resolving short link %s didn't yield anything that looks like a post URL via either HEAD or GET (result: %s) — passing it through as-is, TikWM may reject it.", url, resolved,
598
+ )
599
+ return resolved
600
  except Exception as e:
601
+ log.warning("[tiktok] GET resolution failed: %s", e)
602
 
603
  return url
lumen_tiktok_flow.py CHANGED
@@ -26,12 +26,25 @@ from lumen_tiktok import (
26
  _looks_like_video_bytes,
27
  _slideshow_slide_urls,
28
  _tiktok_video_candidates,
 
29
  TikTokUserFacingError,
30
  _tiktok_music_page_id,
31
  )
32
 
33
  log = logging.getLogger("bot")
34
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  async def _send_tiktok_music(session, media_data: dict, message: Message, author: str, headers: dict) -> None:
36
  import bot
37
  music_url = media_data.get("music")
@@ -125,12 +138,10 @@ async def _send_tiktok_music(session, media_data: dict, message: Message, author
125
  try:
126
  with tempfile.TemporaryDirectory() as tmp_dir:
127
  tmp_mp3_path = os.path.join(tmp_dir, "music.mp3")
128
- with open(tmp_mp3_path, "wb") as f:
129
- f.write(music_bytes)
130
- bot._write_mp3_tags(tmp_mp3_path, cleaned_title, performer_name, cover_bytes)
131
  if os.path.exists(tmp_mp3_path) and os.path.getsize(tmp_mp3_path) > 0:
132
- with open(tmp_mp3_path, "rb") as f:
133
- tagged_music_bytes = f.read()
134
  # Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
135
  music_duration = await bot._probe_audio_duration(tmp_mp3_path)
136
  except Exception as tag_err:
@@ -320,8 +331,8 @@ async def _send_tiktok_single_video(
320
  try:
321
  with tempfile.TemporaryDirectory() as tdir:
322
  raw_path = os.path.join(tdir, "raw_tiktok.mp4")
323
- with open(raw_path, "wb") as f:
324
- f.write(video_bytes)
325
  # Длительность/размеры — явно: TikTok-контейнер Telegram сам не всегда разбирает (иначе "файл" 0:00).
326
  duration, width, height = await bot._probe_video_dimensions(raw_path)
327
  thumb_bytes = await bot._generate_video_thumbnail(raw_path, duration)
 
26
  _looks_like_video_bytes,
27
  _slideshow_slide_urls,
28
  _tiktok_video_candidates,
29
+ _write_file_sync,
30
  TikTokUserFacingError,
31
  _tiktok_music_page_id,
32
  )
33
 
34
  log = logging.getLogger("bot")
35
 
36
+
37
+ def _write_and_tag_mp3_sync(path: str, music_bytes: bytes, title: str, artist: str, cover: bytes | None) -> None:
38
+ import bot
39
+ with open(path, "wb") as f:
40
+ f.write(music_bytes)
41
+ bot._write_mp3_tags(path, title, artist, cover)
42
+
43
+
44
+ def _read_file_sync(path: str) -> bytes:
45
+ with open(path, "rb") as f:
46
+ return f.read()
47
+
48
  async def _send_tiktok_music(session, media_data: dict, message: Message, author: str, headers: dict) -> None:
49
  import bot
50
  music_url = media_data.get("music")
 
138
  try:
139
  with tempfile.TemporaryDirectory() as tmp_dir:
140
  tmp_mp3_path = os.path.join(tmp_dir, "music.mp3")
141
+ # Запись и теги — синхронные и тяжёлые (до 50МБ): в поток, loop не стопорим.
142
+ await asyncio.to_thread(_write_and_tag_mp3_sync, tmp_mp3_path, music_bytes, cleaned_title, performer_name, cover_bytes)
 
143
  if os.path.exists(tmp_mp3_path) and os.path.getsize(tmp_mp3_path) > 0:
144
+ tagged_music_bytes = await asyncio.to_thread(_read_file_sync, tmp_mp3_path)
 
145
  # Длительность — явно: без неё Telegram показывает 0:00 (та же история, что была с видео).
146
  music_duration = await bot._probe_audio_duration(tmp_mp3_path)
147
  except Exception as tag_err:
 
331
  try:
332
  with tempfile.TemporaryDirectory() as tdir:
333
  raw_path = os.path.join(tdir, "raw_tiktok.mp4")
334
+ # Запись до 50МБ в потоке: в loop стопорила всех.
335
+ await asyncio.to_thread(_write_file_sync, raw_path, video_bytes)
336
  # Длительность/размеры — явно: TikTok-контейнер Telegram сам не всегда разбирает (иначе "файл" 0:00).
337
  duration, width, height = await bot._probe_video_dimensions(raw_path)
338
  thumb_bytes = await bot._generate_video_thumbnail(raw_path, duration)
tests/bot_test_helpers.py CHANGED
@@ -34,6 +34,34 @@ class _FakeResolveSession:
34
  return self._get_response
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  class _FakeExc(Exception):
38
  def __init__(self, msg, status_code=None):
39
  super().__init__(msg)
@@ -276,8 +304,13 @@ class _FakeTikTokBot:
276
 
277
  class _FakeTikTokResponse:
278
  def __init__(self, status=200, json_body=None):
 
279
  self.status = status
280
  self._json_body = json_body or {}
 
 
 
 
281
 
282
  async def __aenter__(self):
283
  return self
@@ -304,9 +337,15 @@ class _FakeTikTokSession:
304
 
305
  class _FakeTikwmApiResponse:
306
  def __init__(self, status=200, json_body=None, body_bytes=b""):
 
307
  self.status = status
308
  self._json_body = json_body or {}
309
  self._body_bytes = body_bytes
 
 
 
 
 
310
 
311
  async def __aenter__(self):
312
  return self
 
34
  return self._get_response
35
 
36
 
37
+ class _FakeChainResolveResponse:
38
+ def __init__(self, status: int, url: str = "", headers=None):
39
+ self.status = status
40
+ self.url = url
41
+ self.headers = headers or {}
42
+
43
+ async def __aenter__(self):
44
+ return self
45
+
46
+ async def __aexit__(self, *args):
47
+ return False
48
+
49
+
50
+ class _FakeChainResolveSession:
51
+ """head()/get() по карте URL с записью kwargs — для проверки ручной цепочки редиректов."""
52
+ def __init__(self, mapping: dict):
53
+ self._mapping = mapping
54
+ self.calls = []
55
+
56
+ def head(self, url, *args, **kwargs):
57
+ self.calls.append(("head", url, dict(kwargs)))
58
+ return self._mapping[url][0]
59
+
60
+ def get(self, url, *args, **kwargs):
61
+ self.calls.append(("get", url, dict(kwargs)))
62
+ return self._mapping[url][1]
63
+
64
+
65
  class _FakeExc(Exception):
66
  def __init__(self, msg, status_code=None):
67
  super().__init__(msg)
 
304
 
305
  class _FakeTikTokResponse:
306
  def __init__(self, status=200, json_body=None):
307
+ import json as _json
308
  self.status = status
309
  self._json_body = json_body or {}
310
+ # Честный фейк: настоящий aiohttp парсит .json() из тела — контент
311
+ # выводим из того же json, чтобы код шёл боевым путём чтения с капом.
312
+ self.headers = {}
313
+ self.content = _FakeDownloadContent([_json.dumps(self._json_body).encode("utf-8")])
314
 
315
  async def __aenter__(self):
316
  return self
 
337
 
338
  class _FakeTikwmApiResponse:
339
  def __init__(self, status=200, json_body=None, body_bytes=b""):
340
+ import json as _json
341
  self.status = status
342
  self._json_body = json_body or {}
343
  self._body_bytes = body_bytes
344
+ # Честный фейк (см. _FakeTikTokResponse): контент из json, если байты не заданы.
345
+ self.headers = {}
346
+ self.content = _FakeDownloadContent(
347
+ [body_bytes] if body_bytes else [_json.dumps(self._json_body).encode("utf-8")]
348
+ )
349
 
350
  async def __aenter__(self):
351
  return self
tests/test_bot_tiktok.py CHANGED
@@ -6,11 +6,16 @@ test_bot_tiktok.py — TikTok: резолв, слайдшоу, видео, му
6
  from types import SimpleNamespace
7
  import asyncio
8
  import bot
 
9
  import lumen_tiktok
10
  import pytest
 
 
11
  import time
12
  from tests.bot_test_helpers import (
13
  _FakeAudioBot,
 
 
14
  _FakeDownloadResponse,
15
  _FakeDownloadSession,
16
  _FakeIncomingMessage,
@@ -1485,3 +1490,132 @@ def test_single_video_status_deleted_after_music():
1485
  bot._delete_message_quietly = original_delete
1486
  lumen_tiktok_flow._send_tiktok_music = original_music
1487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from types import SimpleNamespace
7
  import asyncio
8
  import bot
9
+ import functools
10
  import lumen_tiktok
11
  import pytest
12
+ import socket
13
+ import threading
14
  import time
15
  from tests.bot_test_helpers import (
16
  _FakeAudioBot,
17
+ _FakeChainResolveResponse,
18
+ _FakeChainResolveSession,
19
  _FakeDownloadResponse,
20
  _FakeDownloadSession,
21
  _FakeIncomingMessage,
 
1490
  bot._delete_message_quietly = original_delete
1491
  lumen_tiktok_flow._send_tiktok_music = original_music
1492
 
1493
+
1494
+ # ─────────────── S9: DNS в потоке, ручные редиректы, капы, N/A, WebM ───────────────
1495
+
1496
+ def _fake_public_dns(host, port, *args, **kwargs):
1497
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))]
1498
+
1499
+
1500
+ def test_download_url_bin_resolves_dns_off_loop(monkeypatch):
1501
+ # A7-2: блокирующий getaddrinfo раньше стопорил loop на время резолва.
1502
+ seen = {}
1503
+
1504
+ def _recording_dns(host, port, *args, **kwargs):
1505
+ seen["thread"] = threading.current_thread()
1506
+ return _fake_public_dns(host, port)
1507
+
1508
+ monkeypatch.setattr(socket, "getaddrinfo", _recording_dns)
1509
+ result = asyncio.run(lumen_tiktok._download_url_bin(
1510
+ _FakeDownloadSession(_FakeDownloadResponse([b"ok"])), "https://example.com/f.mp4"))
1511
+ assert result == b"ok"
1512
+ assert seen["thread"] is not threading.main_thread()
1513
+
1514
+
1515
+ def test_resolve_tiktok_short_disables_auto_redirect(monkeypatch):
1516
+ # A7-3: allow_redirects=True ходил на следующий хоп до проверки хоста.
1517
+ monkeypatch.setattr(socket, "getaddrinfo", _fake_public_dns)
1518
+ canonical = "https://www.tiktok.com/@u/video/123"
1519
+ session = _FakeChainResolveSession({
1520
+ "https://vt.tiktok.com/x/": (
1521
+ _FakeChainResolveResponse(301, headers={"Location": canonical}),
1522
+ _FakeChainResolveResponse(200, url="https://vt.tiktok.com/x/"),
1523
+ ),
1524
+ canonical: (
1525
+ _FakeChainResolveResponse(200, url=canonical),
1526
+ _FakeChainResolveResponse(200, url=canonical),
1527
+ ),
1528
+ })
1529
+ result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/x/"))
1530
+ assert result == canonical
1531
+ assert session.calls
1532
+ assert all(call[2].get("allow_redirects") is False for call in session.calls)
1533
+
1534
+
1535
+ def test_resolve_tiktok_short_refuses_non_public_hop():
1536
+ # A7-3: внутренний адрес в Location останавливает цепочку до запроса к нему.
1537
+ session = _FakeChainResolveSession({
1538
+ "https://vt.tiktok.com/x/": (
1539
+ _FakeChainResolveResponse(301, headers={"Location": "http://169.254.169.254/evil"}),
1540
+ _FakeChainResolveResponse(404, url="https://vt.tiktok.com/x/"),
1541
+ ),
1542
+ })
1543
+ result = asyncio.run(bot._resolve_tiktok_short(session, "https://vt.tiktok.com/x/"))
1544
+ assert "169.254" not in result
1545
+ assert all("169.254" not in url for _, url, _ in session.calls)
1546
+
1547
+
1548
+ def test_fetch_tikwm_media_data_skips_huge_content_length():
1549
+ # A7-4: тело JSON раньше читалось без капа — заявленные гигабайты
1550
+ # пропускаем до скачивания.
1551
+ from tests.bot_test_helpers import _FakeTikwmApiResponse, _FakeTikwmApiSession
1552
+ resp = _FakeTikwmApiResponse(status=200, json_body={"code": 0, "data": {}})
1553
+ resp.headers = {"Content-Length": str(10 ** 12)}
1554
+ result = asyncio.run(bot._fetch_tikwm_media_data(
1555
+ _FakeTikwmApiSession([resp]), "https://www.tiktok.com/@u/video/1", {}))
1556
+ assert result is None
1557
+
1558
+
1559
+ def test_probe_video_dimensions_survives_na_width(monkeypatch):
1560
+ # A7-6: width=N/A раньше ронял float() и вместе с ним валидный duration.
1561
+ class _FakeFFprobe:
1562
+ returncode = 0
1563
+
1564
+ def __init__(self, out):
1565
+ self._out = out
1566
+
1567
+ async def communicate(self):
1568
+ return (self._out, b"")
1569
+
1570
+ async def wait(self):
1571
+ return 0
1572
+
1573
+ def kill(self):
1574
+ pass
1575
+
1576
+ async def _fake_exec(*args, **kwargs):
1577
+ return _FakeFFprobe(b"width=N/A\nheight=720\nduration=10.5\n")
1578
+
1579
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
1580
+ assert asyncio.run(lumen_tiktok._probe_video_dimensions("/tmp/x.mp4")) == (10, 0, 720)
1581
+
1582
+
1583
+ def test_probe_thumbnail_from_bytes_writes_off_loop(monkeypatch):
1584
+ # A7-7: запись файла раньше шла в loop.
1585
+ seen = {}
1586
+ real_to_thread = asyncio.to_thread
1587
+
1588
+ def _wrap(func):
1589
+ @functools.wraps(func)
1590
+ def _inner(*args, **kwargs):
1591
+ seen.setdefault("threads", []).append(threading.current_thread())
1592
+ return func(*args, **kwargs)
1593
+ return _inner
1594
+
1595
+ async def _recorder(func, /, *args, **kwargs):
1596
+ return await real_to_thread(_wrap(func), *args, **kwargs)
1597
+
1598
+ monkeypatch.setattr(asyncio, "to_thread", _recorder)
1599
+ assert asyncio.run(lumen_tiktok._probe_and_thumbnail_from_bytes(b"\x00" * 16)) == (0, 0, 0, None)
1600
+ assert seen["threads"] and all(t is not threading.main_thread() for t in seen["threads"])
1601
+
1602
+
1603
+ def test_write_and_tag_mp3_sync_roundtrip(tmp_path):
1604
+ # A7-7: синхронный кусок MP3-пути вынесен в хелпер (мутаген битый вход терпит).
1605
+ from lumen_tiktok_flow import _write_and_tag_mp3_sync
1606
+ path = str(tmp_path / "m.mp3")
1607
+ _write_and_tag_mp3_sync(path, b"not a real mp3", "t", "a", None)
1608
+ import os
1609
+ assert os.path.exists(path) and os.path.getsize(path) > 0
1610
+
1611
+
1612
+ def test_tiktok_video_candidates_protocol_relative_url():
1613
+ # A7-8: "//host/path" раньше склеивался в битый tikwm-URL.
1614
+ candidates = bot._tiktok_video_candidates({"play": "//cdn.tikwm.com/v.mp4", "size": 1})
1615
+ assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
1616
+
1617
+
1618
+ def test_looks_like_video_bytes_true_for_webm_ebml():
1619
+ # A7-9: WebM-слайд раньше принимался за фото.
1620
+ assert bot._looks_like_video_bytes(b"\x1a\x45\xdf\xa3" + b"\x00" * 16) is True
1621
+