weed-cli 1.2.7__tar.gz → 1.3.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.2.7 → weed_cli-1.3.0}/PKG-INFO +1 -1
- {weed_cli-1.2.7 → weed_cli-1.3.0}/discovery_relay.py +51 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/node.py +32 -8
- {weed_cli-1.2.7 → weed_cli-1.3.0}/pyproject.toml +1 -1
- {weed_cli-1.2.7 → weed_cli-1.3.0}/tunnel_relay.py +64 -10
- {weed_cli-1.2.7 → weed_cli-1.3.0}/web_ui.py +44 -5
- {weed_cli-1.2.7 → weed_cli-1.3.0}/weed_cli.egg-info/PKG-INFO +1 -1
- {weed_cli-1.2.7 → weed_cli-1.3.0}/LICENSE +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/README.md +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/dht.py +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/lightning_settle.py +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/poc_reputation.py +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/setup.cfg +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/shell.py +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/weed.py +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/weed_cli.egg-info/SOURCES.txt +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.0}/weed_cli.egg-info/requires.txt +0 -0
- {weed_cli-1.2.7 → weed_cli-1.3.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.3.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
|
|
@@ -41,6 +41,22 @@ DATA_PATH = os.environ.get('WEED_RELAY_DATA',
|
|
|
41
41
|
_events = []
|
|
42
42
|
_lock = threading.Lock()
|
|
43
43
|
|
|
44
|
+
# no real signed event (a handful of JSON fields: hashes, URLs, titles)
|
|
45
|
+
# is anywhere close to this; it's purely a cap against a client claiming
|
|
46
|
+
# a huge Content-Length and making this internet-facing server read an
|
|
47
|
+
# unbounded amount into memory
|
|
48
|
+
MAX_BODY_SIZE = 256 * 1024
|
|
49
|
+
|
|
50
|
+
# bounds one identity's storage footprint on this relay -- signer_pubkey
|
|
51
|
+
# is cryptographically bound to its event's signature (see
|
|
52
|
+
# verify_attestation), so it can't be spoofed to blame someone else's
|
|
53
|
+
# quota, and is genuinely what's exceeding the cap. Doesn't stop an
|
|
54
|
+
# attacker from generating a fresh keypair per event -- Ed25519 keygen is
|
|
55
|
+
# free -- but that's a fundamentally bigger attack (unlimited identities)
|
|
56
|
+
# than "spam with one key," and defending against it needs proof-of-
|
|
57
|
+
# work/stake, a bigger design decision than this PoC calls for.
|
|
58
|
+
MAX_EVENTS_PER_SIGNER = 200
|
|
59
|
+
|
|
44
60
|
|
|
45
61
|
def _load_events():
|
|
46
62
|
if not os.path.exists(DATA_PATH):
|
|
@@ -62,6 +78,33 @@ def _append_event(event):
|
|
|
62
78
|
f.write(json.dumps(event) + '\n')
|
|
63
79
|
|
|
64
80
|
|
|
81
|
+
def _rewrite_events_file():
|
|
82
|
+
"""Full rewrite instead of the usual cheap append -- only needed when
|
|
83
|
+
an entry was evicted from _events (see _evict_oldest_for_signer),
|
|
84
|
+
since dropping a line from a JSONL file means rewriting it."""
|
|
85
|
+
os.makedirs(os.path.dirname(DATA_PATH) or '.', exist_ok=True)
|
|
86
|
+
tmp = DATA_PATH + '.tmp'
|
|
87
|
+
with open(tmp, 'w') as f:
|
|
88
|
+
for event in _events:
|
|
89
|
+
f.write(json.dumps(event) + '\n')
|
|
90
|
+
os.replace(tmp, DATA_PATH)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _evict_oldest_for_signer(signer_pubkey):
|
|
94
|
+
"""Caller must hold _lock. Drops that signer's own oldest event once
|
|
95
|
+
they're at the cap, making room for the new one -- an active
|
|
96
|
+
publisher keeps working, they just eventually lose their own oldest
|
|
97
|
+
entries first, same trade as any keep-last-N retention policy."""
|
|
98
|
+
if not signer_pubkey:
|
|
99
|
+
return
|
|
100
|
+
mine = [e for e in _events if e['payload'].get('signer_pubkey') == signer_pubkey]
|
|
101
|
+
if len(mine) < MAX_EVENTS_PER_SIGNER:
|
|
102
|
+
return
|
|
103
|
+
oldest = min(mine, key=lambda e: e['payload'].get('ts', 0))
|
|
104
|
+
_events.remove(oldest)
|
|
105
|
+
_rewrite_events_file()
|
|
106
|
+
|
|
107
|
+
|
|
65
108
|
class RelayHandler(BaseHTTPRequestHandler):
|
|
66
109
|
def log_message(self, fmt, *args):
|
|
67
110
|
pass # quiet — poc_discovery.py prints what matters
|
|
@@ -70,6 +113,13 @@ class RelayHandler(BaseHTTPRequestHandler):
|
|
|
70
113
|
if self.path != '/event':
|
|
71
114
|
self.send_response(404); self.end_headers(); return
|
|
72
115
|
length = int(self.headers.get('Content-Length', 0))
|
|
116
|
+
if length > MAX_BODY_SIZE:
|
|
117
|
+
self.send_response(413)
|
|
118
|
+
self.send_header('Content-Type', 'application/json')
|
|
119
|
+
self.end_headers()
|
|
120
|
+
self.wfile.write(json.dumps({'ok': False,
|
|
121
|
+
'reason': f'body too large (max {MAX_BODY_SIZE} bytes)'}).encode())
|
|
122
|
+
return
|
|
73
123
|
try:
|
|
74
124
|
event = json.loads(self.rfile.read(length))
|
|
75
125
|
ok, reason = verify_attestation(event) # generic: works on any {payload, signature} blob
|
|
@@ -84,6 +134,7 @@ class RelayHandler(BaseHTTPRequestHandler):
|
|
|
84
134
|
eid = attestation_id(event)
|
|
85
135
|
with _lock:
|
|
86
136
|
if not any(attestation_id(e) == eid for e in _events):
|
|
137
|
+
_evict_oldest_for_signer(event['payload'].get('signer_pubkey'))
|
|
87
138
|
_events.append(event)
|
|
88
139
|
_append_event(event)
|
|
89
140
|
self.send_response(200)
|
|
@@ -347,18 +347,43 @@ def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, pri
|
|
|
347
347
|
DATA connection, dialed back to the relay on demand (NEWSTREAM), so
|
|
348
348
|
concurrent tunneled downloads don't block each other.
|
|
349
349
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
the
|
|
350
|
+
REGISTER gets one immediate OK/ERR reply (see below), then the
|
|
351
|
+
control connection sends nothing at all until the first real
|
|
352
|
+
NEWSTREAM — which can be minutes or hours if no one downloads in the
|
|
353
|
+
meantime. Real-world proxies in the middle (observed: Fly's own
|
|
354
|
+
edge) reset TCP connections that go idle for a few minutes, which
|
|
355
|
+
silently unregisters the host with no error on this end until the
|
|
356
|
+
next download attempt fails. A small periodic heartbeat keeps the
|
|
356
357
|
connection looking active; tunnel_relay.py's REGISTER loop already
|
|
357
358
|
discards anything it receives that isn't relevant to it (it only ever
|
|
358
|
-
checks for EOF), so this needs zero changes on the relay side.
|
|
359
|
+
checks for EOF), so this needs zero changes on the relay side.
|
|
360
|
+
|
|
361
|
+
The OK/ERR reply exists so a REGISTER for a token someone else
|
|
362
|
+
already holds an active registration for can be refused instead of
|
|
363
|
+
silently overwriting it — content_hash tokens are public (announced
|
|
364
|
+
via discover), so without this anyone could squat/hijack another
|
|
365
|
+
host's rendezvous slot for content they didn't actually publish."""
|
|
359
366
|
entries_by_hash = {entry['sha256']: (entry, leaves, file_path)}
|
|
360
367
|
ctrl = _connect_tunnel_socket(relay_host, relay_port, use_tls)
|
|
361
368
|
ctrl.sendall(f'REGISTER {token}\n'.encode())
|
|
369
|
+
# one LineReader for the whole connection, created before the first
|
|
370
|
+
# read and reused for the NEWSTREAM loop below -- creating a second
|
|
371
|
+
# one later would lose whatever extra bytes this first recv() also
|
|
372
|
+
# happened to pick up (LineReader buffers internally; a fresh
|
|
373
|
+
# instance starts with an empty buffer, discarding anything already
|
|
374
|
+
# read into the old one)
|
|
375
|
+
reader = LineReader(ctrl)
|
|
376
|
+
ack = reader.readline()
|
|
377
|
+
if ack != 'OK':
|
|
378
|
+
ctrl.close()
|
|
379
|
+
# tunnel_relay.py now refuses a REGISTER for a token someone
|
|
380
|
+
# else already holds an active registration for, instead of
|
|
381
|
+
# silently letting the new one steal it -- content_hash tokens
|
|
382
|
+
# are public (announced via discover), so this stops anyone from
|
|
383
|
+
# squatting/hijacking another host's rendezvous slot for content
|
|
384
|
+
# they didn't actually publish
|
|
385
|
+
sys.exit(f"tunnel relay at {relay_host}:{relay_port} rejected REGISTER for "
|
|
386
|
+
f"{token[:16]}...: {ack or '(connection closed)'}")
|
|
362
387
|
if not quiet:
|
|
363
388
|
tls_note = ' (tls)' if use_tls else ''
|
|
364
389
|
print(f"[tunnel] registered {token[:16]}... with relay {relay_host}:{relay_port}{tls_note}")
|
|
@@ -375,7 +400,6 @@ def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, pri
|
|
|
375
400
|
threading.Thread(target=send_heartbeats, daemon=True).start()
|
|
376
401
|
|
|
377
402
|
try:
|
|
378
|
-
reader = LineReader(ctrl)
|
|
379
403
|
while True:
|
|
380
404
|
line = reader.readline()
|
|
381
405
|
if not line:
|
|
@@ -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.3.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" }
|
|
@@ -18,6 +18,9 @@ raw pipe (CONNECT/DATA, once paired):
|
|
|
18
18
|
REGISTER <token> host -> relay, persistent control connection.
|
|
19
19
|
token is whatever the host wants to be found
|
|
20
20
|
under (node.py uses the archive's content_hash).
|
|
21
|
+
Replies OK, or ERR if another still-active
|
|
22
|
+
connection already holds this token (first
|
|
23
|
+
registration wins; no stealing an active one).
|
|
21
24
|
CONNECT <token> downloader -> relay, wants to reach that host.
|
|
22
25
|
Everything sent after this line IS the tunneled
|
|
23
26
|
protocol, not a relay command.
|
|
@@ -41,7 +44,7 @@ _pending_streams = {} # stream_id -> _Pairing (defined below)
|
|
|
41
44
|
_lock = threading.Lock()
|
|
42
45
|
|
|
43
46
|
|
|
44
|
-
def recv_line(sock):
|
|
47
|
+
def recv_line(sock, timeout=10, max_len=256):
|
|
45
48
|
"""Byte-at-a-time on purpose: CONNECT/DATA connections become a raw
|
|
46
49
|
byte pipe immediately after this first line, and the real client
|
|
47
50
|
(HostConnection.connect_via_tunnel) sends its next protocol command
|
|
@@ -49,14 +52,33 @@ def recv_line(sock):
|
|
|
49
52
|
those bytes along with the handshake line, leaving nothing for pipe()
|
|
50
53
|
to forward. One byte at a time guarantees this stops exactly at the
|
|
51
54
|
newline and never reads into what comes after. Only runs once per
|
|
52
|
-
connection (a ~20-40 byte line), not on the hot path.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
connection (a ~20-40 byte line), not on the hot path.
|
|
56
|
+
|
|
57
|
+
The read timeout and length cap are specific to this one line, not
|
|
58
|
+
the socket in general: a client that never sends a newline (or
|
|
59
|
+
trickles bytes in one at a time forever) would otherwise tie up this
|
|
60
|
+
thread's connection slot indefinitely, and daemon_threads has no cap
|
|
61
|
+
on how many of those can pile up. Neither limit costs a real client
|
|
62
|
+
anything — every genuine REGISTER/CONNECT/DATA line arrives
|
|
63
|
+
immediately and is ~20-40 bytes. The timeout is cleared again before
|
|
64
|
+
returning since callers keep using this same socket afterward:
|
|
65
|
+
REGISTER's control connection sits idle between real downloads for
|
|
66
|
+
as long as minutes or hours (see node.py's heartbeat), and pipe()
|
|
67
|
+
needs plain unbounded blocking reads once a connection becomes a raw
|
|
68
|
+
byte pipe."""
|
|
69
|
+
sock.settimeout(timeout)
|
|
70
|
+
try:
|
|
71
|
+
buf = bytearray()
|
|
72
|
+
while True:
|
|
73
|
+
b = sock.recv(1)
|
|
74
|
+
if not b or b == b'\n':
|
|
75
|
+
break
|
|
76
|
+
buf += b
|
|
77
|
+
if len(buf) > max_len:
|
|
78
|
+
raise ValueError(f'line exceeds {max_len} bytes')
|
|
79
|
+
return buf.decode().strip()
|
|
80
|
+
finally:
|
|
81
|
+
sock.settimeout(None)
|
|
60
82
|
|
|
61
83
|
|
|
62
84
|
def pipe(src, dst):
|
|
@@ -76,8 +98,31 @@ def pipe(src, dst):
|
|
|
76
98
|
|
|
77
99
|
|
|
78
100
|
def handle_register(conn, token, quiet):
|
|
101
|
+
"""token is a content_hash, which is public the moment a host
|
|
102
|
+
announces it (see discover()) -- refusing a second REGISTER while
|
|
103
|
+
the first is still active stops anyone else from squatting/
|
|
104
|
+
hijacking that host's rendezvous slot for content they didn't
|
|
105
|
+
publish. The entry only exists in _registrations while that
|
|
106
|
+
connection's own recv loop below is still blocked waiting on it, so
|
|
107
|
+
"still in the dict" and "still alive" are the same thing here; a
|
|
108
|
+
genuinely dead registration is already gone by the time this runs
|
|
109
|
+
again (see the `finally` cleanup)."""
|
|
79
110
|
with _lock:
|
|
111
|
+
if token in _registrations:
|
|
112
|
+
try:
|
|
113
|
+
conn.sendall(b'ERR token already registered\n')
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
conn.close()
|
|
117
|
+
return
|
|
80
118
|
_registrations[token] = conn
|
|
119
|
+
try:
|
|
120
|
+
conn.sendall(b'OK\n')
|
|
121
|
+
except OSError:
|
|
122
|
+
with _lock:
|
|
123
|
+
if _registrations.get(token) is conn:
|
|
124
|
+
del _registrations[token]
|
|
125
|
+
return
|
|
81
126
|
if not quiet:
|
|
82
127
|
print(f"[tunnel] {token[:16]}... registered", flush=True)
|
|
83
128
|
try:
|
|
@@ -165,7 +210,16 @@ def handle_data(conn, stream_id):
|
|
|
165
210
|
|
|
166
211
|
class TunnelHandler(socketserver.BaseRequestHandler):
|
|
167
212
|
def handle(self):
|
|
168
|
-
|
|
213
|
+
try:
|
|
214
|
+
line = recv_line(self.request)
|
|
215
|
+
except (socket.timeout, ValueError, OSError):
|
|
216
|
+
# a client that never sent a line, sent one too slowly, or
|
|
217
|
+
# sent one too long -- recv_line's own timeout/length cap
|
|
218
|
+
# already protects the server from this; it's routine, not
|
|
219
|
+
# a bug, so close quietly instead of socketserver logging a
|
|
220
|
+
# full traceback for it
|
|
221
|
+
self.request.close()
|
|
222
|
+
return
|
|
169
223
|
parts = line.split()
|
|
170
224
|
if len(parts) < 2:
|
|
171
225
|
self.request.close()
|
|
@@ -37,6 +37,11 @@ except ImportError:
|
|
|
37
37
|
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
|
|
38
38
|
DEFAULT_RELAY = 'http://127.0.0.1:9101'
|
|
39
39
|
LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
|
|
40
|
+
# every real POST body here is a handful of JSON fields (hashes, URLs,
|
|
41
|
+
# titles) -- no endpoint ever legitimately needs anywhere near this much,
|
|
42
|
+
# it's purely a cap against a client claiming a huge Content-Length and
|
|
43
|
+
# making the server read an unbounded amount into memory
|
|
44
|
+
MAX_BODY_SIZE = 1024 * 1024
|
|
40
45
|
|
|
41
46
|
# script-relative, not CWD-relative -- same reasoning as WEB_DIR above, so
|
|
42
47
|
# downloads land in the same place regardless of the directory this was
|
|
@@ -137,7 +142,7 @@ def _rehydrate_jobs_from_library():
|
|
|
137
142
|
'status': 'done', 'idx': 0, 'n_chunks': None,
|
|
138
143
|
'content_hash': content_hash, 'path': rec['path'],
|
|
139
144
|
'title': rec.get('title'), 'size': rec.get('size'), 'bps': rec.get('bps'),
|
|
140
|
-
'error': None,
|
|
145
|
+
'signer_pubkey': rec.get('signer_pubkey'), 'error': None,
|
|
141
146
|
}
|
|
142
147
|
_lan_url = None # set once in run_web_ui() -- the base URL a phone on the
|
|
143
148
|
# same LAN can actually reach this server at, or None if
|
|
@@ -211,7 +216,8 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
|
|
|
211
216
|
_hosts[host_id].update(status='error', error=f'{type(e).__name__}: {e}')
|
|
212
217
|
|
|
213
218
|
|
|
214
|
-
def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None
|
|
219
|
+
def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None,
|
|
220
|
+
signer_pubkey=None):
|
|
215
221
|
def on_progress(idx, n_chunks):
|
|
216
222
|
with _lock:
|
|
217
223
|
_jobs[job_id].update(idx=idx, n_chunks=n_chunks)
|
|
@@ -229,6 +235,7 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
|
|
|
229
235
|
_library['downloads'][content_hash] = {
|
|
230
236
|
'content_hash': content_hash, 'job_id': job_id, 'path': path,
|
|
231
237
|
'title': title, 'downloaded_at': time.time(), 'size': size, 'bps': bps,
|
|
238
|
+
'signer_pubkey': signer_pubkey,
|
|
232
239
|
}
|
|
233
240
|
_save_library()
|
|
234
241
|
except SystemExit as e:
|
|
@@ -266,6 +273,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
266
273
|
|
|
267
274
|
def _read_json_body(self):
|
|
268
275
|
length = int(self.headers.get('Content-Length', 0))
|
|
276
|
+
if length > MAX_BODY_SIZE:
|
|
277
|
+
raise ValueError(f'body too large ({length} bytes, max {MAX_BODY_SIZE})')
|
|
269
278
|
return json.loads(self.rfile.read(length)) if length else {}
|
|
270
279
|
|
|
271
280
|
def do_GET(self):
|
|
@@ -307,8 +316,30 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
307
316
|
return self._handle_qr(data)
|
|
308
317
|
self._serve_static(path)
|
|
309
318
|
|
|
319
|
+
def _check_origin(self):
|
|
320
|
+
"""This server has no auth at all (see module docstring) -- the
|
|
321
|
+
only thing stopping any webpage you happen to have open in the
|
|
322
|
+
same browser from POSTing here (start hosting an arbitrary local
|
|
323
|
+
directory, download attacker-chosen content to an
|
|
324
|
+
attacker-chosen path, like/subscribe as you) is confirming the
|
|
325
|
+
request actually came from this UI, not some other origin your
|
|
326
|
+
browser also has open. Browsers always set Origin on cross-origin
|
|
327
|
+
fetch/XHR and can't be told by page JS to fake it, so a mismatch
|
|
328
|
+
here means a real cross-site request; a request with no Origin at
|
|
329
|
+
all (curl, direct API use, older browsers on a same-origin form
|
|
330
|
+
post) is let through since it isn't the CSRF-from-another-tab
|
|
331
|
+
shape this defends against."""
|
|
332
|
+
origin = self.headers.get('Origin')
|
|
333
|
+
if not origin:
|
|
334
|
+
return True
|
|
335
|
+
host = self.headers.get('Host', '')
|
|
336
|
+
return origin in (f'http://{host}', f'https://{host}')
|
|
337
|
+
|
|
310
338
|
def do_POST(self):
|
|
311
339
|
path = urlparse(self.path).path
|
|
340
|
+
if not self._check_origin():
|
|
341
|
+
return self._json({'error': 'rejected: request Origin does not match this server — '
|
|
342
|
+
'looks like a cross-site request, not this UI'}, status=403)
|
|
312
343
|
try:
|
|
313
344
|
body = self._read_json_body()
|
|
314
345
|
except Exception as e:
|
|
@@ -356,13 +387,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
356
387
|
k = int(body.get('k') or 3)
|
|
357
388
|
use_lightning = bool(body.get('lightning'))
|
|
358
389
|
title = body.get('title')
|
|
390
|
+
signer_pubkey = body.get('signer_pubkey')
|
|
359
391
|
|
|
360
392
|
job_id = uuid.uuid4().hex[:12]
|
|
361
393
|
with _lock:
|
|
362
394
|
_jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
|
|
363
|
-
'content_hash': content_hash, 'path': None, 'title': title,
|
|
395
|
+
'content_hash': content_hash, 'path': None, 'title': title,
|
|
396
|
+
'signer_pubkey': signer_pubkey, 'error': None}
|
|
364
397
|
threading.Thread(target=_run_download_job,
|
|
365
|
-
args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title
|
|
398
|
+
args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title,
|
|
399
|
+
signer_pubkey),
|
|
366
400
|
daemon=True).start()
|
|
367
401
|
self._json({'job_id': job_id})
|
|
368
402
|
|
|
@@ -501,7 +535,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
501
535
|
path = '/index.html'
|
|
502
536
|
safe_path = os.path.normpath(path).lstrip('/')
|
|
503
537
|
full_path = os.path.join(WEB_DIR, safe_path)
|
|
504
|
-
|
|
538
|
+
# the trailing os.sep matters: without it, a sibling directory
|
|
539
|
+
# that happens to share WEB_DIR's name as a string prefix (e.g.
|
|
540
|
+
# "web-private") would also pass this check -- str.startswith
|
|
541
|
+
# doesn't know about path boundaries, only os.path.join does
|
|
542
|
+
web_dir_abs = os.path.abspath(WEB_DIR)
|
|
543
|
+
if not os.path.abspath(full_path).startswith(web_dir_abs + os.sep) \
|
|
505
544
|
or not os.path.isfile(full_path):
|
|
506
545
|
return self._json({'error': 'not found'}, status=404)
|
|
507
546
|
ctype, _ = mimetypes.guess_type(full_path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weed-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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
|
|
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
|