Files
VoxVera/voxvera/cli.py
2025-06-19 11:42:00 -04:00

237 lines
8.9 KiB
Python

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from InquirerPy import prompt, inquirer
from rich.console import Console
tmpimport = None
def require_cmd(cmd: str):
if shutil.which(cmd) is None:
print(f"Required command '{cmd}' not found. Please install it.", file=sys.stderr)
return False
return True
def run(cmd, **kwargs):
try:
subprocess.run(cmd, check=True, **kwargs)
except FileNotFoundError:
print(f"Required command '{cmd[0]}' not found. Please install it.", file=sys.stderr)
sys.exit(1)
def load_config(path: str) -> dict:
with open(path, 'r') as fh:
return json.load(fh)
def save_config(data: dict, path: str):
with open(path, 'w') as fh:
json.dump(data, fh, indent=2)
def open_editor(initial: str) -> str:
import tempfile
editor = os.environ.get('EDITOR', 'nano')
fd, path = tempfile.mkstemp(suffix='.txt')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
fh.write(initial or '')
subprocess.call([editor, path])
with open(path, 'r', encoding='utf-8') as fh:
return fh.read()
finally:
os.unlink(path)
def _len_transform(limit: int):
def _t(val: str) -> str:
l = len(val)
if l > limit:
return f"[red]{val} ({l}/{limit})[/red]"
return f"{val} ({l}/{limit})"
return _t
def interactive_update(config_path: str):
data = load_config(config_path)
console = Console()
console.rule("Metadata")
meta_qs = [
{"type": "input", "message": "Name", "name": "name", "default": data.get("name", ""), "transformer": _len_transform(60)},
{"type": "input", "message": "Subdomain", "name": "subdomain", "default": data.get("subdomain", ""), "transformer": _len_transform(63)},
{"type": "input", "message": "Title", "name": "title", "default": data.get("title", ""), "transformer": _len_transform(60)},
{"type": "input", "message": "Subtitle", "name": "subtitle", "default": data.get("subtitle", ""), "transformer": _len_transform(80)},
{"type": "input", "message": "Headline", "name": "headline", "default": data.get("headline", ""), "transformer": _len_transform(80)},
]
data.update(prompt(meta_qs))
console.rule("Body text")
body = open_editor(data.get("content", ""))
console.print(f"Body length: {len(body)}/1000", style="red" if len(body) > 1000 else "green")
data["content"] = body
console.rule("Links")
protocol = inquirer.select(
message="Default URL type",
choices=[("Tor (.onion)", "tor"), ("HTTPS", "https")],
default="tor",
).execute()
onion_base = "6dshf2gnj7yzxlfcaczlyi57up4mvbtd5orinuj5bjsfycnhz2w456yd.onion"
if protocol == "tor":
constructed = f"http://{data['subdomain']}.{onion_base}"
else:
constructed = f"https://{data['subdomain']}.example.com"
link_qs = [
{"type": "input", "message": "URL", "name": "url", "default": data.get("url", constructed), "transformer": _len_transform(200)},
{"type": "input", "message": "Tear-off link", "name": "tear_off_link", "default": data.get("tear_off_link", constructed), "transformer": _len_transform(200)},
{"type": "input", "message": "URL message", "name": "url_message", "default": data.get("url_message", ""), "transformer": _len_transform(120)},
{"type": "input", "message": "Binary message", "name": "binary_message", "default": data.get("binary_message", ""), "transformer": _len_transform(120)},
]
data.update(prompt(link_qs))
save_config(data, config_path)
def update_from_pdf(config_path: str, pdf_path: str):
import tempfile
tmpdir = tempfile.mkdtemp()
os.makedirs(os.path.join(tmpdir, 'from_client'), exist_ok=True)
shutil.copy(pdf_path, os.path.join(tmpdir, 'from_client', 'submission_form.pdf'))
shutil.copy('host/blank/extract_form_fields.sh', tmpdir)
shutil.copy(config_path, os.path.join(tmpdir, 'config.json'))
run(['bash', 'extract_form_fields.sh'], cwd=tmpdir)
shutil.copy(os.path.join(tmpdir, 'config.json'), config_path)
shutil.rmtree(tmpdir)
def build_assets(config_path: str, pdf_path: str | None = None):
# generate QR codes
run(['bash', 'generate_qr.sh', config_path], cwd='src')
# obfuscate html
run(['bash', 'obfuscate_index.sh', config_path], cwd='src')
run(['bash', 'obfuscate_nostr.sh', config_path], cwd='src')
data = load_config(config_path)
with open('src/index.html', 'r') as fh:
html = fh.read()
pattern = r'<p class="binary" id="binary-message">.*?</p>'
repl = f'<p class="binary" id="binary-message">{data.get("binary_message", "")}</p>'
html = re.sub(pattern, repl, html, flags=re.S)
with open('src/index.html', 'w') as fh:
fh.write(html)
subdomain = data['subdomain']
dest = os.path.join('host', subdomain)
os.makedirs(os.path.join(dest, 'from_client'), exist_ok=True)
shutil.copy(config_path, os.path.join(dest, 'config.json'))
for fname in ['index.html', 'nostr.html', 'qrcode-content.png', 'qrcode-tear-offs.png', 'example.pdf', 'submission_form.pdf']:
shutil.copy(os.path.join('src', fname), dest)
if pdf_path:
shutil.copy(pdf_path, os.path.join(dest, 'from_client', 'submission_form.pdf'))
print(f"Flyer files created under {dest}")
def serve(config_path: str):
if not require_cmd('onionshare-cli'):
sys.exit(1)
subdomain = load_config(config_path)['subdomain']
dir_path = os.path.abspath(os.path.join('host', subdomain))
if not os.path.isdir(dir_path):
print(f"Directory {dir_path} not found", file=sys.stderr)
sys.exit(1)
logfile = os.path.join(dir_path, 'onionshare.log')
proc = subprocess.Popen(['onionshare-cli', '--website', '--public', '--persistent', f'{dir_path}/.onionshare-session', dir_path], stdout=open(logfile, 'w'), stderr=subprocess.STDOUT)
try:
import time, 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())
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)
print(f"Onion URL: {onion_url}")
# update config
data = load_config(os.path.join(dir_path, 'config.json'))
data['url'] = onion_url
data['tear_off_link'] = onion_url
save_config(data, os.path.join(dir_path, 'config.json'))
# regenerate assets
build_assets(os.path.join(dir_path, 'config.json'))
print(f"OnionShare running (PID {proc.pid}). See {logfile} for details.")
except KeyboardInterrupt:
pass
def import_configs():
import glob
files = sorted(glob.glob('imports/*.json'))
if not files:
print('No JSON files found in imports')
return
for json_file in files:
print(f'Processing {json_file}')
dest_config = 'src/config.json'
shutil.copy(json_file, dest_config)
subdomain = load_config(json_file)['subdomain']
shutil.rmtree(os.path.join('host', subdomain), ignore_errors=True)
build_assets(dest_config)
def main(argv=None):
parser = argparse.ArgumentParser(prog='voxvera')
parser.add_argument('--config', default='src/config.json', help='Path to config.json')
sub = parser.add_subparsers(dest='command')
p_init = sub.add_parser('init', help='Update configuration interactively or from PDF')
p_init.add_argument('--from-pdf')
p_init.add_argument('--non-interactive', action='store_true')
p_build = sub.add_parser('build', help='Build flyer assets from config')
p_build.add_argument('--pdf')
sub.add_parser('import', help='Batch import JSON files from imports/')
sub.add_parser('serve', help='Serve flyer over OnionShare using config')
sub.add_parser('quickstart', help='Init, build and serve in sequence')
args = parser.parse_args(argv)
config_path = os.path.abspath(args.config)
if args.command == 'init':
if args.from_pdf:
if not require_cmd('pdftotext'):
sys.exit(1)
update_from_pdf(config_path, args.from_pdf)
elif not args.non_interactive:
interactive_update(config_path)
elif args.command == 'build':
build_assets(config_path, pdf_path=args.pdf)
elif args.command == 'serve':
serve(config_path)
elif args.command == 'import':
import_configs()
elif args.command == 'quickstart':
interactive_update(config_path)
build_assets(config_path)
serve(config_path)
else:
parser.print_help()
if __name__ == '__main__':
main()