1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
|
"""Group chat: sealed messages, history, the epoch keys, link previews, and the
operator's chat settings."""
import base64
import logging
import time
import blake3
from meshbay_common import MNP_VERSION
from meshbay_common.adminop import OP_CHAT_DIRECTORY, OP_CHAT_EPOCH, OP_CHAT_LINK_PREVIEW
from meshbay_common.chatbox import NONCE_LEN as CHAT_NONCE_LEN
from meshbay_common.chatbox import SIG_LEN as CHAT_SIG_LEN
from meshbay_common.groupbox import PURPOSE_CHAT_KEYS, seal
from meshbay_common.protocol import MNP
from meshbay_node import linkpreview, ops
from meshbay_node.chat import FORMAT_SEALED_V1, ReplayedMessage
log = logging.getLogger("meshbay_node.transport.webrtc_server")
# Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5:
# the node
# produces enrichment on demand and keeps nothing durable — the asking device
# caches). Bounded and time-limited so a busy group cannot grow it without end
# and a page that changed its card is picked up within the hour.
_LINK_PREVIEW_TTL = 3600
_LINK_PREVIEW_MAX = 256
_link_preview_cache: dict[str, tuple[float, dict]] = {}
def _link_preview_cache_get(url: str) -> dict | None:
hit = _link_preview_cache.get(url)
if hit is None:
return None
ts, value = hit
if time.time() - ts > _LINK_PREVIEW_TTL:
_link_preview_cache.pop(url, None)
return None
return value
def _link_preview_cache_put(url: str, value: dict) -> None:
if not url:
return
if len(_link_preview_cache) >= _LINK_PREVIEW_MAX:
oldest = min(_link_preview_cache, key=lambda k: _link_preview_cache[k][0])
_link_preview_cache.pop(oldest, None)
_link_preview_cache[url] = (time.time(), value)
# A member pasting a link is normal; a member — or a hub minting tokens for many
# accounts — firing hundreds is amplification/DoS and a way to make the node
# reach arbitrary hosts on demand (finding M3). Only a real outbound fetch is
# counted (a cache hit costs nothing), and the ceilings are generous enough that
# ordinary chat never meets them.
_LINK_PREVIEW_RATE_WINDOW = 60.0
_LINK_PREVIEW_RATE_PER_CONN = 15
_LINK_PREVIEW_RATE_NODE = 60
# Chat limits. A message is a member-supplied write onto the operator's disk
# (`chat.db`, where retention is a manual CLI command — §6.6), relayed from there
# to every other connected member and turned into a notification for every member
# of the group. Nothing bounded any of it: the only ceiling was the frame size,
# 64 MB once the handshake is done, so one member in a loop could fill the
# operator's disk and saturate everyone else's connection. Uploads — the other
# member-supplied write — have carried four protections and a size cap since
# C5a; this is the same question asked of the path nobody had asked it of.
#
# 64 KB of ciphertext is about sixty thousand characters. The sealed payload is
# the text, a thread id, a display name and a timestamp: an attachment is a file
# on a root and travels as a reference (§4.5), so nothing legitimate comes close.
MAX_CHAT_CIPHERTEXT = 64 * 1024
# Per account per group, not per connection: a second tab does not make a person
# type faster, and keying on the session would hand a script one budget per
# socket. Sixty a minute is far above a human and far below a flood.
_CHAT_RATE_WINDOW = 60.0
_CHAT_RATE_PER_ACCOUNT = 60
# When the map of senders grows past this, the stale entries are dropped. A node
# with more live chatters than this in one window is not the case being bounded.
_CHAT_RATE_MAX_TRACKED = 1000
class ChatMixin:
async def _new_chat_epoch(self, group_id: str, reason: str) -> None:
"""
Open a chat epoch because the set of devices that may read future
messages just shrank.
Called on every removal — a member, a device, an unpin — and on group
key rotation, because the operator rotates precisely when someone has
left. It is the exact counterpart of "still rotate the GEK, the
ex-member holds the current one": revocation stops the node handing
over the *next* key, and nothing else takes the current one away.
Best effort by design: a failure here must never turn a successful
revocation into a refused one — the revocation is the control, and this
is the follow-through. It is logged loudly instead, because an operator
who removed someone needs to know if the chat key did not move.
"""
if not group_id:
return
try:
result = await self._run_op(ops.open_chat_epoch, group_id)
except Exception as e:
log.error("chat: could not open a new epoch for group %s after "
"%s (%s) — the removed party still holds the current "
"chat key", group_id[:8], reason, e)
self._audit("chat_epoch_failed", reason)
return
self._audit("chat_epoch", f"{reason}:{result['epoch']}")
# Everyone still connected picks the new key up without reconnecting.
for session in list(
(self._ctx.get("groups") or {}).get(group_id, {})
.get("_peers", {}).values()):
try:
session._send({"type": MNP.CHAT_EPOCH_ACK, "v": MNP_VERSION,
"epoch": result["epoch"]})
except Exception:
pass
def _do_chat_directory(self, msg: dict) -> None:
"""
Where chat attachments are written.
Unlike every other app directory this one is a destination, so it has
to be on a read-write root — checked by `ops.set_chat_directory` after
the signature, which is where the refusal actually lives.
"""
path = msg.get("path")
if not isinstance(path, str):
self._send({"type": "error", "detail": "Missing or invalid 'path'"})
return
path = path.strip("/")
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
self._issue_admin_challenge(OP_CHAT_DIRECTORY, path)
async def _admin_exec_chat_directory(
self, pending: dict, transcript: bytes, sig: bytes,
) -> None:
path = pending["subject"]
if not await self._verify_admin_sig(transcript, sig):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"chat_directory:{path}")
return
try:
await self._run_op(
ops.set_chat_directory, self._group_id or "", path)
except ops.OpError as e:
self._send({"type": "error", "detail": e.message})
return
self._audit("chat_directory", path)
self._broadcast_to_group(
{"type": MNP.CHAT_DIRECTORY_ACK, "v": MNP_VERSION, "path": path})
def _do_chat_link_preview(self, msg: dict) -> None:
"""
Whether the node fetches a page's title and image when a member posts
a link — outbound traffic on the operator's connection, from a message
they did not write, so it is signed like everything else that decides
what leaves this machine.
"""
enabled = msg.get("enabled")
if not isinstance(enabled, bool):
self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
return
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
self._issue_admin_challenge(
OP_CHAT_LINK_PREVIEW, "on" if enabled else "off")
async def _admin_exec_chat_link_preview(
self, pending: dict, transcript: bytes, sig: bytes,
) -> None:
enabled = pending["subject"] == "on"
if not await self._verify_admin_sig(transcript, sig):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed",
f"chat_link_preview:{pending['subject']}")
return
try:
await self._run_op(
ops.set_chat_link_preview, self._group_id or "", enabled)
except ops.OpError as e:
self._send({"type": "error", "detail": e.message})
return
self._audit("chat_link_preview", pending["subject"])
self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK,
"v": MNP_VERSION, "enabled": enabled})
def _do_chat_epoch(self, msg: dict) -> None:
"""
Open a new chat epoch by hand. Operator only, and signed.
There is no switch to turn chat encryption on: MNP 2.0 has no plaintext
chat to fall back to. What an operator may want to do deliberately is
move the key on — the same instruction as `gek_rotate`, and signed for
the same reason. The removals that matter (member revoke, member unpin,
device revoke, `gek_rotate`) already open one by themselves.
"""
group_id = str(msg.get("group_id", "")).strip() or self._group_id
if not group_id:
self._send({"type": "error", "detail": "No group on this connection"})
return
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
self._issue_admin_challenge(OP_CHAT_EPOCH, group_id, group_id=group_id)
async def _admin_exec_chat_epoch(
self, pending: dict, transcript: bytes, sig: bytes,
) -> None:
if not await self._verify_admin_sig(transcript, sig):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"chat_epoch:{pending['subject'][:8]}")
return
try:
result = await self._run_op(ops.open_chat_epoch, pending["subject"])
except ops.OpError as e:
self._send({"type": "error", "detail": e.message})
return
self._audit("chat_epoch", f"manual:{result['epoch']}")
self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK,
"v": MNP_VERSION, "epoch": result["epoch"]})
async def _do_chat_keys_req(self, msg: dict) -> None:
"""
Hand this member every chat epoch key the group has, sealed.
Sealed under a group-derived subkey rather than sent in clear: the same
reasoning as the index and the handshake ack, and one step stronger
here, because the payload *is* key material. A peer that has completed
the handshake holds the group key and can open it; anything short of
that gets a ciphertext.
**Every** live epoch, not just the current one, which is what keeps the
history readable to a member who joined after it was written and to a
device linked this morning. Whether a new member should receive the back
catalogue at all is a policy question with a per-group answer; the shape
is here so that answer can be given without a wire change.
"""
gctx = self._group_ctx()
gek = gctx.get("gek")
if not gek:
self._send({"type": "error", "detail": "Group encryption not initialized"})
return
try:
keys = await self._run_op(ops.chat_epoch_keys, self._group_id or "")
except ops.OpError as e:
self._send({"type": "error", "detail": e.message})
return
payload = {"epochs": [{"epoch": k["epoch"], "key": k["key"]}
for k in keys],
"current": keys[-1]["epoch"] if keys else 0}
sealed = seal(gek, PURPOSE_CHAT_KEYS, MNP.CHAT_KEYS_RESP,
self._group_id or "", payload)
self._send({"type": MNP.CHAT_KEYS_RESP, "v": MNP_VERSION,
"group_id": self._group_id or "", **sealed})
def _do_chat_message(self, msg: dict) -> None:
"""
Store one message and hand it to everyone else in this group.
The node is a relay and an archive here, not a reader: once a group has
chat encryption on, `payload` is a ciphertext it cannot open, and every
decision below is made from fields that stay in clear — which is why
those fields are the ones that must be *authenticated* rather than
merely present.
`sender_id` comes from the authenticated session and never from the wire
(NS6). What the wire may now assert is the sending *device*, and that is
checked against this connection rather than believed: a member who could
name any device could sign as anyone once receivers verify signatures.
"""
# Per-group store — see _peer_registry() and finding H1. Reading
# chat_store off the shared transport context sent every group's
# messages to the first group's database, and served them back to
# anyone on the node.
gctx = self._group_ctx()
chat_store = gctx.get("chat_store")
sender_name = msg.get("sender_name", "")
# Two shapes, and keeping them apart is what makes this deployable.
#
# A plaintext message is exactly what it has always been: a string in
# `payload`. A sealed one carries its ciphertext in `ct`, beside the
# `nonce`/`device`/`sig` that authenticate it. Putting the ciphertext in
# `payload` instead would have been tidier and wrong: `payload` reaches
# older clients — the UI ships inside the desktop package now, so it can
# be months behind the node — and they would render bytes where they
# expect text. A field they have never heard of is ignored instead.
fmt = int(msg.get("format", 0) or 0)
epoch = int(msg.get("epoch", 0) or 0)
device = msg.get("device")
nonce = msg.get("nonce")
sig = msg.get("sig")
if fmt == FORMAT_SEALED_V1:
payload = ""
raw = bytes(msg.get("ct") or b"")
else:
payload = msg.get("payload", "")
raw = (payload.encode() if isinstance(payload, str)
else bytes(payload or b""))
refusal = self._check_chat_envelope(gctx, fmt, raw, device, nonce, sig)
if refusal:
self._send({"type": "error", "detail": refusal})
self._audit("chat_refused", refusal)
return
# Separate from the envelope check above, and deliberately: that one asks
# whether the message is well formed and authentic, this one asks what it
# costs everyone else. Before either is written to disk or relayed.
detail, code = self._chat_bounds_refusal(raw)
if detail:
self._send({"type": "error", "detail": detail, "code": code})
self._audit("chat_refused", code)
return
if sender_name:
self._user_names()[self._user_id] = sender_name
if chat_store:
self._spawn(self._store_chat_message(
chat_store,
iteration=msg.get("iteration", 0), payload=raw,
thread_id=msg.get("thread_id"), sender_name=sender_name,
format=fmt, epoch=epoch, device=device, nonce=nonce, sig=sig,
))
peers = self._peer_registry()
broadcast = {
"type": MNP.CHAT_MESSAGE,
"v": MNP_VERSION,
"sender_id": self._user_id,
"sender_name": sender_name,
"payload": payload,
"thread_id": msg.get("thread_id"),
"timestamp": time.time(),
"format": fmt,
"epoch": epoch,
"device": device,
"nonce": nonce,
"sig": sig,
}
if fmt == FORMAT_SEALED_V1:
broadcast["ct"] = raw
# Excludes this connection, not this account. The sender's other
# devices are ordinary recipients: they did not compose the message and
# have no local echo of it, so skipping them by user_id left a person's
# second device silently missing everything they said from the first.
for session in list(peers.values()):
if session is not self:
try:
session._send(broadcast)
except Exception:
pass
hub_ws = self._ctx.get("hub_ws")
if hub_ws and self._group_id:
try:
import json as _json
self._spawn(hub_ws.send(_json.dumps({
"type": "chat_notify",
"group_id": self._group_id,
# No sender_name. The body is unreadable to the hub the
# moment a group turns encryption on, and shipping the
# author's display name beside it would leave the hub a
# per-message record of who spoke where — the metadata the
# feature is otherwise about not producing. The hub renders
# "New message in <group>".
#
# `sender_user_id` stays: the hub needs it to not notify
# the author of their own message, and it already knows the
# group's membership.
"sender_user_id": self._user_id,
})))
except Exception:
pass
self._send({"type": "ack", "v": MNP_VERSION})
self._audit("chat_message")
def _check_chat_envelope(self, gctx: dict, fmt: int, ct: bytes, device,
nonce, sig) -> str:
"""
Why this message is refused, or "" to accept it.
Two rules, and the first is the one that matters:
**A device may only send as itself.** `device` is what receivers verify
a signature against, so a member free to name another member's key could
be that member to everyone — worse than the node-asserted attribution it
replaces (NS6), not better. The connection has proved which device it is
(`device_hello`), and this must match it.
**Plaintext is refused, always.** Not "accepts and marks", and not
"unless a switch says otherwise": a member who can post in clear into a
group whose members believe their chat is encrypted is a downgrade, and
C6 is the standing lesson that the bypass left open is the one that gets
used. There is no switch to leave open — MNP 2.0 refuses a 1.x peer at
the handshake, so nothing that reaches here is unable to seal.
`FORMAT_PLAIN` still exists, because rows written before 2.0 are still
in `chat.db` and still served. It is a *storage* state, never something
this accepts from the wire.
"""
if fmt != FORMAT_SEALED_V1:
return "Chat messages must be encrypted"
if not (isinstance(device, (bytes, bytearray))
and isinstance(nonce, (bytes, bytearray))
and isinstance(sig, (bytes, bytearray))):
return "Sealed chat message is missing its envelope"
if len(nonce) != CHAT_NONCE_LEN or len(sig) != CHAT_SIG_LEN:
return "Sealed chat message has a malformed envelope"
if not ct:
return "Sealed chat message has no ciphertext"
claimed = base64.b64encode(bytes(device)).decode()
if not self._device_confirmed:
return ("Identify this device before sending chat (device_hello)")
if claimed != self._pinned_pk:
return "That is not the device on this connection"
return ""
def _chat_bounds_refusal(self, ct: bytes) -> tuple[str, str]:
"""What this message would cost the others, or ("", "") to accept it.
Two bounds, and each answers a different half of "who pays". A message
is written to `chat.db` on the operator's disk and kept — retention is a
manual command (§6.6) — then relayed to every other connected member and
turned into a notification for every member of the group. So **size**
bounds what one message costs, and **rate** bounds how often one member
may impose it.
There is deliberately no node-wide ceiling to go with the per-account
one. The link-preview limiter has both because a preview spends the
*node's* egress and its third-party quota, which is one shared thing; a
chat message spends the sender's own group. A node-wide chat ceiling
would let a busy group silence a quiet one, which is the same class of
defect this bound exists to close, one level up.
"""
if len(ct) > MAX_CHAT_CIPHERTEXT:
return ("This message is too large to send in chat — "
"send a large file as an attachment instead.",
"chat_too_large")
if not self._chat_rate_ok():
return ("Too many messages just now — wait a moment.",
"chat_rate_limited")
return ("", "")
def _chat_rate_ok(self) -> bool:
"""True when this sender is within their window; records it when so.
Keyed by (group, account) on the transport context rather than on the
session: the sender is authenticated, so this is the one identifier a
second tab — or fifty of them — cannot multiply. The window is trimmed
on every call, and the map of senders is swept when it grows, so neither
can be the memory leak the bound was added to prevent.
"""
now = time.monotonic()
hits: dict = self._ctx.setdefault("chat_hits", {})
if len(hits) > _CHAT_RATE_MAX_TRACKED:
for key, times in list(hits.items()):
if not times or now - times[-1] >= _CHAT_RATE_WINDOW:
hits.pop(key, None)
key = (self._group_id or "", self._user_id or "")
mine = [t for t in hits.get(key, ()) if now - t < _CHAT_RATE_WINDOW]
if len(mine) >= _CHAT_RATE_PER_ACCOUNT:
hits[key] = mine
return False
mine.append(now)
hits[key] = mine
return True
async def _store_chat_message(self, chat_store, **kwargs) -> None:
"""
Persist one message, treating a replay as already-done.
A replayed message is a *validly signed* copy of a real one, so nothing
about the signature refuses it; the unique `(device, nonce)` does. It is
logged and dropped rather than raised at the sender: the message it
duplicates is already stored, so there is nothing for anyone to retry.
"""
try:
await chat_store.save_message(sender_id=self._user_id, **kwargs)
except ReplayedMessage:
log.warning("Replayed chat message from %s dropped",
(self._user_id or "?")[:8])
self._audit("chat_replay_dropped")
def _do_chat_history(self, msg: dict) -> None:
chat_store = self._group_ctx().get("chat_store")
if not chat_store:
self._send({
"type": MNP.CHAT_HISTORY_RESPONSE,
"v": MNP_VERSION,
"messages": [],
"has_more": False,
})
return
# `before` pages backwards from the newest, which is the direction a chat
# is actually read. `since` remains for callers that want everything
# after a point in time; the browser no longer uses it.
before = msg.get("before")
limit = max(1, min(int(msg.get("limit", 100)), 200))
self._spawn(self._send_chat_history(chat_store, before, limit))
async def _send_chat_history(self, chat_store, before, limit: int) -> None:
if before:
msgs = await chat_store.get_before(int(before), limit=limit)
else:
msgs = await chat_store.get_recent(limit=limit)
# Whether the "load older" control has anything left to fetch. Asked
# about the oldest row returned, so an empty page correctly says no.
has_more = await chat_store.has_before(msgs[0].id) if msgs else False
names = self._user_names()
self._send({
"type": MNP.CHAT_HISTORY_RESPONSE,
"v": MNP_VERSION,
"has_more": has_more,
# `payload` goes out as **bytes**, never decoded here. It used to be
# `.decode("utf-8", errors="replace")`, which substitutes U+FFFD for
# every byte that is not valid UTF-8 — fine while chat was text, and
# silent destruction of a ciphertext. Live messages would have kept
# working (they are relayed, not re-read), so the symptom would have
# been "history won't decrypt", which is the hardest possible place
# to look. msgpack carries `bin` on both sides; the client decides
# how to read it from `format`.
"messages": [self._history_row(m, names) for m in msgs],
})
@staticmethod
def _history_row(m, names: dict) -> dict:
"""One stored message on the wire.
A plaintext row goes out under `payload` as a string, exactly as it
always has — an older client reads this response and must keep working.
A sealed row's ciphertext goes out under `ct` as bytes and `payload`
stays empty: decoding a ciphertext as UTF-8 (which is what this did,
with `errors="replace"`) substitutes U+FFFD for most of it, and the
symptom would have been history that will not decrypt while live
messages worked — the hardest possible place to look.
"""
row = {
"id": m.id,
"sender_id": m.sender_id,
"sender_name": m.sender_name or names.get(m.sender_id, ""),
"timestamp": m.timestamp,
"thread_id": m.thread_id,
"format": m.format,
"epoch": m.epoch,
"device": m.device,
"nonce": m.nonce,
"sig": m.sig,
}
if m.format == FORMAT_SEALED_V1:
row["payload"] = ""
row["ct"] = m.payload
else:
row["payload"] = (m.payload.decode("utf-8", errors="replace")
if isinstance(m.payload, bytes) else m.payload)
return row
def _link_preview_rate_ok(self) -> bool:
"""
True when this preview fetch is within both the per-connection and the
node-wide window; records it when so, and both counts are trimmed to the
window on every call so the lists cannot grow without bound.
"""
now = time.monotonic()
w = _LINK_PREVIEW_RATE_WINDOW
mine = [t for t in getattr(self, "_link_preview_hits", []) if now - t < w]
node = [t for t in self._ctx.get("link_preview_hits", []) if now - t < w]
if (len(mine) >= _LINK_PREVIEW_RATE_PER_CONN
or len(node) >= _LINK_PREVIEW_RATE_NODE):
self._link_preview_hits = mine
self._ctx["link_preview_hits"] = node
return False
mine.append(now)
node.append(now)
self._link_preview_hits = mine
self._ctx["link_preview_hits"] = node
return True
async def _do_link_preview_request(self, msg: dict) -> None:
"""
Unfurl a URL a member pasted into chat (docs/MESHBAY_DESIGN.md §6.5's
enrichment rule:
the client asks, the node produces on demand, the asking device
caches — nothing durable here).
`linkpreview.safe_url` is the SSRF gate: the URL a *member* chose
decides an outbound request from the operator's machine, so http(s)
only and the resolved address must be globally routable. Failure of
any kind — blocked, unreachable, not HTML, nothing worth showing —
comes back as `ok: false`, the way a TMDB miss does; the client then
just shows the bare link.
"""
url = msg.get("url")
key = url if isinstance(url, str) else ""
# Checked before the cache, not after: the operator turning previews
# off has to stop serving the ones already fetched too, or the setting
# takes effect only for links nobody has posted yet. Refused as an
# ordinary miss — the client shows the bare link, which is exactly what
# "no preview" looks like for a page that has none.
if not self._group_ctx().get("chat_link_preview", True):
self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
"url": key, "ok": False})
return
cached = _link_preview_cache_get(key)
if cached is not None:
self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION})
return
if not self._link_preview_rate_ok():
# Same shape as any other miss — the client shows the bare link. A
# rate-limited result is not cached, so it is retried once the
# window clears rather than pinned as "no preview".
log.debug("link_preview_req: rate-limited (peer=%s)", self._peer_id)
self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
"url": key, "ok": False})
return
resp: dict = {"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
"url": key, "ok": False}
try:
meta = await linkpreview.fetch_preview(url)
if meta is not None:
resp.update(ok=True, title=meta["title"],
description=meta["description"],
site_name=meta["site_name"])
image_url = meta.get("image_url")
media_cache = self._ctx.get("media_cache")
if image_url and media_cache is not None:
synthetic_id = f"linkpreview:{image_url}"
thumb_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id)
if thumb_hash is None:
jpeg = await linkpreview.fetch_image(image_url)
if jpeg:
thumb_hash = blake3.blake3(jpeg).hexdigest()
await media_cache.put_thumb(thumb_hash, synthetic_id, jpeg)
if thumb_hash:
resp["image_thumb_hash"] = thumb_hash
except Exception as e:
log.debug("link_preview_req %s: %s", key[:80], e)
_link_preview_cache_put(key, {k: v for k, v in resp.items()
if k not in ("type", "v")})
self._send(resp)
|