feat: Rename subdomain to folder_name, add Tor bootstrap progress, and auto-start server after init

This commit is contained in:
thePR0M3TH3AN committed 2026-03-13 09:02:45 -04:00
1 parent 956a836ff0
commit e429a6e21b
20 files changed
+92 -81

No files matched your search

+1 -1
View File
@@ -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 `<span class="redacted">`. |
| **headline** | 80 chars | Second heading below the subtitle (e.g. "OPERATION VOX VERA"). |
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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/<subdomain>/onionshare.log` for details. Common causes:
`host/<folder_name>/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/<subdomain>/.onionshare-session`. If this file is deleted, a new
`host/<folder_name>/.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.
+3 -3
View File
@@ -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/<subdomain>` 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/<folder_name>` 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/<subdomain>/.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/<folder_name>/.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
+2 -2
View File
@@ -18,7 +18,7 @@
<h1>VoxVera</h1>
<form id="config-form">
<div><label>Name <input type="text" id="name"></label></div>
<div><label>Subdomain <input type="text" id="subdomain"></label></div>
<div><label>Subdomain <input type="text" id="folder_name"></label></div>
<div><label>Title <input type="text" id="title"></label></div>
<div><label>Subtitle <input type="text" id="subtitle"></label></div>
<div><label>Headline <input type="text" id="headline"></label></div>
@@ -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);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "AOS Project",
"subdomain": "aosp",
"folder_name": "aosp",
"title": "ANDROID UNLEASHED",
"subtitle": "WITHOUT BOUNDARIES",
"headline": "OPEN-SOURCE FREEDOM",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "",
"subdomain": "",
"folder_name": "",
"title": "",
"subtitle": "",
"headline": "",
+2 -2
View File
@@ -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"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Life Changing Message",
"subdomain": "jesus",
"folder_name": "jesus",
"title": "GOOD NEWS",
"subtitle": "DO NOT IGNORE",
"headline": "DISCOVER TRUE HOPE",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Vox Vera Printable Flyers",
"subdomain": "voxvera",
"folder_name": "voxvera",
"title": "TOP SECRET",
"subtitle": "DO <span class=\"redacted\">NOT</span> DISTRIBUTE",
"headline": "OPERATION VOX VERA",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Vox Vera Printable Flyers",
"subdomain": "voxvera",
"folder_name": "voxvera",
"title": "TOP SECRET",
"subtitle": "DO <span class=\"redacted\">NOT</span> DISTRIBUTE",
"headline": "OPERATION VOX VERA",
+16 -16
View File
@@ -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()
+4 -4
View File
@@ -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",
+49 -38
View File
@@ -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":
+1 -1
View File
@@ -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/<subdomain>/`.
4. **Asset packaging** -- Copies all built files into `host/<folder_name>/`.
No external tools (Node.js, ImageMagick, qrencode, etc.) are required. The entire build pipeline uses pure Python packages.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Vox Vera Printable Flyers",
"subdomain": "voxvera",
"folder_name": "voxvera",
"title": "TOP SECRET",
"subtitle": "DO <span class=\"redacted\">NOT</span> DISTRIBUTE",
"headline": "OPERATION VOX VERA",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Vox Vera Printable Flyers",
"subdomain": "voxvera",
"folder_name": "voxvera",
"title": "TOP SECRET",
"subtitle": "DO <span class=\\\"redacted\\\">NOT</span> DISTRIBUTE",
"headline": "OPERATION VOX VERA",