#!/usr/bin/env python3 """ Blossom Sync PoC — Nostr-native file sync using Blossom (BUD-01) + kind-1063 events. Built by Colony-0 for sats. MIT License. Features: - Watch a local folder for new/changed files - Upload to configurable Blossom server(s) - Publish kind-1063 file metadata events to configurable Nostr relays - Pull mode: fetch your kind-1063 events and download missing files - Configurable via config.json """ import json, os, sys, time, hashlib, mimetypes, argparse import requests CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json') DEFAULT_CONFIG = { "sync_folder": "~/blossom-sync", "blossom_servers": ["https://blossom.primal.net"], "relays": ["wss://nos.lol", "wss://relay.primal.net"], "nostr_privkey": "", "nostr_pubkey": "", "poll_interval_seconds": 30, "max_file_size_mb": 50 } def load_config(): if os.path.exists(CONFIG_FILE): with open(CONFIG_FILE) as f: cfg = json.load(f) # Merge with defaults for k, v in DEFAULT_CONFIG.items(): if k not in cfg: cfg[k] = v return cfg return DEFAULT_CONFIG.copy() def save_config(cfg): with open(CONFIG_FILE, 'w') as f: json.dump(cfg, f, indent=2) print(f"Config saved to {CONFIG_FILE}") def sha256_file(filepath): h = hashlib.sha256() with open(filepath, 'rb') as f: for chunk in iter(lambda: f.read(8192), b''): h.update(chunk) return h.hexdigest() def get_mime(filepath): mime, _ = mimetypes.guess_type(filepath) return mime or 'application/octet-stream' def sign_event(privkey_hex, pubkey_hex, kind, tags, content): """Sign a Nostr event (Schnorr/secp256k1).""" from coincurve import PrivateKey created_at = int(time.time()) serialized = json.dumps([0, pubkey_hex, created_at, kind, tags, content], separators=(',', ':'), ensure_ascii=False) event_id = hashlib.sha256(serialized.encode('utf-8')).hexdigest() sig = PrivateKey(bytes.fromhex(privkey_hex)).sign_schnorr(bytes.fromhex(event_id)).hex() return { 'id': event_id, 'pubkey': pubkey_hex, 'created_at': created_at, 'kind': kind, 'tags': tags, 'content': content, 'sig': sig } def publish_event(event, relays): """Publish event to multiple relays.""" import websocket results = [] for relay in relays: try: ws = websocket.create_connection(relay, timeout=10) ws.send(json.dumps(['EVENT', event])) resp = ws.recv() ws.close() ok = json.loads(resp) results.append((relay, ok[1] if len(ok) > 1 else 'unknown', True)) except Exception as e: results.append((relay, str(e), False)) return results def blossom_upload(filepath, servers, privkey_hex, pubkey_hex): """Upload file to Blossom server(s) per BUD-02.""" file_hash = sha256_file(filepath) mime = get_mime(filepath) size = os.path.getsize(filepath) filename = os.path.basename(filepath) uploaded_urls = [] for server in servers: try: # BUD-02: PUT /upload with auth header # Create auth event (kind 24242) auth_tags = [ ['t', 'upload'], ['x', file_hash], ['expiration', str(int(time.time()) + 300)] ] auth_event = sign_event(privkey_hex, pubkey_hex, 24242, auth_tags, f"Upload {filename}") auth_b64 = __import__('base64').b64encode(json.dumps(auth_event).encode()).decode() headers = { 'Authorization': f'Nostr {auth_b64}', 'Content-Type': mime, } with open(filepath, 'rb') as f: resp = requests.put( f"{server.rstrip('/')}/upload", data=f, headers=headers, timeout=60 ) if resp.status_code in (200, 201): data = resp.json() url = data.get('url', f"{server}/{file_hash}") uploaded_urls.append(url) print(f" ✅ Uploaded to {server}: {url}") else: print(f" ❌ {server}: {resp.status_code} {resp.text[:100]}") except Exception as e: print(f" ❌ {server}: {e}") return file_hash, mime, size, uploaded_urls def publish_file_metadata(file_hash, filename, mime, size, urls, relays, privkey_hex, pubkey_hex): """Publish kind-1063 file metadata event.""" tags = [ ['x', file_hash], ['m', mime], ['size', str(size)], ['filename', filename], ] for url in urls: tags.append(['url', url]) content = filename event = sign_event(privkey_hex, pubkey_hex, 1063, tags, content) results = publish_event(event, relays) for relay, info, ok in results: status = '✅' if ok else '❌' print(f" {status} Published kind-1063 to {relay}") return event['id'] def load_state(sync_folder): state_file = os.path.join(sync_folder, '.blossom_sync_state.json') if os.path.exists(state_file): with open(state_file) as f: return json.load(f) return {'synced_files': {}} def save_state(sync_folder, state): state_file = os.path.join(sync_folder, '.blossom_sync_state.json') with open(state_file, 'w') as f: json.dump(state, f, indent=2) def cmd_push(cfg): """Scan folder, upload new/changed files, publish events.""" sync_folder = os.path.expanduser(cfg['sync_folder']) os.makedirs(sync_folder, exist_ok=True) state = load_state(sync_folder) max_size = cfg['max_file_size_mb'] * 1024 * 1024 files = [] for root, dirs, filenames in os.walk(sync_folder): # Skip hidden dirs dirs[:] = [d for d in dirs if not d.startswith('.')] for fn in filenames: if fn.startswith('.'): continue filepath = os.path.join(root, fn) files.append(filepath) new_count = 0 for filepath in files: size = os.path.getsize(filepath) if size > max_size: print(f"⏭️ Skipping {filepath} ({size/1024/1024:.1f}MB > {cfg['max_file_size_mb']}MB)") continue file_hash = sha256_file(filepath) rel_path = os.path.relpath(filepath, sync_folder) if rel_path in state['synced_files'] and state['synced_files'][rel_path]['hash'] == file_hash: continue print(f"\n📤 Uploading: {rel_path}") file_hash, mime, size, urls = blossom_upload( filepath, cfg['blossom_servers'], cfg['nostr_privkey'], cfg['nostr_pubkey'] ) if urls: event_id = publish_file_metadata( file_hash, os.path.basename(filepath), mime, size, urls, cfg['relays'], cfg['nostr_privkey'], cfg['nostr_pubkey'] ) state['synced_files'][rel_path] = { 'hash': file_hash, 'event_id': event_id, 'urls': urls, 'synced_at': int(time.time()) } new_count += 1 else: print(f" ⚠️ No successful uploads for {rel_path}") save_state(sync_folder, state) print(f"\n✅ Push complete. {new_count} new files synced. {len(state['synced_files'])} total tracked.") def cmd_pull(cfg): """Fetch kind-1063 events and download missing files.""" import websocket sync_folder = os.path.expanduser(cfg['sync_folder']) os.makedirs(sync_folder, exist_ok=True) state = load_state(sync_folder) # Fetch all kind-1063 events from our pubkey all_events = [] for relay in cfg['relays']: try: ws = websocket.create_connection(relay, timeout=15) filt = json.dumps(['REQ', 'pull', { 'kinds': [1063], 'authors': [cfg['nostr_pubkey']], 'limit': 500 }]) ws.send(filt) for _ in range(600): try: r = ws.recv() d = json.loads(r) if d[0] == 'EOSE': break if d[0] == 'EVENT': all_events.append(d[2]) except: break ws.close() print(f"📡 {relay}: {len(all_events)} file events") break # One relay is enough except Exception as e: print(f"❌ {relay}: {e}") # Deduplicate by event id seen = set() events = [] for e in all_events: if e['id'] not in seen: seen.add(e['id']) events.append(e) downloaded = 0 for event in events: tags = {t[0]: t[1] for t in event['tags'] if len(t) >= 2} filename = tags.get('filename', tags.get('x', 'unknown')[:12]) file_hash = tags.get('x', '') urls = [t[1] for t in event['tags'] if t[0] == 'url'] filepath = os.path.join(sync_folder, filename) # Skip if already exists with correct hash if os.path.exists(filepath) and sha256_file(filepath) == file_hash: continue print(f"\n📥 Downloading: {filename}") for url in urls: try: resp = requests.get(url, timeout=30) if resp.status_code == 200: # Verify hash dl_hash = hashlib.sha256(resp.content).hexdigest() if dl_hash == file_hash: with open(filepath, 'wb') as f: f.write(resp.content) print(f" ✅ Downloaded from {url}") downloaded += 1 break else: print(f" ⚠️ Hash mismatch from {url}") else: print(f" ❌ {url}: HTTP {resp.status_code}") except Exception as e: print(f" ❌ {url}: {e}") print(f"\n✅ Pull complete. {downloaded} files downloaded. {len(events)} total in index.") def cmd_watch(cfg): """Watch folder and auto-push on changes.""" sync_folder = os.path.expanduser(cfg['sync_folder']) os.makedirs(sync_folder, exist_ok=True) interval = cfg['poll_interval_seconds'] print(f"👁️ Watching {sync_folder} (poll every {interval}s). Ctrl+C to stop.") last_state = {} while True: try: current = {} for root, dirs, filenames in os.walk(sync_folder): dirs[:] = [d for d in dirs if not d.startswith('.')] for fn in filenames: if fn.startswith('.'): continue fp = os.path.join(root, fn) current[fp] = os.path.getmtime(fp) changed = False for fp, mtime in current.items(): if fp not in last_state or last_state[fp] != mtime: changed = True break if changed: print(f"\n🔄 Changes detected at {time.strftime('%H:%M:%S')}") cmd_push(cfg) last_state = current time.sleep(interval) except KeyboardInterrupt: print("\n👋 Watch stopped.") break def cmd_status(cfg): """Show sync status.""" sync_folder = os.path.expanduser(cfg['sync_folder']) state = load_state(sync_folder) print(f"📁 Sync folder: {sync_folder}") print(f"🌸 Blossom servers: {', '.join(cfg['blossom_servers'])}") print(f"📡 Relays: {', '.join(cfg['relays'])}") print(f"📊 Tracked files: {len(state['synced_files'])}") for rel_path, info in state['synced_files'].items(): synced = time.strftime('%Y-%m-%d %H:%M', time.gmtime(info['synced_at'])) print(f" • {rel_path} (synced: {synced}, hash: {info['hash'][:12]}...)") def cmd_init(cfg): """Initialize config interactively.""" print("🌸 Blossom Sync — Setup\n") cfg['sync_folder'] = input(f"Sync folder [{cfg['sync_folder']}]: ").strip() or cfg['sync_folder'] servers = input(f"Blossom servers (comma-sep) [{','.join(cfg['blossom_servers'])}]: ").strip() if servers: cfg['blossom_servers'] = [s.strip() for s in servers.split(',')] relays = input(f"Nostr relays (comma-sep) [{','.join(cfg['relays'])}]: ").strip() if relays: cfg['relays'] = [r.strip() for r in relays.split(',')] cfg['nostr_privkey'] = input("Nostr private key (hex): ").strip() or cfg['nostr_privkey'] if cfg['nostr_privkey'] and not cfg['nostr_pubkey']: try: from coincurve import PrivateKey pub = PrivateKey(bytes.fromhex(cfg['nostr_privkey'])).public_key cfg['nostr_pubkey'] = pub.format(compressed=True)[1:].hex() print(f"Derived pubkey: {cfg['nostr_pubkey']}") except: cfg['nostr_pubkey'] = input("Nostr public key (hex): ").strip() save_config(cfg) os.makedirs(os.path.expanduser(cfg['sync_folder']), exist_ok=True) print("\n✅ Setup complete! Try: python blossom_sync.py push") def main(): parser = argparse.ArgumentParser(description='Blossom Sync — Nostr-native file sync') parser.add_argument('command', choices=['init', 'push', 'pull', 'watch', 'status'], help='Command to run') args = parser.parse_args() cfg = load_config() commands = { 'init': cmd_init, 'push': cmd_push, 'pull': cmd_pull, 'watch': cmd_watch, 'status': cmd_status, } commands[args.command](cfg) if __name__ == '__main__': main()