weed-cli 1.7.4__tar.gz → 1.8.0__tar.gz
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.
- {weed_cli-1.7.4/weed_cli.egg-info → weed_cli-1.8.0}/PKG-INFO +2 -1
- {weed_cli-1.7.4 → weed_cli-1.8.0}/node.py +20 -2
- {weed_cli-1.7.4 → weed_cli-1.8.0}/pyproject.toml +11 -2
- weed_cli-1.8.0/tests/test_discovery_relay.py +115 -0
- weed_cli-1.8.0/tests/test_node_manifest.py +91 -0
- weed_cli-1.8.0/tests/test_web_ui_api.py +127 -0
- weed_cli-1.8.0/tests/testutil.py +114 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/web_ui.py +61 -8
- {weed_cli-1.7.4 → weed_cli-1.8.0/weed_cli.egg-info}/PKG-INFO +2 -1
- {weed_cli-1.7.4 → weed_cli-1.8.0}/weed_cli.egg-info/SOURCES.txt +4 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/weed_cli.egg-info/requires.txt +1 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/LICENSE +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/README.md +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/dht.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/discovery_relay.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/lightning_settle.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/poc_reputation.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/setup.cfg +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/shell.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/tunnel_relay.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/weed.py +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-1.7.4 → weed_cli-1.8.0}/weed_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weed-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
|
|
@@ -20,6 +20,7 @@ Provides-Extra: dev
|
|
|
20
20
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
22
22
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-playwright>=0.5; extra == "dev"
|
|
23
24
|
Provides-Extra: dht
|
|
24
25
|
Requires-Dist: kademlia>=2.2; extra == "dht"
|
|
25
26
|
Provides-Extra: qr
|
|
@@ -232,7 +232,18 @@ def load_manifest_entries(archive_dir, file_name=None):
|
|
|
232
232
|
"""Every distinct file in the archive, not just one — find_manifest_entry
|
|
233
233
|
collapses to a single entries[-1], which is exactly why `host <dir>` with
|
|
234
234
|
no --file only ever served the single most-recently-added file out of a
|
|
235
|
-
45-video archive. Dedupes by name (last-write-wins, same convention).
|
|
235
|
+
45-video archive. Dedupes by name (last-write-wins, same convention).
|
|
236
|
+
|
|
237
|
+
Only 'video' entries are returned. Hosting depends on chunk data
|
|
238
|
+
(load_leaves) and per-chunk byte math (entry['chunk_size']), and ott
|
|
239
|
+
only ever writes either for video-type entries — everything else
|
|
240
|
+
(photos, or any file whose extension ott's is_video() doesn't
|
|
241
|
+
recognize, which is also where a plain .mp3 lands, since ott only has
|
|
242
|
+
two types) has chunk_size: None and no .ott/chunks/<hash>.json at all.
|
|
243
|
+
Filtering here, the one function every hosting path (weed.py,
|
|
244
|
+
shell.py, web_ui.py) goes through, means one non-video file sitting
|
|
245
|
+
in an archive_dir no longer poison-pills hosting everything else in
|
|
246
|
+
it with 'no chunks file at ...'."""
|
|
236
247
|
archive_dir = os.path.expanduser(archive_dir)
|
|
237
248
|
manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
|
|
238
249
|
if not os.path.exists(manifest_path):
|
|
@@ -244,8 +255,15 @@ def load_manifest_entries(archive_dir, file_name=None):
|
|
|
244
255
|
by_name = {}
|
|
245
256
|
for e in raw:
|
|
246
257
|
by_name[e['name']] = e
|
|
247
|
-
|
|
258
|
+
all_entries = list(by_name.values())
|
|
259
|
+
entries = [e for e in all_entries if e.get('type') == 'video']
|
|
248
260
|
if not entries:
|
|
261
|
+
if all_entries:
|
|
262
|
+
n = len(all_entries)
|
|
263
|
+
sys.exit(f"no hostable video file found in {archive_dir}" +
|
|
264
|
+
(f" matching {file_name}" if file_name else "") +
|
|
265
|
+
f" — found {n} non-video entr{'y' if n == 1 else 'ies'} "
|
|
266
|
+
f"(only video files can be hosted; see ott's is_video())")
|
|
249
267
|
sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
|
|
250
268
|
return entries
|
|
251
269
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "weed-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.0"
|
|
8
8
|
description = "Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -36,6 +36,15 @@ dev = [
|
|
|
36
36
|
"pytest>=8.0",
|
|
37
37
|
"pytest-cov>=5.0",
|
|
38
38
|
"ruff>=0.5",
|
|
39
|
+
# e2e (tests/e2e/) drives the real web UI in a real browser -- Python
|
|
40
|
+
# Playwright rather than the JS package, so `pip install -e .[dev]` is
|
|
41
|
+
# the only setup step; this repo otherwise has zero npm/node_modules
|
|
42
|
+
# footprint (web/ is vanilla JS, no build step -- see its own README
|
|
43
|
+
# note) and introducing one just for test tooling would be a bigger
|
|
44
|
+
# structural change than the tests themselves. Needs a Chromium binary
|
|
45
|
+
# too -- `playwright install chromium`, or point WEED_TEST_CHROMIUM at
|
|
46
|
+
# an existing one (see tests/e2e/conftest.py).
|
|
47
|
+
"pytest-playwright>=0.5",
|
|
39
48
|
]
|
|
40
49
|
dht = [
|
|
41
50
|
"kademlia>=2.2",
|
|
@@ -60,7 +69,7 @@ py-modules = [
|
|
|
60
69
|
]
|
|
61
70
|
|
|
62
71
|
[tool.pytest.ini_options]
|
|
63
|
-
testpaths = ["
|
|
72
|
+
testpaths = ["tests"]
|
|
64
73
|
python_files = ["test_*.py"]
|
|
65
74
|
python_functions = ["test_*"]
|
|
66
75
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
discovery_relay.py + node.discover()/publish()/unpublish() against a real
|
|
3
|
+
running relay (subprocess, real sockets) -- no mocking of HTTP or
|
|
4
|
+
signatures. Covers the actual trust model: relays store-and-forward
|
|
5
|
+
signed events and do nothing else, so "the network" only knows what a
|
|
6
|
+
client explicitly told a relay it happened to reach.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import node
|
|
11
|
+
from poc_reputation import Identity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_publish_then_discover_round_trip(relay):
|
|
15
|
+
identity = Identity('alice')
|
|
16
|
+
result = node.publish(identity, relay, content_hash='c' * 64, title='My Video',
|
|
17
|
+
host_addr='127.0.0.1:9201')
|
|
18
|
+
assert result.get('ok') is True
|
|
19
|
+
|
|
20
|
+
results = node.discover([relay])
|
|
21
|
+
assert len(results) == 1
|
|
22
|
+
assert results[0]['content_hash'] == 'c' * 64
|
|
23
|
+
assert results[0]['title'] == 'My Video'
|
|
24
|
+
assert results[0]['signer_pubkey'] == identity.pubkey_hex()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_tampered_event_rejected(relay):
|
|
28
|
+
"""The relay verifies signatures itself (node.py's own module
|
|
29
|
+
docstring: 'garbage in doesn't get stored') -- posting a payload that
|
|
30
|
+
doesn't match its signature must be refused, not silently stored."""
|
|
31
|
+
identity = Identity('alice')
|
|
32
|
+
event = identity.sign_event('publish', content_hash='c' * 64, title='Real Title',
|
|
33
|
+
host='127.0.0.1:9201', tunnel=None, ott_status=None)
|
|
34
|
+
event['payload']['title'] = 'Tampered Title' # mutate after signing
|
|
35
|
+
|
|
36
|
+
result = node.post_event(relay, event)
|
|
37
|
+
assert result.get('ok') is False
|
|
38
|
+
|
|
39
|
+
results = node.discover([relay])
|
|
40
|
+
assert results == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_unpublish_delists(relay):
|
|
44
|
+
identity = Identity('alice')
|
|
45
|
+
node.publish(identity, relay, content_hash='c' * 64, title='My Video', host_addr='127.0.0.1:9201')
|
|
46
|
+
assert len(node.discover([relay])) == 1
|
|
47
|
+
|
|
48
|
+
node.unpublish(identity, relay, content_hash='c' * 64)
|
|
49
|
+
assert node.discover([relay]) == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_republish_by_same_signer_replaces_not_duplicates(relay):
|
|
53
|
+
"""Re-running `host` always signs a fresh ts -- discover() must key on
|
|
54
|
+
(content_hash, signer_pubkey) and keep only the newest, not grow one
|
|
55
|
+
entry per re-announcement forever."""
|
|
56
|
+
identity = Identity('alice')
|
|
57
|
+
node.publish(identity, relay, content_hash='c' * 64, title='v1', host_addr='127.0.0.1:9201')
|
|
58
|
+
time.sleep(0.01)
|
|
59
|
+
node.publish(identity, relay, content_hash='c' * 64, title='v2', host_addr='127.0.0.1:9202')
|
|
60
|
+
|
|
61
|
+
results = node.discover([relay])
|
|
62
|
+
assert len(results) == 1
|
|
63
|
+
assert results[0]['title'] == 'v2'
|
|
64
|
+
assert results[0]['host'] == '127.0.0.1:9202'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_two_signers_same_content_both_show_up(relay):
|
|
68
|
+
"""Keyed on (content_hash, signer_pubkey), not content_hash alone --
|
|
69
|
+
two independent hosts of the same file are two separate listings."""
|
|
70
|
+
alice, bob = Identity('alice'), Identity('bob')
|
|
71
|
+
node.publish(alice, relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9201')
|
|
72
|
+
node.publish(bob, relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9202')
|
|
73
|
+
|
|
74
|
+
results = node.discover([relay])
|
|
75
|
+
assert {r['signer_pubkey'] for r in results} == {alice.pubkey_hex(), bob.pubkey_hex()}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_dead_relay_is_skipped_not_fatal(relay):
|
|
79
|
+
"""One relay in the list being unreachable must not lose events that
|
|
80
|
+
genuinely live on a *different*, healthy relay -- discover() degrades,
|
|
81
|
+
it doesn't fail closed."""
|
|
82
|
+
identity = Identity('alice')
|
|
83
|
+
node.publish(identity, relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9201')
|
|
84
|
+
|
|
85
|
+
dead_relay = 'http://127.0.0.1:1' # nothing listens here
|
|
86
|
+
results = node.discover([relay, dead_relay])
|
|
87
|
+
assert len(results) == 1
|
|
88
|
+
assert results[0]['content_hash'] == 'c' * 64
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_content_only_on_a_relay_you_dont_query_is_invisible(relay, tmp_path):
|
|
92
|
+
"""The actual answer to 'how long for full network coordination via
|
|
93
|
+
gossip': never, automatically -- relays never talk to each other.
|
|
94
|
+
Publishing to relay A and only ever querying relay B must not surface
|
|
95
|
+
the content, no matter what."""
|
|
96
|
+
import subprocess, sys, os
|
|
97
|
+
from testutil import free_port, wait_for_port
|
|
98
|
+
|
|
99
|
+
other_port = free_port()
|
|
100
|
+
env = dict(os.environ, WEED_RELAY_DATA=str(tmp_path / 'other_relay_events.jsonl'))
|
|
101
|
+
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
102
|
+
proc = subprocess.Popen([sys.executable, os.path.join(repo_root, 'discovery_relay.py'), str(other_port)],
|
|
103
|
+
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
104
|
+
try:
|
|
105
|
+
other_relay = f'http://127.0.0.1:{other_port}'
|
|
106
|
+
assert wait_for_port('127.0.0.1', other_port)
|
|
107
|
+
|
|
108
|
+
identity = Identity('alice')
|
|
109
|
+
node.publish(identity, other_relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9201')
|
|
110
|
+
|
|
111
|
+
assert node.discover([relay]) == [] # querying the *other* relay only
|
|
112
|
+
assert len(node.discover([other_relay])) == 1 # but it's really there
|
|
113
|
+
finally:
|
|
114
|
+
proc.terminate()
|
|
115
|
+
proc.wait(timeout=5)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
node.py's manifest/chunk-loading logic -- pure filesystem + JSON, no
|
|
3
|
+
network, no servers. Includes a regression test for the real incident
|
|
4
|
+
this session: an .mp3 in the same archive_dir as a hosted video crashed
|
|
5
|
+
`host` entirely (see node.load_manifest_entries's own docstring).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import node
|
|
12
|
+
from testutil import make_fake_archive
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_load_manifest_entries_single_video(tmp_path):
|
|
16
|
+
entry = make_fake_archive(tmp_path, name='good.mp4')
|
|
17
|
+
entries = node.load_manifest_entries(str(tmp_path))
|
|
18
|
+
assert [e['sha256'] for e in entries] == [entry['sha256']]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_load_manifest_entries_filters_non_video(tmp_path):
|
|
22
|
+
"""The actual bug: an mp3 (typed 'image' by ott's extension-based
|
|
23
|
+
is_video()) sitting in the same archive_dir used to poison-pill
|
|
24
|
+
hosting the whole directory -- host <dir> with no --file should just
|
|
25
|
+
skip it and host the real video."""
|
|
26
|
+
video = make_fake_archive(tmp_path, name='good.mp4')
|
|
27
|
+
make_fake_archive(tmp_path, name='song.mp3', video=False)
|
|
28
|
+
|
|
29
|
+
entries = node.load_manifest_entries(str(tmp_path))
|
|
30
|
+
assert [e['sha256'] for e in entries] == [video['sha256']]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_load_manifest_entries_explicit_non_video_file_errors_clearly(tmp_path):
|
|
34
|
+
make_fake_archive(tmp_path, name='good.mp4')
|
|
35
|
+
make_fake_archive(tmp_path, name='song.mp3', video=False)
|
|
36
|
+
|
|
37
|
+
with pytest.raises(SystemExit, match='no hostable video file found'):
|
|
38
|
+
node.load_manifest_entries(str(tmp_path), 'song.mp3')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_load_manifest_entries_no_manifest_at_all(tmp_path):
|
|
42
|
+
with pytest.raises(SystemExit, match='no .ott/manifest.jsonl'):
|
|
43
|
+
node.load_manifest_entries(str(tmp_path))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_load_manifest_entries_dedupes_by_name_last_write_wins(tmp_path):
|
|
47
|
+
"""Two manifest lines for the same file name (re-added after a real
|
|
48
|
+
edit) should collapse to the newer entry, not double-list it."""
|
|
49
|
+
os.makedirs(os.path.join(tmp_path, '.ott'), exist_ok=True)
|
|
50
|
+
manifest = os.path.join(tmp_path, '.ott', 'manifest.jsonl')
|
|
51
|
+
old = {'sha256': 'a' * 64, 'name': 'clip.mp4', 'orig_path': 'clip.mp4',
|
|
52
|
+
'last_path': str(tmp_path / 'clip.mp4'), 'size': 1, 'added': '2020-01-01T00:00:00Z',
|
|
53
|
+
'type': 'video', 'n_chunks': 1, 'chunk_size': 65536}
|
|
54
|
+
new = {**old, 'sha256': 'b' * 64, 'added': '2026-01-01T00:00:00Z'}
|
|
55
|
+
with open(manifest, 'w') as f:
|
|
56
|
+
f.write('%s\n%s\n' % (__import__('json').dumps(old), __import__('json').dumps(new)))
|
|
57
|
+
|
|
58
|
+
entries = node.load_manifest_entries(str(tmp_path))
|
|
59
|
+
assert len(entries) == 1
|
|
60
|
+
assert entries[0]['sha256'] == 'b' * 64
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_load_leaves_round_trips_real_chunks(tmp_path):
|
|
64
|
+
entry = make_fake_archive(tmp_path, name='good.mp4', size=200_000, chunk_size=65_536)
|
|
65
|
+
leaves = node.load_leaves(str(tmp_path), entry['sha256'])
|
|
66
|
+
assert len(leaves) == entry['n_chunks']
|
|
67
|
+
assert len(leaves) > 1 # 200_000 bytes / 65_536 chunk_size genuinely spans multiple chunks
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_load_leaves_missing_chunks_file_errors_clearly(tmp_path):
|
|
71
|
+
entry = make_fake_archive(tmp_path, name='good.mp4', video=False)
|
|
72
|
+
with pytest.raises(SystemExit, match='no chunks file at'):
|
|
73
|
+
node.load_leaves(str(tmp_path), entry['sha256'])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_resolve_file_path_prefers_last_path_when_it_exists(tmp_path):
|
|
77
|
+
entry = make_fake_archive(tmp_path, name='good.mp4')
|
|
78
|
+
resolved = node.resolve_file_path(entry, str(tmp_path))
|
|
79
|
+
assert resolved == entry['last_path']
|
|
80
|
+
assert os.path.exists(resolved)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_resolve_file_path_falls_back_when_last_path_is_stale(tmp_path):
|
|
84
|
+
"""last_path is recorded at archive time on whatever machine ran
|
|
85
|
+
`ott add` -- trusting it unconditionally breaks the moment archive_dir
|
|
86
|
+
is the same content mounted somewhere else (see node.py's own
|
|
87
|
+
docstring for the real Docker-bind-mount incident this guards)."""
|
|
88
|
+
entry = make_fake_archive(tmp_path, name='good.mp4')
|
|
89
|
+
entry['last_path'] = '/nonexistent/path/on/a/different/machine/good.mp4'
|
|
90
|
+
resolved = node.resolve_file_path(entry, str(tmp_path))
|
|
91
|
+
assert resolved == os.path.join(str(tmp_path), 'good.mp4')
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
web_ui.py's REST API against a real, isolated WebUIServer instance (see
|
|
3
|
+
conftest.web_server) -- real HTTP requests via the stdlib, same as the
|
|
4
|
+
rest of this codebase's own "no new dependency" convention. Every
|
|
5
|
+
identity/library/hosts file this touches is redirected into tmp_path by
|
|
6
|
+
the isolated_paths fixture web_server depends on; nothing here can ever
|
|
7
|
+
read or write a real ~/.weed_* file.
|
|
8
|
+
"""
|
|
9
|
+
from testutil import http_get_json, http_post_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_whoami_and_empty_library(web_server):
|
|
13
|
+
who = http_get_json(f'{web_server}/api/whoami')
|
|
14
|
+
assert len(who['pubkey']) == 64 # 32-byte Ed25519 pubkey, hex-encoded
|
|
15
|
+
|
|
16
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
17
|
+
assert lib == {'downloads': [], 'likes': [], 'subscriptions': [], 'playlists': [], 'history': []}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_like_is_idempotent(web_server):
|
|
21
|
+
status, resp = http_post_json(f'{web_server}/api/like', {'content_hash': 'c' * 64})
|
|
22
|
+
assert status == 200
|
|
23
|
+
for _ in range(3):
|
|
24
|
+
http_post_json(f'{web_server}/api/like', {'content_hash': 'c' * 64})
|
|
25
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
26
|
+
assert lib['likes'] == ['c' * 64]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_like_requires_content_hash(web_server):
|
|
30
|
+
status, resp = http_post_json(f'{web_server}/api/like', {})
|
|
31
|
+
assert status == 400
|
|
32
|
+
assert 'content_hash' in resp['error']
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_playlist_create_add_reorder_remove_delete(web_server):
|
|
36
|
+
status, resp = http_post_json(f'{web_server}/api/playlists/create', {'name': 'My List'})
|
|
37
|
+
assert status == 200
|
|
38
|
+
playlist_id = resp['playlist']['id']
|
|
39
|
+
|
|
40
|
+
for h in ('a' * 64, 'b' * 64):
|
|
41
|
+
status, resp = http_post_json(f'{web_server}/api/playlists/add', {
|
|
42
|
+
'playlist_id': playlist_id, 'content_hash': h, 'title': h[:8],
|
|
43
|
+
})
|
|
44
|
+
assert status == 200
|
|
45
|
+
|
|
46
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
47
|
+
pl = next(p for p in lib['playlists'] if p['id'] == playlist_id)
|
|
48
|
+
assert [it['content_hash'] for it in pl['items']] == ['a' * 64, 'b' * 64]
|
|
49
|
+
|
|
50
|
+
status, _ = http_post_json(f'{web_server}/api/playlists/reorder', {
|
|
51
|
+
'playlist_id': playlist_id, 'order': ['b' * 64, 'a' * 64],
|
|
52
|
+
})
|
|
53
|
+
assert status == 200
|
|
54
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
55
|
+
pl = next(p for p in lib['playlists'] if p['id'] == playlist_id)
|
|
56
|
+
assert [it['content_hash'] for it in pl['items']] == ['b' * 64, 'a' * 64]
|
|
57
|
+
|
|
58
|
+
status, _ = http_post_json(f'{web_server}/api/playlists/remove', {
|
|
59
|
+
'playlist_id': playlist_id, 'content_hash': 'a' * 64,
|
|
60
|
+
})
|
|
61
|
+
assert status == 200
|
|
62
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
63
|
+
pl = next(p for p in lib['playlists'] if p['id'] == playlist_id)
|
|
64
|
+
assert [it['content_hash'] for it in pl['items']] == ['b' * 64]
|
|
65
|
+
|
|
66
|
+
status, _ = http_post_json(f'{web_server}/api/playlists/delete', {'playlist_id': playlist_id})
|
|
67
|
+
assert status == 200
|
|
68
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
69
|
+
assert lib['playlists'] == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_play_requires_content_hash(web_server):
|
|
73
|
+
status, resp = http_post_json(f'{web_server}/api/play', {})
|
|
74
|
+
assert status == 400
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_play_bumps_count_and_history_for_a_known_download(web_server):
|
|
78
|
+
import web_ui
|
|
79
|
+
web_ui._library['downloads']['c' * 64] = {
|
|
80
|
+
'content_hash': 'c' * 64, 'job_id': 'job1', 'path': '/tmp/x.mp4',
|
|
81
|
+
'title': 'X', 'downloaded_at': 0, 'size': 1, 'bps': 1, 'signer_pubkey': None,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
status, resp = http_post_json(f'{web_server}/api/play', {'content_hash': 'c' * 64, 'title': 'X'})
|
|
85
|
+
assert status == 200
|
|
86
|
+
assert resp['play_count'] == 1
|
|
87
|
+
assert resp['last_played'] is not None
|
|
88
|
+
|
|
89
|
+
status, resp = http_post_json(f'{web_server}/api/play', {'content_hash': 'c' * 64, 'title': 'X'})
|
|
90
|
+
assert resp['play_count'] == 2
|
|
91
|
+
|
|
92
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
93
|
+
rec = next(d for d in lib['downloads'] if d['content_hash'] == 'c' * 64)
|
|
94
|
+
assert rec['play_count'] == 2
|
|
95
|
+
assert len(lib['history']) == 2
|
|
96
|
+
assert lib['history'][0]['content_hash'] == 'c' * 64 # newest first
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_play_for_unknown_content_hash_still_logs_history_but_no_count(web_server):
|
|
100
|
+
"""A play_count only exists on a downloads record -- playing something
|
|
101
|
+
that isn't (yet, or ever) in _library['downloads'] shouldn't 500, it
|
|
102
|
+
just can't report a play_count."""
|
|
103
|
+
status, resp = http_post_json(f'{web_server}/api/play', {'content_hash': 'z' * 64, 'title': 'Ghost'})
|
|
104
|
+
assert status == 200
|
|
105
|
+
assert resp['play_count'] is None
|
|
106
|
+
|
|
107
|
+
lib = http_get_json(f'{web_server}/api/library')
|
|
108
|
+
assert len(lib['history']) == 1
|
|
109
|
+
assert lib['history'][0]['title'] == 'Ghost'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_cross_origin_post_rejected(web_server):
|
|
113
|
+
"""No auth at all by design (see web_ui.py's module docstring) --
|
|
114
|
+
Origin-checking is the only thing standing between this and any other
|
|
115
|
+
open tab silently POSTing here. A request claiming a different Origin
|
|
116
|
+
must be refused."""
|
|
117
|
+
status, resp = http_post_json(f'{web_server}/api/like', {'content_hash': 'c' * 64},
|
|
118
|
+
headers={'Origin': 'http://evil.example'})
|
|
119
|
+
assert status == 403
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_request_with_no_origin_header_is_allowed(web_server):
|
|
123
|
+
"""curl / server-to-server / direct API use never sets Origin -- only
|
|
124
|
+
a real cross-site browser request does, so a missing header is let
|
|
125
|
+
through (see web_ui.py's _check_origin docstring)."""
|
|
126
|
+
status, resp = http_post_json(f'{web_server}/api/like', {'content_hash': 'd' * 64})
|
|
127
|
+
assert status == 200
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plain (non-fixture) test helpers, deliberately NOT named conftest.py --
|
|
3
|
+
pytest auto-imports every conftest.py it finds under a module name derived
|
|
4
|
+
from its path, and tests/e2e/conftest.py needing helpers from
|
|
5
|
+
tests/conftest.py hits a real name collision that way (both would want the
|
|
6
|
+
bare module name "conftest" under this repo's flat, __init__.py-less
|
|
7
|
+
layout). A uniquely-named module both conftest.py files can import from
|
|
8
|
+
sidesteps that entirely.
|
|
9
|
+
"""
|
|
10
|
+
import http.client
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from ott import chunk_hashes, merkle_root
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def free_port():
|
|
21
|
+
"""Ask the OS for a genuinely free port instead of guessing one and
|
|
22
|
+
racing every other test (and anything else on the machine) for it."""
|
|
23
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
24
|
+
s.bind(('127.0.0.1', 0))
|
|
25
|
+
port = s.getsockname()[1]
|
|
26
|
+
s.close()
|
|
27
|
+
return port
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def wait_for_port(host, port, timeout=5.0):
|
|
31
|
+
deadline = time.time() + timeout
|
|
32
|
+
while time.time() < deadline:
|
|
33
|
+
try:
|
|
34
|
+
with socket.create_connection((host, port), timeout=0.2):
|
|
35
|
+
return True
|
|
36
|
+
except OSError:
|
|
37
|
+
time.sleep(0.05)
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def http_get(url):
|
|
42
|
+
conn_host, conn_port, path = _split_url(url)
|
|
43
|
+
conn = http.client.HTTPConnection(conn_host, conn_port, timeout=5)
|
|
44
|
+
try:
|
|
45
|
+
conn.request('GET', path)
|
|
46
|
+
resp = conn.getresponse()
|
|
47
|
+
return resp.read().decode()
|
|
48
|
+
finally:
|
|
49
|
+
conn.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def http_get_json(url):
|
|
53
|
+
return json.loads(http_get(url))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def http_post_json(url, body, headers=None):
|
|
57
|
+
conn_host, conn_port, path = _split_url(url)
|
|
58
|
+
conn = http.client.HTTPConnection(conn_host, conn_port, timeout=5)
|
|
59
|
+
try:
|
|
60
|
+
h = {'Content-Type': 'application/json'}
|
|
61
|
+
h.update(headers or {})
|
|
62
|
+
conn.request('POST', path, body=json.dumps(body), headers=h)
|
|
63
|
+
resp = conn.getresponse()
|
|
64
|
+
return resp.status, json.loads(resp.read().decode())
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_url(url):
|
|
70
|
+
# http.client wants (host, port) and a bare path, not a full URL
|
|
71
|
+
assert url.startswith('http://')
|
|
72
|
+
rest = url[len('http://'):]
|
|
73
|
+
hostport, _, path = rest.partition('/')
|
|
74
|
+
host, _, port = hostport.partition(':')
|
|
75
|
+
return host, int(port), '/' + path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_fake_archive(archive_dir, name='clip.mp4', size=200_000, chunk_size=65_536, video=True):
|
|
79
|
+
"""Hand-writes a minimal but real .ott/manifest.jsonl + chunks file --
|
|
80
|
+
real sha256 chunk hashes and a real merkle root via the same ott
|
|
81
|
+
helpers node.py itself calls, just skipping ott's own add/stage/commit
|
|
82
|
+
workflow (irrelevant to what node.py/web_ui.py actually read: the
|
|
83
|
+
final manifest.jsonl + chunks/<hash>.json shape). video=False produces
|
|
84
|
+
an 'image'-typed entry with no chunks file, for regression-testing the
|
|
85
|
+
mp3/photo-mixed-into-an-archive fix (load_manifest_entries filtering
|
|
86
|
+
to video-only)."""
|
|
87
|
+
os.makedirs(archive_dir, exist_ok=True)
|
|
88
|
+
ott_dir = os.path.join(archive_dir, '.ott')
|
|
89
|
+
os.makedirs(os.path.join(ott_dir, 'chunks'), exist_ok=True)
|
|
90
|
+
file_path = os.path.join(archive_dir, name)
|
|
91
|
+
with open(file_path, 'wb') as f:
|
|
92
|
+
f.write(os.urandom(size))
|
|
93
|
+
|
|
94
|
+
if video:
|
|
95
|
+
chunks = chunk_hashes(file_path, chunk_size)
|
|
96
|
+
digest = merkle_root(chunks)
|
|
97
|
+
entry = {
|
|
98
|
+
'sha256': digest, 'name': name, 'orig_path': name, 'last_path': file_path,
|
|
99
|
+
'size': size, 'added': '2026-01-01T00:00:00Z', 'type': 'video',
|
|
100
|
+
'n_chunks': len(chunks), 'chunk_size': chunk_size,
|
|
101
|
+
}
|
|
102
|
+
with open(os.path.join(ott_dir, 'chunks', f'{digest}.json'), 'w') as f:
|
|
103
|
+
json.dump(chunks, f)
|
|
104
|
+
else:
|
|
105
|
+
digest = hashlib.sha256(open(file_path, 'rb').read()).hexdigest()
|
|
106
|
+
entry = {
|
|
107
|
+
'sha256': digest, 'name': name, 'orig_path': name, 'last_path': file_path,
|
|
108
|
+
'size': size, 'added': '2026-01-01T00:00:00Z', 'type': 'image',
|
|
109
|
+
'n_chunks': 1, 'chunk_size': None,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
with open(os.path.join(ott_dir, 'manifest.jsonl'), 'a') as f:
|
|
113
|
+
f.write(json.dumps(entry) + '\n')
|
|
114
|
+
return entry
|
|
@@ -45,6 +45,11 @@ WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
|
|
|
45
45
|
DEFAULT_RELAY = os.environ.get('WEED_RELAY', 'http://127.0.0.1:9101')
|
|
46
46
|
DEFAULT_TUNNEL = os.environ.get('WEED_TUNNEL')
|
|
47
47
|
LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
|
|
48
|
+
# play history is a log, not a set -- it grows forever otherwise (every
|
|
49
|
+
# playlist "next" and every re-watch appends). This caps ~/.weed_library.json
|
|
50
|
+
# from growing unbounded while still keeping far more history than anyone
|
|
51
|
+
# is realistically going to scroll back through.
|
|
52
|
+
MAX_HISTORY = 500
|
|
48
53
|
# every real POST body here is a handful of JSON fields (hashes, URLs,
|
|
49
54
|
# titles) -- no endpoint ever legitimately needs anywhere near this much,
|
|
50
55
|
# it's purely a cap against a client claiming a huge Content-Length and
|
|
@@ -89,7 +94,8 @@ class _JobStdout:
|
|
|
89
94
|
return getattr(self._real, name)
|
|
90
95
|
|
|
91
96
|
|
|
92
|
-
|
|
97
|
+
_job_stdout = _JobStdout(sys.stdout)
|
|
98
|
+
sys.stdout = _job_stdout
|
|
93
99
|
|
|
94
100
|
|
|
95
101
|
@contextlib.contextmanager
|
|
@@ -102,13 +108,27 @@ def _quiet():
|
|
|
102
108
|
(...)" lines (exactly the detail that explains *why* a download
|
|
103
109
|
failed) used to get captured here and then thrown away unread, so the
|
|
104
110
|
web UI only ever showed "no candidate host passed the possession
|
|
105
|
-
challenge" with zero indication of which candidate failed how.
|
|
111
|
+
challenge" with zero indication of which candidate failed how.
|
|
112
|
+
|
|
113
|
+
Re-asserts sys.stdout = _job_stdout on every call rather than trusting
|
|
114
|
+
it's still set from module-import time, and manipulates _job_stdout
|
|
115
|
+
directly rather than via sys.stdout -- something else with its own
|
|
116
|
+
reason to reassign sys.stdout globally afterward (pytest's own output
|
|
117
|
+
capturing is the concrete case that surfaced this: it swaps sys.stdout
|
|
118
|
+
to its own capture object between tests, silently detaching
|
|
119
|
+
_JobStdout from the global) would otherwise make this line crash with
|
|
120
|
+
an AttributeError on whatever replaced it, instead of muting output
|
|
121
|
+
like it's supposed to. Nothing in this app's own normal run path ever
|
|
122
|
+
reassigns sys.stdout again after the module-level line above, so this
|
|
123
|
+
is a no-op there -- purely a defensive re-assert."""
|
|
124
|
+
if sys.stdout is not _job_stdout:
|
|
125
|
+
sys.stdout = _job_stdout
|
|
106
126
|
buf = io.StringIO()
|
|
107
|
-
|
|
127
|
+
_job_stdout._local.buf = buf
|
|
108
128
|
try:
|
|
109
129
|
yield buf
|
|
110
130
|
finally:
|
|
111
|
-
|
|
131
|
+
_job_stdout._local.buf = None
|
|
112
132
|
|
|
113
133
|
|
|
114
134
|
def _with_captured_detail(msg, captured):
|
|
@@ -130,9 +150,12 @@ _lock = threading.Lock()
|
|
|
130
150
|
# business seeing how you've grouped your own downloads -- a list of
|
|
131
151
|
# {id, name, items: [{content_hash, title, signer_pubkey}, ...]}, id
|
|
132
152
|
# stable across renames so the UI can keep pointing at the same playlist
|
|
133
|
-
# while its name changes underneath it.
|
|
134
|
-
#
|
|
135
|
-
|
|
153
|
+
# while its name changes underneath it. history is a chronological log of
|
|
154
|
+
# plays (newest last), each {content_hash, title, played_at} -- separate
|
|
155
|
+
# from downloads' own play_count/last_played (see _handle_play) since
|
|
156
|
+
# those two are aggregates per content_hash, while this is the actual
|
|
157
|
+
# per-play timeline. All access goes through _lock, same as _hosts/_jobs.
|
|
158
|
+
_library = {'downloads': {}, 'likes': [], 'subscriptions': [], 'playlists': [], 'history': []}
|
|
136
159
|
|
|
137
160
|
# what to (re-)host on startup -- _hosts above is pure in-memory runtime
|
|
138
161
|
# state, so a restart (a fresh `make node`, a Docker container recreated
|
|
@@ -252,6 +275,7 @@ def _load_library():
|
|
|
252
275
|
'likes': data.get('likes') or [],
|
|
253
276
|
'subscriptions': data.get('subscriptions') or [],
|
|
254
277
|
'playlists': data.get('playlists') or [],
|
|
278
|
+
'history': data.get('history') or [],
|
|
255
279
|
}
|
|
256
280
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
257
281
|
pass
|
|
@@ -500,6 +524,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
500
524
|
'likes': list(_library['likes']),
|
|
501
525
|
'subscriptions': list(_library['subscriptions']),
|
|
502
526
|
'playlists': list(_library['playlists']),
|
|
527
|
+
# newest first -- the only order a "recently played" list
|
|
528
|
+
# is ever actually consumed in
|
|
529
|
+
'history': list(reversed(_library['history'])),
|
|
503
530
|
})
|
|
504
531
|
if path.startswith('/api/download/'):
|
|
505
532
|
job_id = path[len('/api/download/'):]
|
|
@@ -559,7 +586,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
559
586
|
handlers = {
|
|
560
587
|
'/api/host': self._handle_host, '/api/download': self._handle_download,
|
|
561
588
|
'/api/like': self._handle_like, '/api/subscribe': self._handle_subscribe,
|
|
562
|
-
'/api/verify': self._handle_verify,
|
|
589
|
+
'/api/verify': self._handle_verify, '/api/play': self._handle_play,
|
|
563
590
|
'/api/playlists/create': self._handle_playlist_create,
|
|
564
591
|
'/api/playlists/rename': self._handle_playlist_rename,
|
|
565
592
|
'/api/playlists/delete': self._handle_playlist_delete,
|
|
@@ -644,6 +671,32 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
644
671
|
_save_library()
|
|
645
672
|
self._json({'result': result})
|
|
646
673
|
|
|
674
|
+
def _handle_play(self, body):
|
|
675
|
+
"""Called once per openPlayer() on the frontend (Discover's ▶ Play,
|
|
676
|
+
a Downloads row, a playlist item, or onPlayerEnded's own auto-
|
|
677
|
+
advance) -- not inferred from /api/stream's byte-range requests,
|
|
678
|
+
which fire many times per single watch (seeking, buffering) and
|
|
679
|
+
would massively overcount. No relay/event involved, unlike
|
|
680
|
+
like/subscribe above -- what you've watched is purely local, same
|
|
681
|
+
reasoning as playlists' own docstring on why those aren't gossiped
|
|
682
|
+
either."""
|
|
683
|
+
content_hash = body.get('content_hash')
|
|
684
|
+
if not content_hash:
|
|
685
|
+
return self._json({'error': 'content_hash required'}, status=400)
|
|
686
|
+
now = time.time()
|
|
687
|
+
with _lock:
|
|
688
|
+
rec = _library['downloads'].get(content_hash)
|
|
689
|
+
title = body.get('title') or (rec.get('title') if rec else None)
|
|
690
|
+
play_count = None
|
|
691
|
+
if rec is not None:
|
|
692
|
+
rec['play_count'] = rec.get('play_count', 0) + 1
|
|
693
|
+
rec['last_played'] = now
|
|
694
|
+
play_count = rec['play_count']
|
|
695
|
+
_library['history'].append({'content_hash': content_hash, 'title': title, 'played_at': now})
|
|
696
|
+
_library['history'] = _library['history'][-MAX_HISTORY:]
|
|
697
|
+
_save_library()
|
|
698
|
+
self._json({'play_count': play_count, 'last_played': now})
|
|
699
|
+
|
|
647
700
|
def _handle_playlist_create(self, body):
|
|
648
701
|
name = (body.get('name') or '').strip()
|
|
649
702
|
if not name:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weed-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
|
|
@@ -20,6 +20,7 @@ Provides-Extra: dev
|
|
|
20
20
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
22
22
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-playwright>=0.5; extra == "dev"
|
|
23
24
|
Provides-Extra: dht
|
|
24
25
|
Requires-Dist: kademlia>=2.2; extra == "dht"
|
|
25
26
|
Provides-Extra: qr
|
|
@@ -10,6 +10,10 @@ shell.py
|
|
|
10
10
|
tunnel_relay.py
|
|
11
11
|
web_ui.py
|
|
12
12
|
weed.py
|
|
13
|
+
tests/test_discovery_relay.py
|
|
14
|
+
tests/test_node_manifest.py
|
|
15
|
+
tests/test_web_ui_api.py
|
|
16
|
+
tests/testutil.py
|
|
13
17
|
weed_cli.egg-info/PKG-INFO
|
|
14
18
|
weed_cli.egg-info/SOURCES.txt
|
|
15
19
|
weed_cli.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|