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

257 lines
9.6 KiB
Python

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import datetime
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('templates/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 copy_template(name: str) -> str:
"""Copy a template directory into dist/ with a datestamped folder."""
date = datetime.date.today().strftime('%Y%m%d')
src = os.path.join('templates', name)
if not os.path.isdir(src):
print(f"Template {name} not found", file=sys.stderr)
sys.exit(1)
dest = os.path.join('dist', f"{name}-{date}")
os.makedirs('dist', exist_ok=True)
shutil.copytree(src, dest, dirs_exist_ok=True)
print(f"Template copied to {dest}")
return dest
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
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())
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('--template', help='Copy a template into dist/')
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.template:
copy_template(args.template)
return
elif 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()