mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 17:50:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35069e24c5 | ||
|
|
51ab72239f | ||
|
|
4e571b62ea | ||
|
|
3bb8627d20 | ||
|
|
4e84466d38 | ||
|
|
f530e54f45 | ||
|
|
8be7d0761b | ||
|
|
6d747f719f | ||
|
|
dc83b76317 | ||
|
|
9c699eb36b | ||
|
|
3cba4c7e9f |
No files matched your search
@@ -34,6 +34,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade setuptools pkg_resources || pip install --upgrade setuptools
|
||||
pip install -r requirements.txt
|
||||
pip install .
|
||||
pip install pyinstaller pyinstaller-hooks-contrib
|
||||
@@ -49,6 +50,16 @@ jobs:
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then SEP=";"; else SEP=":"; fi
|
||||
pyinstaller --onefile \
|
||||
--collect-all pkg_resources \
|
||||
--collect-all setuptools \
|
||||
--hidden-import pkg_resources \
|
||||
--hidden-import setuptools \
|
||||
--hidden-import jaraco.text \
|
||||
--hidden-import jaraco.functools \
|
||||
--hidden-import jaraco.context \
|
||||
--hidden-import more_itertools \
|
||||
--hidden-import packaging \
|
||||
--hidden-import platformdirs \
|
||||
--collect-all onionshare_cli \
|
||||
--collect-all flask_socketio \
|
||||
--collect-all engineio \
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
# VoxVera v0.2.4
|
||||
|
||||
## New Features
|
||||
- **Optional File Attachment:** Users can now attach a file (e.g., a .torrent, image, or document) to their flyers. This file is packaged into the Tor-hosted site and made available via a "Download Attachment" button on the landing page.
|
||||
- **Interactive Attachment Selection:** Added a prompt during `voxvera init` to easily specify the path to an attachment.
|
||||
|
||||
## Localization
|
||||
- Added new tokens for attachment handling across all 14+ supported languages.
|
||||
+25
@@ -96,6 +96,28 @@ else
|
||||
msg "onionshare-cli found (system package), skipping pipx install. Run 'pipx install --force ...' if you want the latest git version."
|
||||
fi
|
||||
|
||||
# Clean up stale user-local packages from prior pip --user installs of voxvera.
|
||||
# These can shadow system packages (e.g. Werkzeug) and break onionshare-cli.
|
||||
msg "Cleaning up stale user-local packages that may conflict with system onionshare-cli..."
|
||||
PIP_CLEANUP_OPTS=""
|
||||
if [ -f /etc/debian_version ]; then
|
||||
DEB_VER=$(cat /etc/debian_version | cut -d. -f1)
|
||||
if [[ "$DEB_VER" =~ ^[0-9]+$ ]] && [ "$DEB_VER" -ge 12 ]; then
|
||||
PIP_CLEANUP_OPTS="--break-system-packages"
|
||||
fi
|
||||
fi
|
||||
for pkg in werkzeug flask flask-socketio onionshare-cli; do
|
||||
if pip3 show --quiet "$pkg" 2>/dev/null && \
|
||||
pip3 show "$pkg" 2>/dev/null | grep -q "Location.*\.local"; then
|
||||
pip3 uninstall -y $PIP_CLEANUP_OPTS "$pkg" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
# Also uninstall any previous pip --user install of voxvera itself
|
||||
if pip3 show --quiet voxvera 2>/dev/null && \
|
||||
pip3 show voxvera 2>/dev/null | grep -q "Location.*\.local"; then
|
||||
pip3 uninstall -y $PIP_CLEANUP_OPTS voxvera 2>/dev/null || true
|
||||
fi
|
||||
|
||||
download_binary() {
|
||||
local url=$1
|
||||
local dest=$2
|
||||
@@ -156,6 +178,9 @@ fi
|
||||
if command_exists pipx; then
|
||||
msg "Falling back to pipx installation (this may take a few minutes)..."
|
||||
if pipx install --force 'voxvera@git+https://github.com/PR0M3TH3AN/VoxVera.git@main'; then
|
||||
# Ensure setuptools (pkg_resources) is available in the venv — pipx can
|
||||
# sometimes skip it if the resolver thinks the build-system copy suffices.
|
||||
pipx inject voxvera setuptools 2>/dev/null || true
|
||||
pipx ensurepath --force
|
||||
msg "\nVoxVera installed/updated successfully via pipx."
|
||||
msg "IMPORTANT: Please restart your terminal or run 'source ~/.bashrc' (or your shell config) to use 'voxvera'."
|
||||
|
||||
+2
-4
@@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi
|
||||
|
||||
[project]
|
||||
name = "voxvera"
|
||||
version = "0.2.8"
|
||||
version = "0.2.18"
|
||||
description = "VoxVera CLI utilities"
|
||||
readme = "README.md"
|
||||
authors = [{name = "VoxVera"}]
|
||||
@@ -28,9 +28,7 @@ dependencies = [
|
||||
"htmlmin2",
|
||||
"pypdf",
|
||||
"wcwidth",
|
||||
"onionshare-cli",
|
||||
"flask<2.3.0",
|
||||
"werkzeug<3.0.0",
|
||||
"setuptools",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
try:
|
||||
import pkg_resources
|
||||
import setuptools
|
||||
print("pkg_resources available")
|
||||
except ImportError as e:
|
||||
print(f"Failed to import: {e}")
|
||||
|
||||
from voxvera import cli
|
||||
cli._internal_onionshare()
|
||||
@@ -0,0 +1,2 @@
|
||||
import pkg_resources
|
||||
print("Success")
|
||||
@@ -0,0 +1,2 @@
|
||||
import pkg_resources
|
||||
print("Success")
|
||||
+5
-5
@@ -134,7 +134,7 @@ class TestFullWorkflow:
|
||||
"binary_message": "0101010",
|
||||
}
|
||||
|
||||
config_path = tmp_path / "src" / "config.json"
|
||||
config_path = tmp_path / "config.json"
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f)
|
||||
|
||||
@@ -233,7 +233,7 @@ class TestFullWorkflow:
|
||||
"binary_message": "101010",
|
||||
}
|
||||
|
||||
config_path = tmp_path / "src" / "config.json"
|
||||
config_path = tmp_path / "config.json"
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f)
|
||||
|
||||
@@ -368,7 +368,7 @@ class TestSiteFiles:
|
||||
"binary_message": "",
|
||||
}
|
||||
|
||||
config_path = tmp_path / "src" / "config.json"
|
||||
config_path = tmp_path / "config.json"
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f)
|
||||
|
||||
@@ -398,8 +398,8 @@ class TestErrorHandling:
|
||||
monkeypatch.setattr(cli, "ROOT", tmp_path)
|
||||
|
||||
# Try to serve without building first
|
||||
config_path = tmp_path / "src" / "config.json"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
|
||||
@@ -51,6 +51,7 @@ def test_export_import_roundtrip(tmp_path, monkeypatch):
|
||||
shutil.copytree(REPO_ROOT / "voxvera" / "src", tmp_path / "src")
|
||||
shutil.copytree(REPO_ROOT / "voxvera" / "locales", tmp_path / "locales")
|
||||
monkeypatch.setattr(cli, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(cli, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(cli, "_src_res", lambda *p: tmp_path / "src" / Path(*p))
|
||||
monkeypatch.setattr(cli, "_locale_res", lambda *p: tmp_path / "locales" / Path(*p))
|
||||
|
||||
|
||||
+5
-9
@@ -1,16 +1,12 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.2.8"
|
||||
__version__ = "0.2.18"
|
||||
|
||||
# Add the vendor directory to sys.path. We prepend it to ensure our bundled
|
||||
# dependencies take priority over potentially incompatible system packages
|
||||
# (e.g. Flask/Werkzeug version mismatches on Debian).
|
||||
# Add the vendor directory to sys.path ONLY for PyInstaller (frozen) builds.
|
||||
# For pip/pipx installs the system/venv packages are correct and the vendored
|
||||
# copies are incomplete (missing data files), so we must not shadow them.
|
||||
if getattr(sys, 'frozen', False):
|
||||
vendor_dir = Path(sys._MEIPASS).joinpath("voxvera", "vendor")
|
||||
else:
|
||||
vendor_dir = Path(__file__).parent / "vendor"
|
||||
|
||||
if vendor_dir.exists():
|
||||
if str(vendor_dir) not in sys.path:
|
||||
if vendor_dir.exists() and str(vendor_dir) not in sys.path:
|
||||
sys.path.insert(0, str(vendor_dir))
|
||||
+87
-63
@@ -10,6 +10,20 @@ import time
|
||||
from pathlib import Path
|
||||
from importlib import resources
|
||||
from importlib.resources.abc import Traversable
|
||||
|
||||
# Force PyInstaller to bundle these critical dependencies for onionshare-cli.
|
||||
# Only needed at freeze-time analysis; skip for normal pip/pipx installs to
|
||||
# avoid pulling in vendored copies that may be incomplete.
|
||||
if getattr(sys, 'frozen', False):
|
||||
try:
|
||||
import pkg_resources
|
||||
import setuptools
|
||||
import jaraco
|
||||
import platformdirs
|
||||
import packaging
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from InquirerPy import prompt, inquirer
|
||||
from rich.console import Console
|
||||
from voxvera import __version__
|
||||
@@ -24,7 +38,7 @@ else:
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
# Persistent data directory (where host/ and projects live)
|
||||
DATA_DIR = Path(os.environ.get("VOXVERA_DIR", Path.cwd()))
|
||||
DATA_DIR = Path(os.environ.get("VOXVERA_DIR", Path.home()))
|
||||
|
||||
# Global locale state
|
||||
CURRENT_LOCALE = {}
|
||||
@@ -180,7 +194,7 @@ def _open_editor_terminal(initial: str) -> str:
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(initial or "")
|
||||
subprocess.call([editor, path])
|
||||
subprocess.run([editor, path], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
except Exception as e:
|
||||
@@ -375,11 +389,18 @@ def interactive_update(config_path: str):
|
||||
|
||||
console.rule(t("cli.init_links"))
|
||||
link_qs = [
|
||||
{
|
||||
"type": "input",
|
||||
"message": t("cli.attachment_path_label"),
|
||||
"name": "attachment_path",
|
||||
"default": data.get("attachment_path", ""),
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"message": t("cli.url_label"),
|
||||
"name": "url",
|
||||
"default": data.get("url", "https://voxvera.org/"),
|
||||
"default": data.get("url", "https://voxvera.org/") if not data.get("url", "").startswith("download/") else "",
|
||||
"when": lambda result: not result.get("attachment_path", "").strip(),
|
||||
"transformer": _len_transform(200),
|
||||
"validate": _len_validator(200),
|
||||
},
|
||||
@@ -391,21 +412,6 @@ def interactive_update(config_path: str):
|
||||
"transformer": _len_transform(120),
|
||||
"validate": _len_validator(120),
|
||||
},
|
||||
|
||||
{
|
||||
"type": "input",
|
||||
"message": t("cli.binary_message_label"),
|
||||
"name": "binary_message",
|
||||
"default": data.get("binary_message", ""),
|
||||
"transformer": _len_transform(120),
|
||||
"validate": _len_validator(120),
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"message": t("cli.attachment_path_label"),
|
||||
"name": "attachment_path",
|
||||
"default": data.get("attachment_path", ""),
|
||||
},
|
||||
]
|
||||
data.update(prompt(link_qs))
|
||||
|
||||
@@ -542,25 +548,23 @@ def build_assets(
|
||||
val_str = re.sub(r"~~(.*?)~~", r'<span class="redacted">\1</span>', val_str)
|
||||
html = html.replace(f"{{{{{key}}}}}", val_str)
|
||||
|
||||
# Handle optional file attachment
|
||||
# Handle optional file attachment logic
|
||||
attachment_path = data.get("attachment_path")
|
||||
attachment_url = ""
|
||||
attachment_display = "none"
|
||||
if attachment_path and os.path.isfile(attachment_path):
|
||||
att_filename = os.path.basename(attachment_path)
|
||||
attachment_url = f"download/{att_filename}"
|
||||
attachment_display = "inline-block"
|
||||
|
||||
html = html.replace("{{attachment_url}}", attachment_url)
|
||||
html = html.replace("{{attachment_display}}", attachment_display)
|
||||
|
||||
# Only default to the local attachment path if no onion URL is set
|
||||
current_url = data.get("url", "")
|
||||
if not (current_url.startswith("http") and ".onion" in current_url):
|
||||
data["url"] = f"download/{att_filename}"
|
||||
save_config(data, config_path)
|
||||
html = html.replace("{{url}}", data["url"])
|
||||
|
||||
folder_name = data["folder_name"]
|
||||
dest = DATA_DIR / "host" / folder_name
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
|
||||
# 1. Refresh QR codes directly in destination
|
||||
# generate_qr only creates them if URLs are present in config
|
||||
from voxvera.build import generate_qr
|
||||
|
||||
# We generate to a temp dir first to ensure we ONLY get what's in current config
|
||||
import tempfile
|
||||
@@ -665,24 +669,29 @@ def _internal_onionshare():
|
||||
bundled_tor = tor_dir / ("tor.exe" if "windows" in system else "tor")
|
||||
if bundled_tor.exists() and bundled_tor.stat().st_size > 100:
|
||||
os.environ["PATH"] = str(tor_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
# Robustness: explicitly tell OnionShare where Tor is
|
||||
os.environ["TOR_BINARY"] = str(bundled_tor)
|
||||
|
||||
onionshare_args = sys.argv[2:] # strip 'voxvera _internal_onionshare'
|
||||
|
||||
# Always prefer the system onionshare-cli binary. This avoids
|
||||
# pkg_resources / setuptools isolation issues in both PyInstaller
|
||||
# builds and pipx venvs.
|
||||
onionshare_bin = shutil.which("onionshare-cli") or shutil.which("onionshare")
|
||||
if onionshare_bin:
|
||||
os.execvp(onionshare_bin, [onionshare_bin] + onionshare_args)
|
||||
# execvp replaces the process; unreachable unless execvp itself fails
|
||||
|
||||
# Last resort: try to import onionshare_cli as a Python module
|
||||
try:
|
||||
from onionshare_cli import main
|
||||
# sys.argv is ['voxvera', '_internal_onionshare', '--website', ... ]
|
||||
# We need to make it look like ['onionshare-cli', '--website', ... ]
|
||||
sys.argv = ["onionshare-cli"] + sys.argv[2:]
|
||||
sys.argv = ["onionshare-cli"] + onionshare_args
|
||||
sys.exit(main())
|
||||
except ImportError as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"Error: onionshare-cli not bundled or installed correctly: {e}", file=sys.stderr)
|
||||
print(f"DEBUG: sys.path = {sys.path}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Error: onionshare-cli not available: {e}", file=sys.stderr)
|
||||
print("Install onionshare-cli via your system package manager.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
def serve(config_path: str) -> str | None:
|
||||
socks, ctl = get_tor_ports()
|
||||
|
||||
@@ -698,11 +707,7 @@ def serve(config_path: str) -> str | None:
|
||||
env["TOR_SOCKS_PORT"] = socks
|
||||
env["TOR_CONTROL_PORT"] = ctl
|
||||
|
||||
# Run the bundled onionshare-cli via our hidden command
|
||||
# sys.argv[0] is the current executable (python script or PyInstaller binary)
|
||||
cmd = [
|
||||
sys.argv[0],
|
||||
"_internal_onionshare",
|
||||
onionshare_args = [
|
||||
"--website",
|
||||
"--public",
|
||||
"--persistent",
|
||||
@@ -714,10 +719,16 @@ def serve(config_path: str) -> str | None:
|
||||
str(dir_path),
|
||||
]
|
||||
|
||||
# If running via 'python -m voxvera.cli', sys.argv[0] might be the script path,
|
||||
# so we should ensure it's executed correctly if not a PyInstaller binary.
|
||||
if not getattr(sys, 'frozen', False) and sys.argv[0].endswith('.py'):
|
||||
cmd = [sys.executable, sys.argv[0]] + cmd[1:]
|
||||
# Always prefer the system onionshare-cli binary to avoid pkg_resources
|
||||
# and dependency-isolation issues inside both PyInstaller and pipx.
|
||||
onionshare_bin = shutil.which("onionshare-cli") or shutil.which("onionshare")
|
||||
if onionshare_bin:
|
||||
cmd = [onionshare_bin] + onionshare_args
|
||||
else:
|
||||
# Fallback: route through our hidden subcommand
|
||||
cmd = [sys.argv[0], "_internal_onionshare"] + onionshare_args
|
||||
if not getattr(sys, 'frozen', False) and sys.argv[0].endswith('.py'):
|
||||
cmd = [sys.executable] + cmd
|
||||
|
||||
log_fh = open(logfile, "w")
|
||||
proc = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT, env=env)
|
||||
@@ -805,9 +816,9 @@ def get_export_dir() -> Path:
|
||||
|
||||
def export_site(folder_name: str, output_path: str = None):
|
||||
"""Export a site folder and its keys into a zip archive."""
|
||||
source_dir = ROOT / "host" / folder_name
|
||||
source_dir = DATA_DIR / "host" / folder_name
|
||||
if not source_dir.exists():
|
||||
print(f"Error: Site folder '{folder_name}' not found in {ROOT / 'host'}")
|
||||
print(f"Error: Site folder '{folder_name}' not found in {DATA_DIR / 'host'}")
|
||||
return
|
||||
|
||||
if not output_path:
|
||||
@@ -863,7 +874,7 @@ def import_site(zip_path: str):
|
||||
print("Error: Invalid config.json. Missing 'folder_name'.")
|
||||
return
|
||||
|
||||
dest_dir = ROOT / "host" / folder_name
|
||||
dest_dir = DATA_DIR / "host" / folder_name
|
||||
if dest_dir.exists():
|
||||
confirm = inquirer.confirm(message=f"Site '{folder_name}' already exists. Overwrite?").execute()
|
||||
if not confirm:
|
||||
@@ -1118,20 +1129,30 @@ def batch_import_configs():
|
||||
dest_config.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _seed_bundled_sites():
|
||||
"""Copy bundled example sites from ROOT/host/ to DATA_DIR/host/ if they
|
||||
don't already exist there. This runs once — after that, only DATA_DIR is
|
||||
the source of truth so deletes actually stick."""
|
||||
bundled_host = ROOT / "host"
|
||||
if not bundled_host.exists() or ROOT == DATA_DIR:
|
||||
return
|
||||
dest_host = DATA_DIR / "host"
|
||||
for src in bundled_host.iterdir():
|
||||
if src.is_dir() and (src / "config.json").exists():
|
||||
dest = dest_host / src.name
|
||||
if not dest.exists():
|
||||
shutil.copytree(src, dest)
|
||||
|
||||
|
||||
def get_servers() -> list[str]:
|
||||
# Check both bundled ROOT/host and local DATA_DIR/host
|
||||
search_paths = []
|
||||
if ROOT != DATA_DIR:
|
||||
search_paths.append(ROOT / "host")
|
||||
search_paths.append(DATA_DIR / "host")
|
||||
|
||||
servers = set()
|
||||
for host_dir in search_paths:
|
||||
if host_dir.exists():
|
||||
for d in host_dir.iterdir():
|
||||
if d.is_dir() and (d / "config.json").exists():
|
||||
servers.add(d.name)
|
||||
return sorted(list(servers))
|
||||
"""Return server names found in DATA_DIR/host only."""
|
||||
host_dir = DATA_DIR / "host"
|
||||
servers = []
|
||||
if host_dir.exists():
|
||||
for d in host_dir.iterdir():
|
||||
if d.is_dir() and (d / "config.json").exists():
|
||||
servers.append(d.name)
|
||||
return sorted(servers)
|
||||
|
||||
|
||||
def is_server_running(folder_name: str) -> bool:
|
||||
@@ -1207,6 +1228,9 @@ def manage_servers():
|
||||
from InquirerPy.base.control import Choice
|
||||
console = Console()
|
||||
|
||||
# On first run, copy any bundled example sites so they're user-editable.
|
||||
_seed_bundled_sites()
|
||||
|
||||
while True:
|
||||
servers = get_servers()
|
||||
|
||||
|
||||
@@ -579,9 +579,6 @@
|
||||
<a id="download-link" href="download/voxvera-source.zip" download>
|
||||
<button class="download-button" data-i18n="web.download_button">{{t_web_download_button}}</button>
|
||||
</a>
|
||||
<a id="attachment-link" href="{{attachment_url}}" download style="display: {{attachment_display}};">
|
||||
<button class="download-button" data-i18n="web.download_attachment">{{t_web_download_attachment}}</button>
|
||||
</a>
|
||||
<select class="lang-select" id="lang-selector" onchange="setLanguage(this.value)"></select>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user