diff --git a/README.md b/README.md index 1180227..3b735b0 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ voxvera quickstart | Field | Max | Where it appears | |---|---|---| | **name** | 60 chars | Browser tab title. Not printed on the flyer itself. | -| **subdomain** | 63 chars | Used internally as the folder name under `host/`. Lowercase letters, numbers, and hyphens only. | +| **folder_name** | 63 chars | Used internally as the folder name under `host/`. Lowercase letters, numbers, and hyphens only. | | **title** | 60 chars | Large heading at the top-right of the flyer (e.g. "TOP SECRET"). | | **subtitle** | 80 chars | Smaller text directly below the title (e.g. "DO NOT DISTRIBUTE"). Supports basic HTML like ``. | | **headline** | 80 chars | Second heading below the subtitle (e.g. "OPERATION VOX VERA"). | diff --git a/ci/test-e2e.sh b/ci/test-e2e.sh index 4642a0d..2d7d311 100755 --- a/ci/test-e2e.sh +++ b/ci/test-e2e.sh @@ -22,8 +22,8 @@ EOI "$voxvera_cmd" build # Verify build output exists in host/ -subdomain=$(python3 -c "import json; print(json.load(open('voxvera/src/config.json'))['subdomain'])") -host_dir="voxvera/host/$subdomain" +folder_name=$(python3 -c "import json; print(json.load(open('voxvera/src/config.json'))['folder_name'])") +host_dir="voxvera/host/$folder_name" ls -R "$host_dir" >>"$LOG_DIR/tree.txt" # Verify essential files diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4764112..e75be08 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -26,7 +26,7 @@ If no running Tor is found, the defaults (9050/9051) are used and OnionShare wil ## OnionShare crashes If `voxvera serve` prints "OnionShare exited unexpectedly", check the log file -`host//onionshare.log` for details. Common causes: +`host//onionshare.log` for details. Common causes: - Tor is not running (`sudo systemctl start tor`) - A port conflict with another service @@ -34,7 +34,7 @@ If `voxvera serve` prints "OnionShare exited unexpectedly", check the log file ## Onion URL changed unexpectedly The onion URL is tied to the Ed25519 keypair stored in -`host//.onionshare-session`. If this file is deleted, a new +`host//.onionshare-session`. If this file is deleted, a new keypair (and URL) will be generated on the next `voxvera serve`. The `voxvera import` command preserves this file automatically, but manual deletion of the `host/` directory will destroy it. diff --git a/docs/usage.md b/docs/usage.md index 62baf8a..cb5fda1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -36,7 +36,7 @@ All build dependencies (QR generation, HTML minification, PDF parsing) are Pytho ``` This automatically detects your Tor instance, starts OnionShare, and writes the generated .onion address into the flyer's tear-off links. QR codes are regenerated with the new address. -The generated `host/` directory contains all flyer files. The `index.html` file fetches `config.json` at runtime, so the flyer must be served via a web server (OnionShare handles this). Visitors can use the **Download** button to retrieve any bundled files. +The generated `host/` directory contains all flyer files. The `index.html` file fetches `config.json` at runtime, so the flyer must be served via a web server (OnionShare handles this). Visitors can use the **Download** button to retrieve any bundled files. ## How URLs Work @@ -55,7 +55,7 @@ Place configuration files in an `imports/` directory at the project root and run voxvera import ``` -Each JSON file is copied to `src/config.json` and processed with `voxvera build`. Existing folders under `host/` with the same subdomain are cleaned before new files are written. OnionShare session keys (`.onionshare-session`) are preserved so the onion URL stays the same across re-imports. +Each JSON file is copied to `src/config.json` and processed with `voxvera build`. Existing folders under `host/` with the same folder_name are cleaned before new files are written. OnionShare session keys (`.onionshare-session`) are preserved so the onion URL stays the same across re-imports. ## Hosting with OnionShare @@ -73,7 +73,7 @@ TOR_SOCKS_PORT=9150 TOR_CONTROL_PORT=9151 voxvera serve The command launches `onionshare-cli` in persistent website mode, waits for the generated onion URL, writes it into `config.json` as the tear-off link, regenerates QR codes, and copies updated files into the `host` directory. -The onion URL is derived from an Ed25519 keypair stored in `host//.onionshare-session`. As long as this file exists, the URL stays the same even if you rebuild with new content. Keep OnionShare running to continue hosting. +The onion URL is derived from an Ed25519 keypair stored in `host//.onionshare-session`. As long as this file exists, the URL stays the same even if you rebuild with new content. Keep OnionShare running to continue hosting. ## All-in-One diff --git a/gui/electron/index.html b/gui/electron/index.html index cf7d671..ee14c80 100644 --- a/gui/electron/index.html +++ b/gui/electron/index.html @@ -18,7 +18,7 @@

