diff --git a/.gitignore b/.gitignore index 1f8ea9b..8bf312e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ tmp*/ .DS_Store Thumbs.db *~ -*.swp \ No newline at end of file +*.swp +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 001416a..1d9e88a 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,17 @@ environments such as **MSYS2** or **Git Bash** can also be used, but they must provide the same command-line utilities. ## Generating a Flyer -Run the helper script from the repository root: +Run the CLI from the repository root: ```bash # interactive prompts -./src/create_flyer.sh +voxvera init # use an alternate config file -./src/create_flyer.sh -c path/to/custom.json +voxvera init --config path/to/custom.json # use answers from an existing PDF form -./src/create_flyer.sh --from-pdf path/to/form.pdf +voxvera init --from-pdf path/to/form.pdf ``` When run interactively you'll be prompted for details such as the flyer title @@ -82,7 +82,7 @@ Additional documentation is available in the `src/` directory; see [src/README.m ## Step-by-Step 1. Edit `src/index-master.html` or `src/nostr-master.html` if you need custom content. -2. Run `./src/create_flyer.sh` and follow the prompts, or use `./src/create_flyer.sh --from-pdf path/to/form.pdf`. +2. Run `voxvera init` and follow the prompts, or use `voxvera init --from-pdf path/to/form.pdf`. 3. Host the generated `host/` directory. The `index.html` file fetches `config.json`, so the flyer must be served via a local or remote web server rather than opened directly from disk. For a quick @@ -93,21 +93,21 @@ Additional documentation is available in the `src/` directory; see [src/README.m Place configuration files in an `imports/` directory at the project root. Run ```bash -./src/import_from_json.sh +voxvera import ``` Each JSON file is copied to `src/config.json` and processed with -`create_flyer.sh --no-interaction`. Existing folders under `host/` with the +`voxvera build`. Existing folders under `host/` with the same subdomain are removed before new files are written. ## Hosting with OnionShare The folder under `host/` contains everything needed to serve the -flyer. Run the helper script `serve_with_onionshare.sh` to publish it over Tor. +flyer. Use the CLI to publish it over Tor: The script now resolves the configuration and host paths internally, so it can be invoked from any directory: ```bash -./serve_with_onionshare.sh +voxvera serve ``` The script launches `onionshare-cli` in persistent website mode, waits for the diff --git a/serve_with_onionshare.sh b/serve_with_onionshare.sh deleted file mode 100755 index 163dab8..0000000 --- a/serve_with_onionshare.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -set -euo pipefail - -CONFIG="src/config.json" -if [[ $# -gt 0 ]]; then - CONFIG="$1" -fi - -# get subdomain from config -subdomain=$(jq -r '.subdomain' "$CONFIG") - -# resolve paths to absolute locations so the script works from anywhere -CONFIG="$(realpath "$CONFIG")" -DIR="$(realpath "host/$subdomain")" -if [[ ! -d "$DIR" ]]; then - echo "Directory $DIR not found" >&2 - exit 1 -fi - -logfile="$DIR/onionshare.log" - -# start OnionShare in background -onionshare-cli --website --public --persistent "$DIR/.onionshare-session" "$DIR" >"$logfile" 2>&1 & -os_pid=$! - -# wait for onion address to appear -while ! grep -m1 -Eo 'https?://[a-z0-9]+\.onion' "$logfile" >/dev/null; do - sleep 1 - if ! kill -0 $os_pid 2>/dev/null; then - echo "OnionShare exited unexpectedly" >&2 - cat "$logfile" >&2 - exit 1 - fi -done - -onion_url=$(grep -m1 -Eo 'https?://[a-z0-9]+\.onion' "$logfile") - -# update config with onion url -jq --arg url "$onion_url" '.url=$url | .tear_off_link=$url' "$DIR/config.json" >"$DIR/config.tmp" && mv "$DIR/config.tmp" "$DIR/config.json" - -# regenerate assets -(cd src && ./generate_qr.sh "$DIR/config.json") -(cd src && ./obfuscate_index.sh "$DIR/config.json" && ./obfuscate_nostr.sh "$DIR/config.json") -cp src/index.html src/nostr.html src/qrcode-content.png src/qrcode-tear-offs.png "$DIR/" - -echo "Onion URL: $onion_url" - -echo "OnionShare running (PID $os_pid). See $logfile for details." diff --git a/src/README.md b/src/README.md index df0f748..441d699 100644 --- a/src/README.md +++ b/src/README.md @@ -124,21 +124,21 @@ If you prefer to use Visual Studio Code to edit and run these scripts: ``` ## Creating and Hosting a Flyer -The `create_flyer.sh` script automates filling `config.json`, building the HTML files, and copying everything into a new directory under `host/`. +The `voxvera` CLI automates filling `config.json`, building the HTML files, and copying everything into a new directory under `host/`. ### Usage ```bash # interactive mode -./create_flyer.sh +voxvera init && voxvera build # use an alternate config file -./create_flyer.sh -c path/to/custom.json +voxvera init --config path/to/custom.json && voxvera build --config path/to/custom.json # use an existing filled PDF form -./create_flyer.sh --from-pdf path/to/form.pdf +voxvera init --from-pdf path/to/form.pdf && voxvera build ``` By default the script updates `src/config.json`. Use the `-c` option to specify a different file. After answering the prompts (or extracting from the PDF), `index.html` and `nostr.html` are generated and copied along with the QR code images and PDFs. The files end up in `host/` which can be served statically. -QR codes are built automatically during this process. After the configuration is updated, `create_flyer.sh` calls `generate_qr.sh` to read the URLs from `config.json` and produce `qrcode-content.png` and `qrcode-tear-offs.png`. +QR codes are built automatically during this process. After the configuration is updated, the CLI regenerates the QR codes by invoking `generate_qr.sh` to read the URLs from `config.json` and produce `qrcode-content.png` and `qrcode-tear-offs.png`. diff --git a/src/import_from_json.sh b/src/import_from_json.sh index 7d4d344..be81d56 100755 --- a/src/import_from_json.sh +++ b/src/import_from_json.sh @@ -17,5 +17,5 @@ for json in "${files[@]}"; do subdomain=$(jq -r '.subdomain' "$json") dest="host/$subdomain" rm -rf "$dest" - ./src/create_flyer.sh --no-interaction + voxvera build --config src/config.json done diff --git a/voxvera/__init__.py b/voxvera/__init__.py new file mode 100644 index 0000000..ee5434a --- /dev/null +++ b/voxvera/__init__.py @@ -0,0 +1 @@ +"""VoxVera command line utilities.""" diff --git a/voxvera/__main__.py b/voxvera/__main__.py new file mode 100644 index 0000000..71b440f --- /dev/null +++ b/voxvera/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == '__main__': + main() diff --git a/voxvera/cli.py b/voxvera/cli.py new file mode 100644 index 0000000..11bd82a --- /dev/null +++ b/voxvera/cli.py @@ -0,0 +1,196 @@ +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +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 interactive_update(config_path: str): + data = load_config(config_path) + def prompt(field, default=None): + val = input(f"{field}{' ['+default+']' if default else ''}: ") + return val or default or '' + + data['name'] = prompt('Name') + data['subdomain'] = prompt('Subdomain') + data['title'] = prompt('Title') + data['subtitle'] = prompt('Subtitle') + data['headline'] = prompt('Headline') + print('Enter content (end with EOF on its own line):') + content_lines = [] + while True: + line = input() + if line == 'EOF': + break + content_lines.append(line) + data['content'] = '\n'.join(content_lines) + data['url_message'] = prompt('URL message') + data['binary_message'] = prompt('Binary message') + onion_base = '6dshf2gnj7yzxlfcaczlyi57up4mvbtd5orinuj5bjsfycnhz2w456yd.onion' + constructed = f"http://{data['subdomain']}.{onion_base}" + data['url'] = prompt('URL', constructed) + data['tear_off_link'] = prompt('Tear-off link', constructed) + 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'

.*?

' + repl = f'

{data.get("binary_message", "")}

' + 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()