summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py59
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py6
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_bound.py186
3 files changed, 251 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 4540f2f..773d3dc 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -213,6 +213,23 @@ _LINK_PREVIEW_RATE_WINDOW = 60.0
_LINK_PREVIEW_RATE_PER_CONN = 15
_LINK_PREVIEW_RATE_NODE = 60
+# A free-text TMDB search spends the *operator's* credential, which is rated by
+# TMDB and shared by everyone in the group: one member typing in the search box
+# can exhaust what every other member's automatic matching depends on, and the
+# operator is the one who has to notice. §6.5's rule is a bound and a named
+# adversary in the same commit; this one arrived without either.
+#
+# Per member rather than per connection, unlike link previews above: three tabs
+# is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the
+# group context so it survives a reconnect, which is the other thing a per-session
+# count cannot do.
+#
+# Generous next to what a person types — ten searches a minute is a search every
+# six seconds, sustained — and small next to a loop.
+_TMDB_SEARCH_WINDOW = 60.0
+_TMDB_SEARCH_PER_MEMBER = 10
+_TMDB_SEARCH_NODE = 30
+
# 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
@@ -4471,6 +4488,21 @@ class WebRTCPeerSession:
"query": query, "media_type": media_type, "results": []})
return
+ # Refused out loud, not as an empty result: "no matches" is what the
+ # client draws for an empty list, and telling somebody their film is
+ # unknown when the node simply declined to ask is a worse answer than
+ # the truth. `video-app.js`'s `runSearch` puts `detail` on screen.
+ if not self._tmdb_search_rate_ok():
+ log.info("tmdb_search_req: rate-limited (user=%s)", (self._user_id or "")[:8])
+ self._send({
+ "type": "error",
+ "detail": "Too many searches in the last minute. This spends the "
+ "operator's search quota, which everyone in the group "
+ "shares — try again shortly.",
+ "code": "tmdb_search_rate_limited",
+ })
+ return
+
raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
else await tmdb_client.search_tv_results(query))
results = []
@@ -5103,6 +5135,33 @@ class WebRTCPeerSession:
if isinstance(m.payload, bytes) else m.payload)
return row
+ def _tmdb_search_rate_ok(self) -> bool:
+ """
+ True when this search is within both the member's window and the node's;
+ records it when so, and trims both to the window on every call so neither
+ list can grow without bound.
+
+ Both are checked because they answer different questions: the member's
+ keeps one person from spending everyone's quota, and the node's keeps a
+ group of them from doing it together.
+ """
+ now = time.monotonic()
+ w = _TMDB_SEARCH_WINDOW
+ ctx = self._group_ctx()
+ by_member = ctx.setdefault("tmdb_search_hits", {})
+ who = self._user_id or ""
+ mine = [t for t in by_member.get(who, []) if now - t < w]
+ node = [t for t in self._ctx.get("tmdb_search_hits_node", []) if now - t < w]
+ if len(mine) >= _TMDB_SEARCH_PER_MEMBER or len(node) >= _TMDB_SEARCH_NODE:
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return False
+ mine.append(now)
+ node.append(now)
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return True
+
def _link_preview_rate_ok(self) -> bool:
"""
True when this preview fetch is within both the per-connection and the
diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py
index 9dcc6ce..4141d13 100644
--- a/packages/meshbay-node/tests/test_season_and_search_requests.py
+++ b/packages/meshbay-node/tests/test_season_and_search_requests.py
@@ -22,6 +22,12 @@ def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession:
session = WebRTCPeerSession.__new__(WebRTCPeerSession)
session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client}
session._group_id = None
+ # Set because production always has one: `_dispatch_message` refuses every
+ # message until the handshake settles `_user_id`, so a session reaching any
+ # of these handlers without it does not exist. Left out, this fixture was
+ # narrower than the node and the per-member search ceiling could not be
+ # exercised by it at all.
+ session._user_id = "u1"
session.sent = []
session._send = session.sent.append
return session
diff --git a/packages/meshbay-node/tests/test_tmdb_search_bound.py b/packages/meshbay-node/tests/test_tmdb_search_bound.py
new file mode 100644
index 0000000..486a2c2
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_search_bound.py
@@ -0,0 +1,186 @@
+"""
+One member's typing must not spend what the whole group depends on.
+
+`tmdb_search_req` takes a member's free text and calls TMDB with the
+**operator's** credential. That credential is rated by TMDB and shared: the
+automatic matching every other member sees runs on it too. So a member holding
+down a search box — or a script doing it — degrades the library for everyone and
+costs the operator their quota, and the node had no ceiling of any kind on it.
+§6.5's standing rule is a bound and a named adversary in the same commit; this
+handler shipped with neither.
+
+Two members in every test here, which is the point: a ceiling that one person
+can exhaust for another is not a ceiling, it is a queue. The per-member window
+is what keeps them apart, and the node-wide one is what keeps them together
+from emptying the operator's quota — they answer different questions and both
+are checked.
+
+The refusal is an error rather than an empty result. An empty list is what "no
+such film" looks like, and telling somebody their film is unknown when the node
+simply declined to ask is a worse answer than the truth.
+"""
+
+import pytest
+from meshbay_node.transport import webrtc_server
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+
+
+class _FakeTmdb:
+ """Counts what would have been spent."""
+
+ def __init__(self):
+ self.calls = 0
+
+ async def search_movie_results(self, query):
+ self.calls += 1
+ return [{"id": 1, "title": "Some Saga", "release_date": "1999-01-01",
+ "poster_path": None}]
+
+ async def search_tv_results(self, query):
+ self.calls += 1
+ return []
+
+
+class _FakeMediaCache:
+ async def get_thumb_hash_by_file_id(self, _file_id):
+ return None
+
+
+@pytest.fixture
+def group():
+ """One group's context, shared by every session in it, as a node has."""
+ return {
+ "gek": b"k" * 32,
+ "tmdb_enabled": True,
+ }
+
+
+@pytest.fixture
+def node(group):
+ tmdb = _FakeTmdb()
+ ctx = {
+ "groups": {GROUP: group},
+ "media_cache": _FakeMediaCache(),
+ "tmdb_client": tmdb,
+ }
+ return ctx, tmdb
+
+
+def _member(ctx, user_id: str) -> WebRTCPeerSession:
+ s = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ s._ctx = ctx
+ s._group_id = GROUP
+ s._user_id = user_id
+ s._peer_id = user_id
+ s.sent = []
+ s._send = s.sent.append
+ s._audit = lambda *a, **k: None
+ return s
+
+
+async def _search(session, query="a film"):
+ await session._do_tmdb_search_request(
+ {"query": query, "media_type": "movie"})
+
+
+def _refusals(session):
+ return [m for m in session.sent
+ if m.get("code") == "tmdb_search_rate_limited"]
+
+
+async def test_a_member_at_the_ceiling_does_not_stop_another_one(node, monkeypatch):
+ """
+ The property a one-member test cannot state.
+
+ Alice exhausts her own window; Bob, who has typed nothing, must be served
+ exactly as if she had not been there.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 3)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100)
+ ctx, tmdb = node
+
+ alice = _member(ctx, "alice")
+ for i in range(4):
+ await _search(alice, f"film {i}")
+ assert tmdb.calls == 3, "the ceiling did not stop the fourth search"
+ assert len(_refusals(alice)) == 1
+
+ bob = _member(ctx, "bob")
+ await _search(bob, "something else")
+ assert tmdb.calls == 4
+ assert _refusals(bob) == []
+
+
+async def test_one_member_cannot_spend_the_whole_node_quota(node, monkeypatch):
+ """
+ And the other half: two members together still meet a node-wide ceiling,
+ because the operator's credential is one credential however many people
+ hold the search box down.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 100)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 2)
+ ctx, tmdb = node
+
+ alice, bob = _member(ctx, "alice"), _member(ctx, "bob")
+ await _search(alice)
+ await _search(bob)
+ await _search(bob)
+
+ assert tmdb.calls == 2
+ assert len(_refusals(bob)) == 1
+
+
+async def test_a_members_count_survives_their_reconnection(node, monkeypatch):
+ """
+ Kept in the group context, not on the session: otherwise the ceiling is one
+ reconnect wide, and a client that drops its DataChannel between searches has
+ no ceiling at all.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 2)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100)
+ ctx, tmdb = node
+
+ first = _member(ctx, "alice")
+ await _search(first, "one")
+ await _search(first, "two")
+
+ reconnected = _member(ctx, "alice") # same person, new connection
+ await _search(reconnected, "three")
+
+ assert tmdb.calls == 2, "a reconnect reset the member's window"
+ assert len(_refusals(reconnected)) == 1
+
+
+async def test_a_refusal_is_said_out_loud_and_not_drawn_as_no_matches(node, monkeypatch):
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 0)
+ ctx, _ = node
+
+ alice = _member(ctx, "alice")
+ await _search(alice)
+
+ (msg,) = alice.sent
+ assert msg["type"] == "error"
+ assert msg["code"] == "tmdb_search_rate_limited"
+ assert msg.get("results") is None, (
+ "a refusal that carries an empty result list reads as 'no such film'")
+
+
+async def test_the_windows_do_not_grow_without_bound(node, monkeypatch):
+ """
+ The lists are trimmed on every call, so the thing that bounds a member also
+ bounds what remembering them costs.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_WINDOW", 0.0)
+ ctx, tmdb = node
+
+ alice = _member(ctx, "alice")
+ for i in range(12):
+ await _search(alice, f"film {i}")
+
+ # Every entry ages out before the next call, so nothing is refused, and what
+ # is kept is the one just recorded rather than one per search ever made.
+ assert tmdb.calls == 12
+ assert len(ctx["groups"][GROUP]["tmdb_search_hits"]["alice"]) == 1
+ assert len(ctx["tmdb_search_hits_node"]) == 1