mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2025-09-07 14:38:42 +00:00
Add Python CLI and update docs
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,4 +6,6 @@ tmp*/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*~
|
||||
*.swp
|
||||
*.swp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
18
README.md
18
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/<subdomain>` 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/<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
|
||||
be invoked from any directory:
|
||||
|
||||
```bash
|
||||
./serve_with_onionshare.sh
|
||||
voxvera serve
|
||||
```
|
||||
|
||||
The script launches `onionshare-cli` in persistent website mode, waits for the
|
||||
|
@@ -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."
|
@@ -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/<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`.
|
||||
|
@@ -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
|
||||
|
1
voxvera/__init__.py
Normal file
1
voxvera/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""VoxVera command line utilities."""
|
4
voxvera/__main__.py
Normal file
4
voxvera/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
196
voxvera/cli.py
Normal file
196
voxvera/cli.py
Normal 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()
|
Reference in New Issue
Block a user