Add Python CLI and update docs

This commit is contained in:
thePR0M3TH3AN
2025-06-18 22:27:28 -04:00
parent 9cbc9a2041
commit d7af591319
8 changed files with 219 additions and 64 deletions

4
.gitignore vendored
View File

@@ -6,4 +6,6 @@ tmp*/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*~ *~
*.swp *.swp
__pycache__/
*.pyc

View File

@@ -58,17 +58,17 @@ environments such as **MSYS2** or **Git Bash** can also be used, but they must
provide the same command-line utilities. provide the same command-line utilities.
## Generating a Flyer ## Generating a Flyer
Run the helper script from the repository root: Run the CLI from the repository root:
```bash ```bash
# interactive prompts # interactive prompts
./src/create_flyer.sh voxvera init
# use an alternate config file # 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 # 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 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 ## Step-by-Step
1. Edit `src/index-master.html` or `src/nostr-master.html` if you need custom content. 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/<subdomain>` directory. 3. Host the generated `host/<subdomain>` directory.
The `index.html` file fetches `config.json`, so the flyer must be served via a 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 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 Place configuration files in an `imports/` directory at the project root. Run
```bash ```bash
./src/import_from_json.sh voxvera import
``` ```
Each JSON file is copied to `src/config.json` and processed with 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. same subdomain are removed before new files are written.
## Hosting with OnionShare ## Hosting with OnionShare
The folder under `host/<subdomain>` contains everything needed to serve the The folder under `host/<subdomain>` 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 The script now resolves the configuration and host paths internally, so it can
be invoked from any directory: be invoked from any directory:
```bash ```bash
./serve_with_onionshare.sh voxvera serve
``` ```
The script launches `onionshare-cli` in persistent website mode, waits for the The script launches `onionshare-cli` in persistent website mode, waits for the

View File

@@ -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."

View File

@@ -124,21 +124,21 @@ If you prefer to use Visual Studio Code to edit and run these scripts:
``` ```
## Creating and Hosting a Flyer ## 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 ### Usage
```bash ```bash
# interactive mode # interactive mode
./create_flyer.sh voxvera init && voxvera build
# use an alternate config file # 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 # 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/<subdomain>` which can be served statically. 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/<subdomain>` 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`.

View File

@@ -17,5 +17,5 @@ for json in "${files[@]}"; do
subdomain=$(jq -r '.subdomain' "$json") subdomain=$(jq -r '.subdomain' "$json")
dest="host/$subdomain" dest="host/$subdomain"
rm -rf "$dest" rm -rf "$dest"
./src/create_flyer.sh --no-interaction voxvera build --config src/config.json
done done

1
voxvera/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""VoxVera command line utilities."""

4
voxvera/__main__.py Normal file
View File

@@ -0,0 +1,4 @@
from .cli import main
if __name__ == '__main__':
main()

196
voxvera/cli.py Normal file
View File

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