nonebot-plugin-botui 0.1.0__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,438 @@
1
+ """媒体资源的小工具:SSRF 校验、流式转发、文本读取与内存缓存。
2
+
3
+ WebUI 通过 ``/media`` 代理远程图片/文件:QQ 的媒体链接大多带防盗链,直接
4
+ ``<img src>`` / ``<a href>`` 会加载或下载失败。
5
+
6
+ 安全说明:``/media`` 是一个「按用户给的 URL 去取内容」的接口,天然容易变成
7
+ SSRF 跳板(拿它去打内网、打云元数据接口 ``169.254.169.254``)。所以这里对
8
+ 目标地址做了限制,默认只允许公网地址,见 :func:`_blocked_reason`。
9
+
10
+ 小资源(图片、缩略图)走 :func:`fetch` 读进内存缓存;大文件走
11
+ :func:`stream`,边收边转发、支持 Range 断点续传,不把文件读进内存。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import socket
17
+ import asyncio
18
+ import ipaddress
19
+ import mimetypes
20
+ from typing import Any
21
+ from pathlib import Path
22
+ from urllib.parse import urljoin, urlsplit
23
+ from collections.abc import AsyncIterator
24
+
25
+ import httpx
26
+ from nonebot import logger
27
+
28
+ from .config import plugin_config as cfg
29
+
30
+ # 单个资源最大体积(超过则不内联)
31
+ MAX_BYTES = 6 * 1024 * 1024
32
+ # 缓存条目上限
33
+ MAX_ENTRIES = 512
34
+ # 跟随重定向的最大跳数(每一跳都要重新校验目标地址)
35
+ MAX_REDIRECTS = 3
36
+ # 单次流式读取的块大小
37
+ CHUNK_SIZE = 64 * 1024
38
+ # 文本预览最多读取的字节数(避免预览一个几百 MB 的日志把内存吃光)
39
+ MAX_TEXT_BYTES = 512 * 1024
40
+
41
+ # 允许内联预览的文本类型(MIME)
42
+ _TEXT_TYPES = frozenset(
43
+ {
44
+ 'application/json',
45
+ 'application/xml',
46
+ 'application/javascript',
47
+ 'application/x-javascript',
48
+ 'application/x-yaml',
49
+ 'application/yaml',
50
+ 'application/x-sh',
51
+ 'application/sql',
52
+ 'application/x-python',
53
+ }
54
+ )
55
+ _TEXT_SUFFIXES = frozenset(
56
+ {
57
+ '.txt',
58
+ '.md',
59
+ '.markdown',
60
+ '.log',
61
+ '.json',
62
+ '.jsonl',
63
+ '.xml',
64
+ '.yaml',
65
+ '.yml',
66
+ '.toml',
67
+ '.ini',
68
+ '.cfg',
69
+ '.conf',
70
+ '.csv',
71
+ '.tsv',
72
+ '.env',
73
+ '.py',
74
+ '.js',
75
+ '.mjs',
76
+ '.cjs',
77
+ '.ts',
78
+ '.tsx',
79
+ '.jsx',
80
+ '.java',
81
+ '.kt',
82
+ '.go',
83
+ '.rs',
84
+ '.c',
85
+ '.h',
86
+ '.cpp',
87
+ '.hpp',
88
+ '.cs',
89
+ '.rb',
90
+ '.php',
91
+ '.sh',
92
+ '.bash',
93
+ '.zsh',
94
+ '.fish',
95
+ '.ps1',
96
+ '.bat',
97
+ '.cmd',
98
+ '.lua',
99
+ '.sql',
100
+ '.html',
101
+ '.htm',
102
+ '.css',
103
+ '.scss',
104
+ '.less',
105
+ '.vue',
106
+ '.svelte',
107
+ '.patch',
108
+ '.diff',
109
+ '.srt',
110
+ '.vtt',
111
+ }
112
+ )
113
+
114
+ _cache: dict[str, tuple[bytes, str]] = {}
115
+ _order: list[str] = []
116
+
117
+
118
+ def _remember(url: str, data: bytes, content_type: str) -> None:
119
+ if url in _cache:
120
+ return
121
+ _cache[url] = (data, content_type)
122
+ _order.append(url)
123
+ while len(_order) > MAX_ENTRIES:
124
+ _cache.pop(_order.pop(0), None)
125
+
126
+
127
+ def _ip_blocked_reason(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str:
128
+ """地址是否属于「不该让这里去访问」的范围,返回原因(可访问则返回空串)。"""
129
+ # IPv4-mapped IPv6(::ffff:127.0.0.1)要先还原成 IPv4 再判断,
130
+ # 否则 is_global 会把它当成普通 IPv6 地址放过
131
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
132
+ ip = ip.ipv4_mapped
133
+ if ip.is_loopback:
134
+ return '环回地址'
135
+ if ip.is_link_local:
136
+ return '链路本地地址(含云元数据接口)'
137
+ if ip.is_private:
138
+ return '内网地址'
139
+ if ip.is_reserved:
140
+ return '保留地址'
141
+ if ip.is_multicast:
142
+ return '组播地址'
143
+ if ip.is_unspecified:
144
+ return '未指定地址'
145
+ if not ip.is_global:
146
+ return '非公网地址'
147
+ return ''
148
+
149
+
150
+ async def _blocked_reason(url: str) -> str:
151
+ """检查 URL 的**目标地址**是否不允许访问,允许则返回空串。
152
+
153
+ 域名会先解析成 IP,并且**解析出的每一个**地址都要通过检查:只查第一个
154
+ 的话,攻击者可以用一个同时返回公网与内网地址的域名绕过。
155
+
156
+ 注意这不能完全防住 DNS rebinding(这里解析一次、httpx 连接时可能再解析
157
+ 一次)。对「只在本机使用的管理后台」来说这个强度够用;真要严格防住,
158
+ 需要把解析结果固定下来再直连 IP。
159
+ """
160
+ try:
161
+ parts = urlsplit(url)
162
+ except ValueError:
163
+ return '链接格式不合法'
164
+ if parts.scheme not in ('http', 'https'):
165
+ return '仅支持 http/https 链接'
166
+ host = parts.hostname
167
+ if not host:
168
+ return '链接里没有主机名'
169
+
170
+ # 字面量 IP 直接判断,不走 DNS
171
+ try:
172
+ return _ip_blocked_reason(ipaddress.ip_address(host))
173
+ except ValueError:
174
+ pass
175
+
176
+ # 域名:解析出全部地址逐个判断
177
+ try:
178
+ infos = await asyncio.get_running_loop().getaddrinfo(
179
+ host, parts.port or (443 if parts.scheme == 'https' else 80)
180
+ )
181
+ except (OSError, socket.gaierror) as e:
182
+ return f'域名无法解析({e})'
183
+ if not infos:
184
+ return '域名无法解析'
185
+ for info in infos:
186
+ addr = info[4][0]
187
+ try:
188
+ reason = _ip_blocked_reason(ipaddress.ip_address(addr))
189
+ except ValueError:
190
+ continue
191
+ if reason:
192
+ return f'{host} 解析到{reason} {addr}'
193
+ return ''
194
+
195
+
196
+ class Blocked(Exception):
197
+ """目标地址不允许访问(或跳转太多)"""
198
+
199
+
200
+ def _timeout() -> httpx.Timeout:
201
+ return httpx.Timeout(cfg.botui_api_timeout, connect=min(5.0, cfg.botui_api_timeout))
202
+
203
+
204
+ def _client(headers: dict[str, str] | None = None) -> httpx.AsyncClient:
205
+ """构造代理用的客户端。
206
+
207
+ ``follow_redirects=False``:重定向要自己逐跳校验,不能让 httpx 直接跟
208
+ ——否则公网地址 302 到内网就绕过了 :func:`_blocked_reason`。
209
+ """
210
+ return httpx.AsyncClient(
211
+ timeout=_timeout(), follow_redirects=False, headers=headers
212
+ )
213
+
214
+
215
+ async def _open(
216
+ client: httpx.AsyncClient, url: str, *, headers: dict[str, str] | None = None
217
+ ) -> httpx.Response:
218
+ """发起请求并处理重定向,每一跳都重新校验目标地址。
219
+
220
+ ``headers`` 会被 httpx **合并**到客户端的默认请求头上,所以调用方只需
221
+ 传 ``Range`` 这类逐次不同的头。
222
+ """
223
+ allow_private = bool(cfg.botui_media_allow_private)
224
+ current = url
225
+ for _ in range(MAX_REDIRECTS + 1):
226
+ if not allow_private:
227
+ reason = await _blocked_reason(current)
228
+ if reason:
229
+ raise Blocked(f'拒绝代理媒体 {current[:80]}:{reason}')
230
+ resp = await client.get(current, headers=headers)
231
+ if resp.is_redirect and resp.headers.get('location'):
232
+ current = urljoin(current, resp.headers['location'])
233
+ continue
234
+ resp.raise_for_status()
235
+ return resp
236
+ raise Blocked(f'重定向次数过多:{url[:64]}')
237
+
238
+
239
+ async def fetch(url: str) -> tuple[bytes, str] | None:
240
+ """下载小资源(图片、缩略图)并缓存;失败或目标地址不允许时返回 None。"""
241
+ if not url or not url.startswith(('http://', 'https://')):
242
+ return None
243
+ if url in _cache:
244
+ return _cache[url]
245
+
246
+ try:
247
+ async with _client() as client:
248
+ resp = await _open(client, url)
249
+ data = resp.content
250
+ if not data or len(data) > MAX_BYTES:
251
+ logger.debug(f'Skip media {url[:64]}: size={len(data)}')
252
+ return None
253
+ ctype = (resp.headers.get('content-type') or '').split(';')[0].strip()
254
+ if not ctype:
255
+ ctype = mimetypes.guess_type(url)[0] or 'application/octet-stream'
256
+ _remember(url, data, ctype)
257
+ return data, ctype
258
+ except Blocked as e:
259
+ logger.warning(str(e))
260
+ return None
261
+ except Exception as e:
262
+ logger.debug(f'Failed to fetch media {url[:64]}: {e}')
263
+ return None
264
+
265
+
266
+ async def probe(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
267
+ """发起请求但立刻关闭 body,只留下响应头供调用方读取。
268
+
269
+ 流式转发需要先把 ``content-type`` / ``content-length`` / ``content-range``
270
+ 告诉浏览器,所以单独探一次头。返回的 response 已经关闭,只用它的
271
+ ``status_code`` 与 ``headers``。
272
+ """
273
+ async with _client() as client:
274
+ resp = await _open(client, url, headers=headers)
275
+ await resp.aclose()
276
+ return resp
277
+
278
+
279
+ async def stream(
280
+ url: str, headers: dict[str, str] | None = None
281
+ ) -> AsyncIterator[bytes]:
282
+ """边下边转发远程资源,不在内存里完整驻留。
283
+
284
+ ``headers`` 用于透传 ``Range``(音视频拖动进度条会用到)。目标地址不允许
285
+ 时抛 :class:`Blocked`,其他网络错误原样向上抛,由调用方决定怎么回给浏览器。
286
+ """
287
+ async with _client() as client:
288
+ resp = await _open(client, url, headers=headers)
289
+ async for chunk in resp.aiter_bytes(CHUNK_SIZE):
290
+ yield chunk
291
+
292
+
293
+ class TooLarge(Exception):
294
+ """下载的内容超过了允许的体积(只用来中断缓存下载)"""
295
+
296
+
297
+ async def download(
298
+ url: str,
299
+ dest: Path,
300
+ *,
301
+ max_bytes: int = 0,
302
+ timeout: float | None = None,
303
+ ) -> tuple[int, str]:
304
+ """把远程资源流式写入本地文件,返回 ``(字节数, content-type)``。
305
+
306
+ 与 :func:`stream` 的区别是「边下边落盘」:预缓存媒体时不该先把整个文件读进
307
+ 内存,尤其是视频。超过 ``max_bytes`` 时中断下载并抛 :class:`TooLarge`
308
+ (调用方负责删掉半截的临时文件);目标地址不允许时抛 :class:`Blocked`。
309
+ """
310
+ dest = Path(dest)
311
+ limit = int(max_bytes)
312
+ client = (
313
+ _client()
314
+ if timeout is None
315
+ else httpx.AsyncClient(
316
+ timeout=httpx.Timeout(timeout, connect=min(5.0, timeout)),
317
+ follow_redirects=False,
318
+ )
319
+ )
320
+ written = 0
321
+ try:
322
+ async with client:
323
+ resp = await _open(client, url)
324
+ ctype = (resp.headers.get('content-type') or '').split(';')[0].strip()
325
+ declared = resp.headers.get('content-length')
326
+ if limit > 0 and declared and declared.isdigit() and int(declared) > limit:
327
+ raise TooLarge(f'资源过大({int(declared)} 字节)')
328
+ # 这里就地把下载内容写进本地文件:调用方(预缓存)本来就是后台任务,
329
+ # 而每块只有 64 KB,比起「每块都切一次线程」的开销这样更划算。
330
+ handle = dest.open('wb') # noqa: ASYNC230
331
+ try:
332
+ async for chunk in resp.aiter_bytes(CHUNK_SIZE):
333
+ written += len(chunk)
334
+ if limit > 0 and written > limit:
335
+ raise TooLarge(f'资源过大(>{limit} 字节)')
336
+ handle.write(chunk)
337
+ finally:
338
+ handle.close()
339
+ except Exception:
340
+ dest.unlink(missing_ok=True)
341
+ raise
342
+ if not ctype:
343
+ ctype = mimetypes.guess_type(url)[0] or 'application/octet-stream'
344
+ return written, ctype
345
+
346
+
347
+ def is_textual(content_type: str, name: str = '') -> bool:
348
+ """判断一个资源是否适合当纯文本在线预览。"""
349
+ ctype = (content_type or '').split(';')[0].strip().lower()
350
+ if ctype.startswith('text/') or ctype in _TEXT_TYPES:
351
+ return True
352
+ # MIME 不可靠(很多文件源返回 application/octet-stream),按扩展名兜底
353
+ target = name or ''
354
+ try:
355
+ target = urlsplit(target).path or target
356
+ except ValueError: # pragma: no cover - 防御性
357
+ target = name or ''
358
+ dot = target.rfind('.')
359
+ suffix = target[dot:].lower() if dot >= 0 else ''
360
+ return suffix in _TEXT_SUFFIXES
361
+
362
+
363
+ def decode_text(data: bytes) -> tuple[str, bool]:
364
+ """把字节解码成文本,返回 ``(文本, 是否被截断)``。
365
+
366
+ 先看 BOM 定 UTF-16/32,再依次尝试 UTF-8 / GB18030:中文聊天里发来的 txt
367
+ 大多是这几种。不带 BOM 的 UTF-16 与 GB18030 无法可靠区分,这里优先按
368
+ GB18030 解(UTF-16 文件一般带 BOM)。都不行就退回 UTF-8 + 替换字符。
369
+ """
370
+ truncated = len(data) > MAX_TEXT_BYTES
371
+ head = data[:MAX_TEXT_BYTES]
372
+ # 带 BOM 的 UTF-16/32:交给 utf-16 处理(它认得 BOM)
373
+ if head[:2] in (b'\xff\xfe', b'\xfe\xff'):
374
+ try:
375
+ return head.decode('utf-16'), truncated
376
+ except UnicodeDecodeError:
377
+ pass
378
+ for encoding in ('utf-8', 'gb18030'):
379
+ try:
380
+ return head.decode(encoding), truncated
381
+ except (UnicodeDecodeError, LookupError):
382
+ continue
383
+ return head.decode('utf-8', errors='replace'), truncated
384
+
385
+
386
+ async def fetch_text(url: str) -> tuple[str, bool, str, int] | None:
387
+ """读取文本文件内容用于在线预览,返回 ``(文本, 被截断, 类型, 总字节数)``。
388
+
389
+ 用 ``Range: bytes=0-N`` 只取前 :data:`MAX_TEXT_BYTES` 个字节,避免预览
390
+ 一个几百 MB 的日志把内存和带宽吃光;服务端不支持 Range 时读完再截断。
391
+ 总字节数取自 ``Content-Range`` / ``Content-Length``,拿不到时为 0。
392
+ """
393
+ if not url or not url.startswith(('http://', 'https://')):
394
+ return None
395
+ range_header = {'Range': f'bytes=0-{MAX_TEXT_BYTES - 1}'}
396
+ try:
397
+ async with _client() as client:
398
+ resp = await _open(client, url, headers=range_header)
399
+ try:
400
+ data = await resp.aread()
401
+ finally:
402
+ await resp.aclose()
403
+ except Blocked as e:
404
+ logger.warning(str(e))
405
+ return None
406
+ except Exception as e:
407
+ logger.debug(f'Failed to read text {url[:64]}: {e}')
408
+ return None
409
+ if not data:
410
+ return None
411
+ ctype = (resp.headers.get('content-type') or '').split(';')[0].strip()
412
+ text, truncated = decode_text(data)
413
+ # 服务端忽略 Range 时也要截断,避免超长内容拖垮前端
414
+ if len(data) >= MAX_TEXT_BYTES:
415
+ truncated = True
416
+ return text, truncated, ctype, _total_bytes(resp.headers)
417
+
418
+
419
+ def _total_bytes(headers: Any) -> int:
420
+ """从响应头里推断资源总字节数(拿不到返回 0)。"""
421
+ content_range = headers.get('content-range') or ''
422
+ if '/' in content_range:
423
+ tail = content_range.rsplit('/', 1)[-1].strip()
424
+ if tail.isdigit():
425
+ return int(tail)
426
+ length = headers.get('content-length')
427
+ return int(length) if length and length.isdigit() else 0
428
+
429
+
430
+ def avatar_of(avatar: Any) -> str:
431
+ """从 uninfo 的 Scene/User 头像字段里取出可用的 url"""
432
+ if not avatar:
433
+ return ''
434
+ if isinstance(avatar, str):
435
+ return avatar
436
+ if isinstance(avatar, dict):
437
+ return str(avatar.get('url') or avatar.get('src') or '')
438
+ return str(getattr(avatar, 'url', '') or '')