weed-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
web_ui.py ADDED
@@ -0,0 +1,444 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Local control UI for weed: a small stdlib JSON API (same tool
4
+ discovery_relay.py already uses — ThreadingHTTPServer, no new dependency)
5
+ plus a static frontend (web/), so hosting/discovering/downloading/
6
+ liking/subscribing don't require memorizing weed.py's CLI flags. Every
7
+ endpoint is a thin wrapper over the real node.py functions the CLI
8
+ already calls — no reimplementation of any protocol logic.
9
+
10
+ Binds 127.0.0.1 by default on purpose — this is a *local* control
11
+ surface, not something meant to face the internet, and there's no auth
12
+ built (same "reachability is on you" honesty --advertise-host's docs
13
+ already apply elsewhere in this repo). Pass --bind to expose it on a LAN
14
+ at your own risk.
15
+ """
16
+ import contextlib
17
+ import io
18
+ import json
19
+ import mimetypes
20
+ import os
21
+ import sys
22
+ import threading
23
+ import uuid
24
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
+ from urllib.parse import urlparse, parse_qs
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ import node
29
+
30
+ try:
31
+ import qrcode # pip install qrcode -- same package ott.py's own `ott qr` already uses
32
+ _HAS_QR = True
33
+ except ImportError:
34
+ _HAS_QR = False
35
+
36
+ WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
37
+ DEFAULT_RELAY = 'http://127.0.0.1:9101'
38
+
39
+
40
+ class _JobStdout:
41
+ """Replaces sys.stdout for this whole process so a background job
42
+ thread (host/download) can be muted without touching a single print()
43
+ in node.py -- those are correct and wanted when the same functions
44
+ run from weed.py or the interactive shell, where a human is actually
45
+ watching the terminal. They're just noise here: nothing reads this
46
+ process's stdout, and a job's real status already goes through
47
+ _jobs[job_id]/_hosts[host_id], which the API/frontend actually poll.
48
+
49
+ Routes by the *calling* thread, not a single global swap -- a
50
+ threading.local() flag, so two jobs running concurrently in different
51
+ threads (or a job running while the main thread prints its own
52
+ [web:PORT] startup line) can't clobber each other's output."""
53
+
54
+ def __init__(self, real):
55
+ self._real = real
56
+ self._local = threading.local()
57
+
58
+ def write(self, s):
59
+ buf = getattr(self._local, 'buf', None)
60
+ (buf if buf is not None else self._real).write(s)
61
+
62
+ def flush(self):
63
+ self._real.flush()
64
+
65
+ def __getattr__(self, name):
66
+ return getattr(self._real, name)
67
+
68
+
69
+ sys.stdout = _JobStdout(sys.stdout)
70
+
71
+
72
+ @contextlib.contextmanager
73
+ def _quiet():
74
+ """Mute node.py's CLI-oriented prints for the current thread only,
75
+ for the duration of the with-block."""
76
+ sys.stdout._local.buf = io.StringIO()
77
+ try:
78
+ yield
79
+ finally:
80
+ sys.stdout._local.buf = None
81
+
82
+ _hosts = {} # host_id -> dict describing an actively-hosted file
83
+ _jobs = {} # job_id -> dict describing a download's progress/result
84
+ _lock = threading.Lock()
85
+ _lan_url = None # set once in run_web_ui() -- the base URL a phone on the
86
+ # same LAN can actually reach this server at, or None if
87
+ # it can't (bound to 127.0.0.1). The client can't compute
88
+ # this itself: window.location.origin only reflects
89
+ # whatever address the *desktop* browser used to load the
90
+ # page, which is 127.0.0.1 the moment someone opens it via
91
+ # localhost -- exactly the bug this fixes.
92
+
93
+
94
+ def _identity():
95
+ return node.load_or_create_identity()
96
+
97
+
98
+ def _as_list(value, default):
99
+ if not value:
100
+ return default
101
+ return [value] if isinstance(value, str) else list(value)
102
+
103
+
104
+ def _detect_lan_ip():
105
+ """Best-effort outbound-facing LAN IP. UDP connect() here never sends a
106
+ packet -- it just makes the kernel pick a route -- but that's enough to
107
+ read back the interface address that route would use. Needed because
108
+ a startup/QR URL naming the literal 0.0.0.0 wildcard bind address is
109
+ useless: nothing can connect *to* 0.0.0.0 from another device."""
110
+ import socket
111
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
112
+ try:
113
+ s.connect(('8.8.8.8', 80))
114
+ return s.getsockname()[0]
115
+ except OSError:
116
+ return None
117
+ finally:
118
+ s.close()
119
+
120
+
121
+ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, advertise_host, tunnel):
122
+ try:
123
+ with _quiet():
124
+ identity = _identity()
125
+ entry = node.find_manifest_entry(archive_dir, file_name)
126
+ # fail fast, before announcing anything — a manifest entry with no
127
+ # matching chunk data would otherwise get announced to the relay
128
+ # and only fail later, deep in a background thread with no way
129
+ # for the UI to ever find out
130
+ leaves = node.load_leaves(archive_dir, entry['sha256'])
131
+ announced = []
132
+ for relay_url in relay_urls:
133
+ host_addr = f'{advertise_host}:{port}'
134
+ node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
135
+ tunnel=tunnel)
136
+ announced.append(relay_url)
137
+ with _lock:
138
+ _hosts[host_id].update(name=entry['name'], content_hash=entry['sha256'],
139
+ announced_on=announced, status='running')
140
+ if tunnel:
141
+ relay_host, relay_port, use_tls = node._parse_tunnel(tunnel)
142
+ expanded_dir = os.path.expanduser(archive_dir)
143
+ file_path = entry.get('last_path') or os.path.join(expanded_dir, entry['name'])
144
+ threading.Thread(target=node.run_host_tunnel,
145
+ args=(relay_host, relay_port, entry['sha256'], entry, leaves,
146
+ file_path, price),
147
+ kwargs={'use_tls': use_tls, 'quiet': True}, daemon=True).start()
148
+ node.run_host_server(archive_dir, file_name, port, quiet=True, price=price)
149
+ except SystemExit as e:
150
+ with _lock:
151
+ _hosts[host_id].update(status='error', error=str(e))
152
+ except Exception as e:
153
+ with _lock:
154
+ _hosts[host_id].update(status='error', error=f'{type(e).__name__}: {e}')
155
+
156
+
157
+ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning):
158
+ def on_progress(idx, n_chunks):
159
+ with _lock:
160
+ _jobs[job_id].update(idx=idx, n_chunks=n_chunks)
161
+
162
+ try:
163
+ with _quiet():
164
+ path = node.download_with_auction(content_hash, relay_urls, out_path=out_path, k=k,
165
+ use_lightning=use_lightning, on_progress=on_progress)
166
+ with _lock:
167
+ _jobs[job_id].update(status='done', path=path)
168
+ except SystemExit as e:
169
+ with _lock:
170
+ _jobs[job_id].update(status='error', error=str(e))
171
+ except Exception as e:
172
+ with _lock:
173
+ _jobs[job_id].update(status='error', error=f'{type(e).__name__}: {e}')
174
+
175
+
176
+ class Handler(BaseHTTPRequestHandler):
177
+ def log_message(self, fmt, *args):
178
+ pass # quiet — this is a local UI, not a service worth logging every hit for
179
+
180
+ def _json(self, obj, status=200):
181
+ body = json.dumps(obj).encode()
182
+ self.send_response(status)
183
+ self.send_header('Content-Type', 'application/json')
184
+ self.send_header('Content-Length', str(len(body)))
185
+ self.end_headers()
186
+ self.wfile.write(body)
187
+
188
+ def _read_json_body(self):
189
+ length = int(self.headers.get('Content-Length', 0))
190
+ return json.loads(self.rfile.read(length)) if length else {}
191
+
192
+ def do_GET(self):
193
+ parsed = urlparse(self.path)
194
+ path, qs = parsed.path, parse_qs(parsed.query)
195
+
196
+ if path == '/api/whoami':
197
+ return self._json({'pubkey': _identity().pubkey_hex()})
198
+ if path == '/api/lan-url':
199
+ return self._json({'url': _lan_url})
200
+ if path == '/api/discover':
201
+ return self._json({'results': node.discover(qs.get('relay') or [DEFAULT_RELAY])})
202
+ if path == '/api/hosts':
203
+ with _lock:
204
+ return self._json({'hosts': list(_hosts.values())})
205
+ if path.startswith('/api/download/'):
206
+ with _lock:
207
+ job = _jobs.get(path[len('/api/download/'):])
208
+ return self._json(job) if job else self._json({'error': 'no such job'}, status=404)
209
+ if path.startswith('/api/reputation/'):
210
+ from poc_reputation import ReputationStore
211
+ pubkey = path[len('/api/reputation/'):]
212
+ reputation = ReputationStore(os.path.expanduser('~/.weed_reputation.json'))
213
+ score, why = reputation.trust_score(pubkey)
214
+ return self._json({'pubkey': pubkey, 'score': score, 'why': why})
215
+ if path.startswith('/api/stream/'):
216
+ return self._handle_stream(path[len('/api/stream/'):])
217
+ if path == '/api/qr':
218
+ data = (qs.get('data') or [''])[0]
219
+ if not data:
220
+ return self._json({'error': 'data query param required'}, status=400)
221
+ return self._handle_qr(data)
222
+ self._serve_static(path)
223
+
224
+ def do_POST(self):
225
+ path = urlparse(self.path).path
226
+ try:
227
+ body = self._read_json_body()
228
+ except Exception as e:
229
+ return self._json({'error': f'bad JSON body: {e}'}, status=400)
230
+
231
+ handlers = {
232
+ '/api/host': self._handle_host, '/api/download': self._handle_download,
233
+ '/api/like': self._handle_like, '/api/subscribe': self._handle_subscribe,
234
+ }
235
+ handler = handlers.get(path)
236
+ if not handler:
237
+ return self._json({'error': 'not found'}, status=404)
238
+ try:
239
+ handler(body)
240
+ except Exception as e:
241
+ self._json({'error': f'{type(e).__name__}: {e}'}, status=400)
242
+
243
+ def _handle_host(self, body):
244
+ archive_dir = body.get('archive_dir')
245
+ if not archive_dir:
246
+ return self._json({'error': 'archive_dir required'}, status=400)
247
+ port = int(body.get('port') or 9201)
248
+ price = int(body.get('price') or 0)
249
+ relay_urls = _as_list(body.get('relay'), [DEFAULT_RELAY])
250
+ advertise_host = body.get('advertise_host') or '127.0.0.1'
251
+ tunnel = body.get('tunnel') or None
252
+
253
+ host_id = uuid.uuid4().hex[:12]
254
+ with _lock:
255
+ _hosts[host_id] = {'id': host_id, 'archive_dir': archive_dir, 'port': port,
256
+ 'price': price, 'tunnel': tunnel, 'status': 'starting'}
257
+ threading.Thread(target=_run_host_job,
258
+ args=(host_id, archive_dir, body.get('file_name'), port, price,
259
+ relay_urls, advertise_host, tunnel),
260
+ daemon=True).start()
261
+ self._json({'host_id': host_id})
262
+
263
+ def _handle_download(self, body):
264
+ content_hash = body.get('content_hash')
265
+ if not content_hash:
266
+ return self._json({'error': 'content_hash required'}, status=400)
267
+ relay_urls = _as_list(body.get('relay'), [DEFAULT_RELAY])
268
+ out_path = body.get('out_path') or f'download_{content_hash[:16]}'
269
+ k = int(body.get('k') or 3)
270
+ use_lightning = bool(body.get('lightning'))
271
+
272
+ job_id = uuid.uuid4().hex[:12]
273
+ with _lock:
274
+ _jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
275
+ 'content_hash': content_hash, 'path': None, 'error': None}
276
+ threading.Thread(target=_run_download_job,
277
+ args=(job_id, content_hash, relay_urls, out_path, k, use_lightning),
278
+ daemon=True).start()
279
+ self._json({'job_id': job_id})
280
+
281
+ def _handle_like(self, body):
282
+ content_hash = body.get('content_hash')
283
+ if not content_hash:
284
+ return self._json({'error': 'content_hash required'}, status=400)
285
+ identity = _identity()
286
+ event = identity.sign_event('like', content_hash=content_hash)
287
+ self._json({'result': node.post_event(body.get('relay') or DEFAULT_RELAY, event)})
288
+
289
+ def _handle_subscribe(self, body):
290
+ target_pubkey = body.get('target_pubkey')
291
+ if not target_pubkey:
292
+ return self._json({'error': 'target_pubkey required'}, status=400)
293
+ identity = _identity()
294
+ event = identity.sign_event('subscribe', target_pubkey=target_pubkey)
295
+ self._json({'result': node.post_event(body.get('relay') or DEFAULT_RELAY, event)})
296
+
297
+ def _handle_stream(self, job_id):
298
+ """Serve an already-downloaded job's file with real HTTP range
299
+ support, so a <video> tag can seek/scrub instead of just
300
+ downloading the whole thing blind — the actual gap behind
301
+ 'play/open media': the web UI could already trigger a download,
302
+ but the bytes only ever landed on this server's disk, never made
303
+ it to the browser. This is a thin BaseHTTPRequestHandler, so
304
+ range parsing is done by hand rather than pulled in from a
305
+ framework — same "no new dependency" choice as the rest of this
306
+ file."""
307
+ with _lock:
308
+ job = _jobs.get(job_id)
309
+ if not job or job.get('status') != 'done' or not job.get('path'):
310
+ return self._json({'error': 'no completed download for that job id'}, status=404)
311
+ path = job['path']
312
+ if not os.path.isfile(path):
313
+ return self._json({'error': f'{path} no longer exists on disk'}, status=404)
314
+
315
+ file_size = os.path.getsize(path)
316
+ ctype, _ = mimetypes.guess_type(path)
317
+ ctype = ctype or 'application/octet-stream'
318
+
319
+ range_header = self.headers.get('Range')
320
+ if range_header:
321
+ try:
322
+ _, _, range_spec = range_header.partition('=')
323
+ start_s, _, end_s = range_spec.partition('-')
324
+ start = int(start_s) if start_s else 0
325
+ end = int(end_s) if end_s else file_size - 1
326
+ end = min(end, file_size - 1)
327
+ except ValueError:
328
+ return self._json({'error': f'malformed Range header: {range_header!r}'}, status=400)
329
+ if start > end or start >= file_size:
330
+ self.send_response(416)
331
+ self.send_header('Content-Range', f'bytes */{file_size}')
332
+ self.end_headers()
333
+ return
334
+ length = end - start + 1
335
+ self.send_response(206)
336
+ self.send_header('Content-Range', f'bytes {start}-{end}/{file_size}')
337
+ else:
338
+ start, length = 0, file_size
339
+ self.send_response(200)
340
+
341
+ self.send_header('Content-Type', ctype)
342
+ self.send_header('Accept-Ranges', 'bytes')
343
+ self.send_header('Content-Length', str(length))
344
+ self.end_headers()
345
+
346
+ with open(path, 'rb') as f:
347
+ f.seek(start)
348
+ remaining = length
349
+ while remaining > 0:
350
+ chunk = f.read(min(262144, remaining))
351
+ if not chunk:
352
+ break
353
+ try:
354
+ self.wfile.write(chunk)
355
+ except (BrokenPipeError, ConnectionResetError):
356
+ return # client seeked or closed mid-stream -- not an error
357
+ remaining -= len(chunk)
358
+
359
+ def _handle_qr(self, data):
360
+ """PNG QR code of an arbitrary string -- same qrcode package and
361
+ same 'encode a URL so a phone can scan instead of type it' idea as
362
+ ott's own `ott qr`, just rendered as an image for the page to
363
+ embed instead of printed as terminal ASCII."""
364
+ if not _HAS_QR:
365
+ return self._json({'error': 'qrcode not installed on the server -- pip install qrcode'},
366
+ status=501)
367
+ import io
368
+ qr = qrcode.QRCode(border=1)
369
+ qr.add_data(data)
370
+ qr.make(fit=True)
371
+ img = qr.make_image()
372
+ buf = io.BytesIO()
373
+ img.save(buf, format='PNG')
374
+ body = buf.getvalue()
375
+ self.send_response(200)
376
+ self.send_header('Content-Type', 'image/png')
377
+ self.send_header('Content-Length', str(len(body)))
378
+ self.end_headers()
379
+ self.wfile.write(body)
380
+
381
+ def _serve_static(self, path):
382
+ if path == '/':
383
+ path = '/index.html'
384
+ safe_path = os.path.normpath(path).lstrip('/')
385
+ full_path = os.path.join(WEB_DIR, safe_path)
386
+ if not os.path.abspath(full_path).startswith(os.path.abspath(WEB_DIR)) \
387
+ or not os.path.isfile(full_path):
388
+ return self._json({'error': 'not found'}, status=404)
389
+ ctype, _ = mimetypes.guess_type(full_path)
390
+ with open(full_path, 'rb') as f:
391
+ body = f.read()
392
+ self.send_response(200)
393
+ self.send_header('Content-Type', ctype or 'application/octet-stream')
394
+ self.send_header('Content-Length', str(len(body)))
395
+ self.end_headers()
396
+ self.wfile.write(body)
397
+
398
+
399
+ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False):
400
+ global _lan_url
401
+ srv = ThreadingHTTPServer((bind_host, port), Handler)
402
+
403
+ reachable_host = _detect_lan_ip() if bind_host == '0.0.0.0' else bind_host
404
+ if bind_host != '127.0.0.1' and reachable_host:
405
+ _lan_url = f'http://{reachable_host}:{port}/'
406
+
407
+ if not quiet:
408
+ print(f"[web:{port}] weed control UI at http://{bind_host}:{port}/", flush=True)
409
+ if bind_host == '127.0.0.1':
410
+ print(" bound to localhost only -- pass --bind 0.0.0.0 to reach this from your "
411
+ "phone (and get a scan-to-open QR here)", flush=True)
412
+ elif _lan_url:
413
+ if _HAS_QR:
414
+ print(f" scan to open on your phone ({_lan_url}):", flush=True)
415
+ qr = qrcode.QRCode(border=1)
416
+ qr.add_data(_lan_url)
417
+ qr.make(fit=True)
418
+ qr.print_ascii(invert=True)
419
+ else:
420
+ print(f" scan-to-open URL: {_lan_url} (pip install qrcode for a terminal "
421
+ f"QR code)", flush=True)
422
+ else:
423
+ print(" couldn't detect a LAN IP -- phone QR codes in the web UI won't work "
424
+ "until you restart with reachable networking", flush=True)
425
+ srv.serve_forever()
426
+
427
+
428
+ def main():
429
+ import argparse
430
+ parser = argparse.ArgumentParser(description='weed local control UI')
431
+ parser.add_argument('port', nargs='?', type=int, default=8080,
432
+ help='port to listen on (default: 8080)')
433
+ parser.add_argument('--port', dest='port_flag', type=int,
434
+ help='same as the positional port arg, --port form')
435
+ parser.add_argument('--bind', default='127.0.0.1',
436
+ help='bind address (default: 127.0.0.1, local only -- no auth is '
437
+ 'built, so only widen this on a network you trust)')
438
+ args = parser.parse_args()
439
+ port = args.port_flag if args.port_flag is not None else args.port
440
+ run_web_ui(port, bind_host=args.bind)
441
+
442
+
443
+ if __name__ == '__main__':
444
+ main()
weed.py ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ weed — single CLI entry point for this repo's PoC mechanisms.
4
+
5
+ Two kinds of subcommand:
6
+
7
+ - demo/network/stats/... — thin argparse wrapper over the Makefile, not a
8
+ reimplementation of it. The Makefile stays the single source of truth
9
+ for what each demo actually runs; this just gives it subcommands and
10
+ `--help` text instead of needing to remember `make` targets.
11
+
12
+ - host/discover/download/like/subscribe/whoami — real commands, not demo
13
+ wrappers. These call directly into node.py and actually host a file,
14
+ actually download one from a peer with per-chunk verification, actually
15
+ post/read signed events from a relay. This is the integration piece —
16
+ one tool, not six disconnected scripts.
17
+ """
18
+ import argparse
19
+ import os
20
+ import subprocess
21
+ import sys
22
+
23
+ REPO_DIR = os.path.dirname(os.path.abspath(__file__))
24
+ sys.path.insert(0, REPO_DIR)
25
+
26
+ COMMANDS = {
27
+ 'demo': ('demo', 'possession-gated auction, narrated (Parts 1 + 2)'),
28
+ 'network': ('network', 'real sockets, single-shot challenge rounds, loopback'),
29
+ 'stats': ('stats', 'real sockets, repeated-challenge separation analysis'),
30
+ 'discovery': ('discovery', '3 real relays, personalized ranking, sybil test'),
31
+ 'reputation': ('reputation', 'signed attestations + revocation demo'),
32
+ 'chart': ('chart', 'regenerate the README separation chart'),
33
+ 'containers': ('containers', 'same challenge test, real docker containers'),
34
+ 'real-archive': ('real-archive', 'challenge mechanism against a real .ott video archive'),
35
+ 'clean': ('clean', 'remove __pycache__, tmp reputation stores'),
36
+ }
37
+
38
+ LIGHTNING_COMMANDS = {
39
+ 'up': ('lightning-up', 'start bitcoind + 2 LND nodes on regtest'),
40
+ 'down': ('lightning-down', 'tear down the lightning stack (drops chain state + wallets)'),
41
+ 'demo': ('lightning-demo', 'poc_challenge_auction.py --lightning — real HTLC settlement'),
42
+ 'smoke': ('lightning-smoke', 'one real test payment via lightning_settle.py'),
43
+ }
44
+
45
+
46
+ def run_make(target):
47
+ result = subprocess.run(['make', target], cwd=REPO_DIR)
48
+ sys.exit(result.returncode)
49
+
50
+
51
+ def build_parser():
52
+ parser = argparse.ArgumentParser(
53
+ prog='weed',
54
+ description='Censorship-resistant video PoC — real mechanisms behind the #all-pdx brainstorm.')
55
+ sub = parser.add_subparsers(dest='command', required=False)
56
+
57
+ sub.add_parser('shell', help='interactive shell with tab completion (also the default with no command)')
58
+
59
+ for name, (_target, help_text) in COMMANDS.items():
60
+ sub.add_parser(name, help=help_text)
61
+
62
+ lightning = sub.add_parser('lightning', help='real bitcoind + LND regtest stack (see lightning/README.md)')
63
+ lightning_sub = lightning.add_subparsers(dest='lightning_command', required=True)
64
+ for name, (_target, help_text) in LIGHTNING_COMMANDS.items():
65
+ lightning_sub.add_parser(name, help=help_text)
66
+
67
+ p_whoami = sub.add_parser('whoami', help='print your persistent node pubkey (~/.weed_identity.key)')
68
+
69
+ p_host = sub.add_parser('host', help='actually serve a real archived file to the real network')
70
+ p_host.add_argument('archive_dir', help='directory containing .ott/ (e.g. real_archive)')
71
+ p_host.add_argument('--file', help='which archived file, if more than one (default: most recent)')
72
+ p_host.add_argument('--port', type=int, default=9201)
73
+ p_host.add_argument('--price', type=int, default=0, help='sats to charge per download (default: free)')
74
+ p_host.add_argument('--relay', action='append', default=[], help='relay URL to announce on (repeatable)')
75
+ p_host.add_argument('--advertise-host', default='127.0.0.1',
76
+ help='address to tell the relay to advertise (set this to your real '
77
+ 'reachable IP if hosting off localhost — or use --tunnel if you '
78
+ 'have no reachable address at all, e.g. behind NAT/CGNAT)')
79
+ p_host.add_argument('--tunnel', metavar='[tls://]RELAY_HOST:PORT',
80
+ help='tunnel_relay.py address to register with instead of relying on a '
81
+ 'reachable inbound port — see tunnel_relay.py. Downloaders connect '
82
+ 'through the relay, not to you directly. Prefix with tls:// if the '
83
+ 'relay terminates TLS at the edge (e.g. a Fly service with '
84
+ 'handlers = ["tls"]) — the relay process itself never needs to know')
85
+
86
+ p_discover = sub.add_parser('discover', help='list real content announced on one or more relays')
87
+ p_discover.add_argument('--relay', action='append', default=['http://127.0.0.1:9101'],
88
+ help='relay URL to query (repeatable)')
89
+
90
+ p_download = sub.add_parser('download', aliases=['get'],
91
+ help='discover, possession-challenge, auction, optionally pay, '
92
+ 'and download from the winning host — chunk-verified')
93
+ p_download.add_argument('content_hash', nargs='?', help='content hash to resolve via --relay')
94
+ p_download.add_argument('--from', dest='from_addr', help='host:port to connect to directly, skipping '
95
+ 'discovery/auction entirely (no possession challenge, no reputation, no payment)')
96
+ p_download.add_argument('--relay', action='append', default=['http://127.0.0.1:9101'],
97
+ help='relay URL to resolve content_hash against (repeatable)')
98
+ p_download.add_argument('--out', help='output path (default: the advertised filename)')
99
+ p_download.add_argument('--challenge-rounds', type=int, default=3,
100
+ help='chunks to sample-verify per candidate host before trusting it (default: 3)')
101
+ p_download.add_argument('--lightning', action='store_true',
102
+ help='pay the winning host over a real Lightning HTLC if it has a price '
103
+ '(needs the lightning/ stack up — see lightning/README.md)')
104
+
105
+ p_like = sub.add_parser('like', help='sign and post a real like event')
106
+ p_like.add_argument('content_hash')
107
+ p_like.add_argument('--relay', default='http://127.0.0.1:9101')
108
+
109
+ p_subscribe = sub.add_parser('subscribe', help='sign and post a real subscribe event')
110
+ p_subscribe.add_argument('target_pubkey')
111
+ p_subscribe.add_argument('--relay', default='http://127.0.0.1:9101')
112
+
113
+ p_web = sub.add_parser('web', help='local web UI — discover/host/download/like/subscribe '
114
+ 'from a browser instead of the CLI')
115
+ p_web.add_argument('--port', type=int, default=8080)
116
+ p_web.add_argument('--bind', default='127.0.0.1',
117
+ help='bind address (default 127.0.0.1 — local only; no auth is built, '
118
+ 'so only widen this on a network you trust)')
119
+
120
+ p_serve = sub.add_parser('serve', help='alias for "web" with positional args, '
121
+ 'e.g. `serve 0.0.0.0 8080`')
122
+ p_serve.add_argument('bind', nargs='?', default='127.0.0.1',
123
+ help='bind address (default 127.0.0.1; pass 0.0.0.0 to reach it '
124
+ 'from your phone — no auth is built, only widen this on a '
125
+ 'network you trust)')
126
+ p_serve.add_argument('port', nargs='?', type=int, default=8080)
127
+
128
+ return parser
129
+
130
+
131
+ def cmd_whoami(args):
132
+ import node
133
+ identity = node.load_or_create_identity()
134
+ print(identity.pubkey_hex())
135
+
136
+
137
+ def cmd_host(args):
138
+ import threading
139
+ import node
140
+ identity = node.load_or_create_identity()
141
+ entries = node.load_manifest_entries(args.archive_dir, args.file)
142
+ if args.tunnel and len(entries) > 1:
143
+ sys.exit(f"{len(entries)} files in {args.archive_dir} but --tunnel only serves one at a "
144
+ f"time — pass --file NAME to pick one")
145
+ # fail fast, before announcing anything — a manifest entry with no
146
+ # matching chunk data would otherwise get announced to the relay and
147
+ # only fail later, in the background server thread
148
+ all_leaves = {e['sha256']: node.load_leaves(args.archive_dir, e['sha256']) for e in entries}
149
+ for relay_url in args.relay:
150
+ host_addr = f'{args.advertise_host}:{args.port}'
151
+ for entry in entries:
152
+ result = node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
153
+ tunnel=args.tunnel)
154
+ print(f"announced {entry['name']} on {relay_url}: {result}")
155
+ if args.tunnel:
156
+ entry = entries[0]
157
+ relay_host, relay_port, use_tls = node._parse_tunnel(args.tunnel)
158
+ archive_dir = os.path.expanduser(args.archive_dir)
159
+ file_path = entry.get('last_path') or os.path.join(archive_dir, entry['name'])
160
+ threading.Thread(target=node.run_host_tunnel,
161
+ args=(relay_host, relay_port, entry['sha256'], entry,
162
+ all_leaves[entry['sha256']], file_path, args.price),
163
+ kwargs={'use_tls': use_tls}, daemon=True).start()
164
+ node.run_host_server(args.archive_dir, args.file, args.port, price=args.price)
165
+
166
+
167
+ def cmd_discover(args):
168
+ import node
169
+ results = node.discover(args.relay)
170
+ if not results:
171
+ print("nothing found (relay(s) unreachable, or nothing published yet)")
172
+ return
173
+ for r in results:
174
+ print(f" {r['title']!r:40s} hash={r['content_hash'][:16]}... host={r['host']} "
175
+ f"by={r['signer_pubkey'][:12]}...")
176
+
177
+
178
+ def cmd_download(args):
179
+ import node
180
+ if args.from_addr:
181
+ # skips discovery entirely, so no possession challenge, reputation,
182
+ # or auction happens — a deliberate escape hatch, not the normal path
183
+ out_path = args.out or f'download_{args.content_hash or "file"}'
184
+ node.download(args.from_addr, out_path)
185
+ return
186
+ if not args.content_hash:
187
+ sys.exit("need a content_hash (to resolve via --relay) or --from host:port")
188
+ node.download_with_auction(args.content_hash, args.relay, out_path=args.out,
189
+ k=args.challenge_rounds, use_lightning=args.lightning)
190
+
191
+
192
+ def cmd_like(args):
193
+ import node
194
+ identity = node.load_or_create_identity()
195
+ event = identity.sign_event('like', content_hash=args.content_hash)
196
+ print(node.post_event(args.relay, event))
197
+
198
+
199
+ def cmd_subscribe(args):
200
+ import node
201
+ identity = node.load_or_create_identity()
202
+ event = identity.sign_event('subscribe', target_pubkey=args.target_pubkey)
203
+ print(node.post_event(args.relay, event))
204
+
205
+
206
+ def cmd_web(args):
207
+ import web_ui
208
+ web_ui.run_web_ui(port=args.port, bind_host=args.bind)
209
+
210
+
211
+ NATIVE_COMMANDS = {
212
+ 'whoami': cmd_whoami, 'host': cmd_host, 'discover': cmd_discover,
213
+ 'download': cmd_download, 'get': cmd_download, 'like': cmd_like, 'subscribe': cmd_subscribe,
214
+ 'web': cmd_web, 'serve': cmd_web,
215
+ }
216
+
217
+
218
+ def main():
219
+ args = build_parser().parse_args()
220
+ if args.command is None or args.command == 'shell':
221
+ import shell
222
+ try:
223
+ shell.WeedShell().cmdloop()
224
+ except KeyboardInterrupt:
225
+ print()
226
+ return
227
+ if args.command in NATIVE_COMMANDS:
228
+ NATIVE_COMMANDS[args.command](args)
229
+ return
230
+ if args.command == 'lightning':
231
+ target, _ = LIGHTNING_COMMANDS[args.lightning_command]
232
+ else:
233
+ target, _ = COMMANDS[args.command]
234
+ run_make(target)
235
+
236
+
237
+ if __name__ == '__main__':
238
+ main()