VoxVera

-
+
@@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', load); document.getElementById('quickstart').addEventListener('click', async () => { - const ids = ['name','subdomain','title','subtitle','headline','content','url_message','url','tear_off_link','binary_message']; + const ids = ['name','folder_name','title','subtitle','headline','content','url_message','url','tear_off_link','binary_message']; const cfg = {}; ids.forEach(id => { const el = document.getElementById(id); diff --git a/imports/aosp.json b/imports/aosp.json index ac7ed73..e0c30ee 100644 --- a/imports/aosp.json +++ b/imports/aosp.json @@ -1,6 +1,6 @@ { "name": "AOS Project", - "subdomain": "aosp", + "folder_name": "aosp", "title": "ANDROID UNLEASHED", "subtitle": "WITHOUT BOUNDARIES", "headline": "OPEN-SOURCE FREEDOM", diff --git a/imports/bitcoin.json b/imports/bitcoin.json index 3ae28e9..cd0910d 100644 --- a/imports/bitcoin.json +++ b/imports/bitcoin.json @@ -1,6 +1,6 @@ { "name": "Bitcoin Revolution Flyers", - "subdomain": "bitcoin", + "folder_name": "bitcoin", "title": "THE FUTURE OF MONEY", "subtitle": "DO NOT IGNORE", "headline": "JOIN THE BITCOIN REVOLUTION", diff --git a/imports/blank.json b/imports/blank.json index b11b7b2..4f543a0 100644 --- a/imports/blank.json +++ b/imports/blank.json @@ -1,6 +1,6 @@ { "name": "", - "subdomain": "", + "folder_name": "", "title": "", "subtitle": "", "headline": "", diff --git a/imports/example.json b/imports/example.json index fc1aecf..950fba6 100644 --- a/imports/example.json +++ b/imports/example.json @@ -1,12 +1,12 @@ { "name": "name", - "subdomain": "subdomain", + "folder_name": "folder_name", "title": "title", "subtitle": "subtitle", "headline": "headline", "content": "content", "url_message": "url message", "url": "url", - "tear_off_link": "http://example-subdomain.6dshf2gnj7yzxlfcaczlyi57up4mvbtd5orinuj5bjsfycnhz2w456yd.onion", + "tear_off_link": "http://example-folder_name.6dshf2gnj7yzxlfcaczlyi57up4mvbtd5orinuj5bjsfycnhz2w456yd.onion", "binary_message": "0110010 0101011 0110010 0111101 0110100" } \ No newline at end of file diff --git a/imports/jesus.json b/imports/jesus.json index 4000237..c1125f3 100644 --- a/imports/jesus.json +++ b/imports/jesus.json @@ -1,6 +1,6 @@ { "name": "Life Changing Message", - "subdomain": "jesus", + "folder_name": "jesus", "title": "GOOD NEWS", "subtitle": "DO NOT IGNORE", "headline": "DISCOVER TRUE HOPE", diff --git a/imports/meshtastic.json b/imports/meshtastic.json index c07575b..32d0c99 100644 --- a/imports/meshtastic.json +++ b/imports/meshtastic.json @@ -1,6 +1,6 @@ { "name": "Meshtastic Network Flyers", - "subdomain": "meshtastic", + "folder_name": "meshtastic", "title": "SECURE COMMUNICATIONS", "subtitle": "DO NOT IGNORE", "headline": "BUILD YOUR EMERGENCY NETWORK", diff --git a/imports/nostr.json b/imports/nostr.json index 14a90b3..034ba11 100644 --- a/imports/nostr.json +++ b/imports/nostr.json @@ -1,6 +1,6 @@ { "name": "Nostr Protocol Flyers", - "subdomain": "voxvera", + "folder_name": "voxvera", "title": "SOCIAL FREEDOM", "subtitle": "DO NOT IGNORE", "headline": "JOIN THE CENSORSHIP-RESISTANT NETWORK", diff --git a/imports/voxvera.json b/imports/voxvera.json index 42a8fc5..52f87c6 100644 --- a/imports/voxvera.json +++ b/imports/voxvera.json @@ -1,6 +1,6 @@ { "name": "Vox Vera Printable Flyers", - "subdomain": "voxvera", + "folder_name": "voxvera", "title": "TOP SECRET", "subtitle": "DO NOT DISTRIBUTE", "headline": "OPERATION VOX VERA", diff --git a/site/config.json b/site/config.json index ed866c9..3907f73 100644 --- a/site/config.json +++ b/site/config.json @@ -1,6 +1,6 @@ { "name": "Vox Vera Printable Flyers", - "subdomain": "voxvera", + "folder_name": "voxvera", "title": "TOP SECRET", "subtitle": "DO NOT DISTRIBUTE", "headline": "OPERATION VOX VERA", diff --git a/tests/test_cli.py b/tests/test_cli.py index 0313f8e..84d7b99 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -65,7 +65,7 @@ def test_import(tmp_path, monkeypatch): base_data = json.load(open(repo / "imports" / "example.json")) for sub in ["foo", "bar"]: data = dict(base_data) - data["subdomain"] = sub + data["folder_name"] = sub with open(imports_dir / f"{sub}.json", "w") as fh: json.dump(data, fh) cli.main(["import"]) @@ -81,7 +81,7 @@ def test_import_preserves_session(tmp_path, monkeypatch): imports_dir = tmp_path / "imports" imports_dir.mkdir() base_data = json.load(open(repo / "imports" / "example.json")) - base_data["subdomain"] = "keep" + base_data["folder_name"] = "keep" with open(imports_dir / "keep.json", "w") as fh: json.dump(base_data, fh) @@ -125,12 +125,12 @@ def test_check_missing(capsys, monkeypatch): def test_build_download_zip(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) config = json.load(open(tmp_path / "src" / "config.json")) - subdomain = config["subdomain"] + folder_name = config["folder_name"] zip_path = tmp_path / "file.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("dummy.txt", "data") cli.main(["build", "--download", str(zip_path)]) - dest = tmp_path / "host" / subdomain / "download" / "download.zip" + dest = tmp_path / "host" / folder_name / "download" / "download.zip" assert dest.is_file() @@ -139,8 +139,8 @@ def test_serve_updates_url(tmp_path, monkeypatch): _setup_tmp(monkeypatch, tmp_path) cli.main(["build"]) config = json.load(open(tmp_path / "src" / "config.json")) - subdomain = config["subdomain"] - dir_path = tmp_path / "host" / subdomain + folder_name = config["folder_name"] + dir_path = tmp_path / "host" / folder_name # Set a custom URL that should be preserved (content link) config["url"] = "https://example.com/external-resource" @@ -152,7 +152,7 @@ def test_serve_updates_url(tmp_path, monkeypatch): orig_build = cli.build_assets def safe_build_assets(cfg, download_path=None): - dest = cli.ROOT / "host" / json.load(open(cfg))["subdomain"] / "config.json" + dest = cli.ROOT / "host" / json.load(open(cfg))["folder_name"] / "config.json" if Path(cfg) == dest: return return orig_build(cfg, download_path=download_path) @@ -193,14 +193,14 @@ def test_quickstart_noninteractive(tmp_path, monkeypatch): """Test quickstart auto-generates onion URL for tear-off links.""" _setup_tmp(monkeypatch, tmp_path) config = json.load(open(tmp_path / "src" / "config.json")) - subdomain = config["subdomain"] + folder_name = config["folder_name"] # No need for TOR_* env vars anymore - auto-detection should work monkeypatch.setattr(cli, "require_cmd", lambda c: True) orig_build = cli.build_assets def safe_build_assets(cfg, download_path=None): - dest = cli.ROOT / "host" / json.load(open(cfg))["subdomain"] / "config.json" + dest = cli.ROOT / "host" / json.load(open(cfg))["folder_name"] / "config.json" if Path(cfg) == dest: return return orig_build(cfg, download_path=download_path) @@ -228,7 +228,7 @@ def test_quickstart_noninteractive(tmp_path, monkeypatch): cli.main(["quickstart", "--non-interactive"]) - dir_path = tmp_path / "host" / subdomain + dir_path = tmp_path / "host" / folder_name assert (dir_path / "index.html").exists() log_file = dir_path / "onionshare.log" assert onion_url in log_file.read_text() @@ -287,7 +287,7 @@ def test_build_without_url_skips_qr(tmp_path, monkeypatch, capsys): # Build should complete without error, just skip QR generation cli.main(["build"]) - dest = tmp_path / "host" / config["subdomain"] + dest = tmp_path / "host" / config["folder_name"] assert dest.is_dir() assert (dest / "config.json").exists() assert (dest / "index.html").exists() @@ -315,7 +315,7 @@ def test_build_with_only_url(tmp_path, monkeypatch): cli.main(["build"]) - dest = tmp_path / "host" / config["subdomain"] + dest = tmp_path / "host" / config["folder_name"] # Only content QR should exist (tear-off will be set by serve) assert (dest / "qrcode-content.png").exists() assert not (dest / "qrcode-tear-offs.png").exists() @@ -340,7 +340,7 @@ def test_build_with_only_tear_off_link(tmp_path, monkeypatch): cli.main(["build"]) - dest = tmp_path / "host" / config["subdomain"] + dest = tmp_path / "host" / config["folder_name"] # Only tear-off QR should exist (will be updated by serve) assert not (dest / "qrcode-content.png").exists() assert (dest / "qrcode-tear-offs.png").exists() @@ -351,7 +351,7 @@ def test_serve_passes_tor_ports_to_env(monkeypatch, tmp_path): _setup_tmp(monkeypatch, tmp_path) cli.main(["build"]) config = json.load(open(tmp_path / "src" / "config.json")) - subdomain = config["subdomain"] + folder_name = config["folder_name"] captured_env = {} @@ -397,7 +397,7 @@ def test_backward_compatibility_with_https_config(tmp_path, monkeypatch): cli.main(["build"]) - dest = tmp_path / "host" / config["subdomain"] + dest = tmp_path / "host" / config["folder_name"] assert (dest / "qrcode-content.png").exists() assert (dest / "qrcode-tear-offs.png").exists() @@ -415,6 +415,6 @@ def test_long_urls_in_qr(tmp_path, monkeypatch): cli.main(["build"]) - dest = tmp_path / "host" / config["subdomain"] + dest = tmp_path / "host" / config["folder_name"] assert (dest / "qrcode-content.png").exists() assert (dest / "qrcode-tear-offs.png").exists() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index aa7b60f..9fc4cf6 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -111,7 +111,7 @@ class TestFullWorkflow: # Create test config config = { "name": "Test Flyer", - "subdomain": "testflyer", + "folder_name": "testflyer", "title": "TEST TITLE", "subtitle": "Test Subtitle", "headline": "Test Headline", @@ -203,7 +203,7 @@ class TestFullWorkflow: config = { "name": "Reachability Test", - "subdomain": "reachtest", + "folder_name": "reachtest", "title": "Reachability Test", "subtitle": "Testing Tor reachability", "headline": "Test Headline", @@ -323,7 +323,7 @@ class TestSiteFiles: config = { "name": "Test", - "subdomain": "filetest", + "folder_name": "filetest", "title": "Title", "subtitle": "Subtitle", "headline": "Headline", @@ -369,7 +369,7 @@ class TestErrorHandling: config_path.write_text( json.dumps( { - "subdomain": "nonexistent", + "folder_name": "nonexistent", "name": "Test", "title": "Title", "subtitle": "Subtitle", diff --git a/voxvera/cli.py b/voxvera/cli.py index f83e104..5985166 100644 --- a/voxvera/cli.py +++ b/voxvera/cli.py @@ -160,9 +160,9 @@ def _len_validator(limit: int): return _v -def _subdomain_validator(val: str): +def _folder_name_validator(val: str): if len(val) > 63: - return "Subdomain must be at most 63 characters" + return "Folder name must be at most 63 characters" if not re.fullmatch(r"[a-z0-9-]+", val): return "Use only lowercase letters, numbers and '-'" return True @@ -184,11 +184,11 @@ def interactive_update(config_path: str): }, { "type": "input", - "message": "Subdomain", - "name": "subdomain", - "default": data.get("subdomain", ""), + "message": "Folder Name", + "name": "folder_name", + "default": data.get("folder_name", ""), "transformer": _len_transform(63), - "validate": _subdomain_validator, + "validate": _folder_name_validator, }, { "type": "input", @@ -299,8 +299,8 @@ def build_assets( with open(src_dir / "index.html", "w") as fh: fh.write(html) - subdomain = data["subdomain"] - dest = ROOT / "host" / subdomain + folder_name = data["folder_name"] + dest = ROOT / "host" / folder_name os.makedirs(dest, exist_ok=True) if download_path: os.makedirs(dest / "download", exist_ok=True) @@ -372,8 +372,8 @@ def serve(config_path: str): socks, ctl = get_tor_ports() - subdomain = load_config(config_path)["subdomain"] - dir_path = ROOT / "host" / subdomain + folder_name = load_config(config_path)["folder_name"] + dir_path = ROOT / "host" / folder_name if not dir_path.is_dir(): print(f"Directory {dir_path} not found", file=sys.stderr) sys.exit(1) @@ -405,20 +405,29 @@ def serve(config_path: str): import re as _re onion_url = None - while onion_url is None: - time.sleep(1) - if proc.poll() is not None: - print("OnionShare exited unexpectedly", file=sys.stderr) - with open(logfile) as fh: - sys.stderr.write(fh.read()) - pid_file.unlink(missing_ok=True) - sys.exit(1) - if os.path.exists(logfile): - with open(logfile) as fh: - content = fh.read() - m = _re.search(r"https?://[a-z0-9]+\.onion", content) - if m: - onion_url = m.group(0) + console = Console() + with console.status("[bold green]Connecting to Tor relays and setting up hidden service...") as status: + while onion_url is None: + time.sleep(1) + if proc.poll() is not None: + print("OnionShare exited unexpectedly", file=sys.stderr) + with open(logfile) as fh: + sys.stderr.write(fh.read()) + pid_file.unlink(missing_ok=True) + sys.exit(1) + if os.path.exists(logfile): + with open(logfile) as fh: + content = fh.read() + + # Look for Tor bootstrap progress in logs to give feedback + m_progress = _re.findall(r"Bootstrapped (\d+)%", content) + if m_progress: + status.update(f"[bold green]Tor Bootstrapping: {m_progress[-1]}% ...") + + m = _re.search(r"https?://[a-z0-9]+\.onion", content) + if m: + onion_url = m.group(0) + print(f"Onion URL: {onion_url}") # update config with the onion URL # tear_off_link is set to the onion address (for tear-off tabs) @@ -462,8 +471,8 @@ def import_configs(): print(f"Processing {json_file}") dest_config = ROOT / "src" / "config.json" shutil.copy(json_file, dest_config) - subdomain = load_config(json_file)["subdomain"] - _clear_host_dir(ROOT / "host" / subdomain) + folder_name = load_config(json_file)["folder_name"] + _clear_host_dir(ROOT / "host" / folder_name) build_assets(dest_config) @@ -478,8 +487,8 @@ def get_servers() -> list[str]: return sorted(servers) -def is_server_running(subdomain: str) -> bool: - pid_file = ROOT / "host" / subdomain / "server.pid" +def is_server_running(folder_name: str) -> bool: + pid_file = ROOT / "host" / folder_name / "server.pid" if not pid_file.exists(): return False try: @@ -517,10 +526,10 @@ def is_server_running(subdomain: str) -> bool: return True -def stop_server(subdomain: str): - pid_file = ROOT / "host" / subdomain / "server.pid" +def stop_server(folder_name: str): + pid_file = ROOT / "host" / folder_name / "server.pid" if not pid_file.exists(): - print(f"Server {subdomain} is not running.") + print(f"Server {folder_name} is not running.") return try: with open(pid_file, "r") as f: @@ -530,20 +539,20 @@ def stop_server(subdomain: str): subprocess.run(["taskkill", "/F", "/PID", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: os.kill(pid, signal.SIGTERM) - print(f"Stopped {subdomain} (PID {pid})") + print(f"Stopped {folder_name} (PID {pid})") except Exception as e: - print(f"Error stopping {subdomain}: {e}") + print(f"Error stopping {folder_name}: {e}") finally: pid_file.unlink(missing_ok=True) -def delete_server(subdomain: str): - if is_server_running(subdomain): - stop_server(subdomain) - dir_path = ROOT / "host" / subdomain +def delete_server(folder_name: str): + if is_server_running(folder_name): + stop_server(folder_name) + dir_path = ROOT / "host" / folder_name if dir_path.exists(): shutil.rmtree(dir_path, ignore_errors=True) - print(f"Deleted server {subdomain}") + print(f"Deleted server {folder_name}") def manage_servers(): @@ -640,6 +649,8 @@ def main(argv=None): return elif not args.non_interactive: interactive_update(config_path) + build_assets(config_path) + serve(config_path) elif args.command == "build": build_assets(config_path, download_path=args.download) elif args.command == "serve": diff --git a/voxvera/src/README.md b/voxvera/src/README.md index 5e9c2e4..ffdf098 100644 --- a/voxvera/src/README.md +++ b/voxvera/src/README.md @@ -14,7 +14,7 @@ The `voxvera build` command processes these source files: 1. **QR code generation** -- Reads URLs from `config.json` and generates PNG QR codes using the `qrcode` and `Pillow` Python libraries. 2. **HTML minification** -- Minifies embedded JavaScript with `jsmin` and the overall HTML with `htmlmin2`. Produces `index.html` and `nostr.html` from the master templates. 3. **Binary message injection** -- Writes the configured binary message into the built HTML. -4. **Asset packaging** -- Copies all built files into `host//`. +4. **Asset packaging** -- Copies all built files into `host//`. No external tools (Node.js, ImageMagick, qrencode, etc.) are required. The entire build pipeline uses pure Python packages. diff --git a/voxvera/src/config.json b/voxvera/src/config.json index 2d7871e..9d93b4a 100644 --- a/voxvera/src/config.json +++ b/voxvera/src/config.json @@ -1,6 +1,6 @@ { "name": "Vox Vera Printable Flyers", - "subdomain": "voxvera", + "folder_name": "voxvera", "title": "TOP SECRET", "subtitle": "DO NOT DISTRIBUTE", "headline": "OPERATION VOX VERA", diff --git a/voxvera/templates/voxvera/config.json b/voxvera/templates/voxvera/config.json index 9822f0f..27b9a47 100644 --- a/voxvera/templates/voxvera/config.json +++ b/voxvera/templates/voxvera/config.json @@ -1,6 +1,6 @@ { "name": "Vox Vera Printable Flyers", - "subdomain": "voxvera", + "folder_name": "voxvera", "title": "TOP SECRET", "subtitle": "DO NOT DISTRIBUTE", "headline": "OPERATION VOX VERA",