Add VoxVera Nostr static client

This commit is contained in:
thePR0M3TH3AN committed 2026-06-04 20:15:50 -04:00
1 parent 285d730369
commit d415eb26a3
14 files changed
+12651 -11

No files matched your search

+480
View File
@@ -0,0 +1,480 @@
# VoxVera Nostr Static Client Spec
Status: Phase 1 CLI import implemented for local event JSON files. A first static Editor/Viewer client exists under `site/nostr/`; relay publish/fetch uses browser WebSockets. Viewer mode is flyer-first and keeps Nostr controls hidden behind a small drawer toggle. Browser-side QR generation is implemented with a vendored local QR library. Browser-side anonymous key generation/signing is implemented with a vendored Nostr library, so publishing does not require a NIP-07 login. Independent browser-side signature verification before accepting fetched events is pending.
Branch: `nostr-static-client`
## Purpose
Build a Nostr-powered VoxVera mode where public Nostr events store flyer content and any static/offline VoxVera client can fetch, validate, and render that content into the existing VoxVera flyer.
The generated flyer frontend must remain visually and behaviorally identical to the current VoxVera flyer. Nostr is an input/source layer, not a redesign.
## Core Principle
```text
Nostr event -> validate -> normalize to existing config.json -> existing VoxVera build/render path
```
The existing flyer template, QR layout, tear-off layout, typography, print behavior, download behavior, and Tor-safe static output stay unchanged.
## Goals
- Let anyone host the VoxVera client as static files, over the web, Tor, IPFS, local disk, USB drive, or other mirrors.
- Let Nostr relays store the flyer content as public events.
- Let the client fetch a VoxVera flyer event by `naddr`, `note1`, `nevent1`, raw event ID, or URL fragment.
- Let the CLI import/build/serve a Nostr-backed flyer without changing the generated flyer frontend.
- Generate QR codes locally from validated content.
- Keep the default QR behavior aligned with current VoxVera: QR points to the generated flyer URL unless explicitly configured otherwise.
- Support fully offline rendering once a Nostr event payload has been exported or cached.
## Non-Goals For V1
- No redesign of `index-master.html`.
- No new visual style for the flyer.
- No server-side backend.
- No custody of user-managed Nostr keys.
- No required login or NIP-07 signer.
- No private/encrypted Nostr events.
- No remote images, fonts, scripts, CSS, CDNs, analytics, or trackers.
- No raw HTML injection from Nostr content.
- No dependency on a single relay.
## User Flows
### Static Client Flow
1. User opens a static VoxVera client.
2. User pastes a `naddr`, `note1`, `nevent1`, raw event ID, or URL containing one.
3. Client chooses relays from the identifier, user settings, or defaults.
4. Client fetches the event.
5. Client validates the event as a VoxVera flyer source.
6. Client normalizes content into the existing VoxVera config schema.
7. Client renders the existing flyer UI and generates QR codes locally.
8. User prints, exports, saves, or hosts the generated flyer.
Viewer mode must default to the rendered flyer, not the editor shell. Header text, event forms, normalized config JSON, and other loader UI stay hidden unless the user opens the viewer controls drawer. Printing from viewer mode must target only the 8.5x11 flyer surface.
Viewer mode must auto-load content when the URL includes an event reference. Supported forms include:
- `?addr=<naddr>`
- `?event=<raw-id-or-note1-or-nevent1-or-naddr>`
- `?id=<raw-64-character-event-id>`
- `#viewer/<raw-id-or-note1-or-nevent1-or-naddr>`
- pasted `nostr:note1...`, `nostr:nevent1...`, or `nostr:naddr...` values in the viewer lookup tool
The manual viewer lookup tool must accept the same raw ID, `note1`, `nevent1`, `naddr`, and `nostr:` values. `nevent1` and `naddr` relay hints should be merged with the default/user relay list.
The canonical poster URL should use `naddr`, not the raw event ID, because the flyer itself needs to include a URL that resolves back to the poster. Raw event IDs are hashes of the final event payload, so embedding a raw event-ID URL in the same event creates a circular dependency. A `naddr` is stable because it is derived from author pubkey, kind `30078`, and the `d` tag.
### CLI Flow
```bash
voxvera nostr import <note-or-nevent>
voxvera nostr build <note-or-nevent>
voxvera nostr serve <note-or-nevent>
```
Expected behavior:
- `import` writes a normal site folder under `~/host/<folder_name>/` with a standard `config.json`.
- `build` imports and builds the flyer assets.
- `serve` imports, builds, and runs the existing OnionShare hosting path.
The CLI should preserve the current config/build/serve contract so Nostr flyers can be exported, imported, and mirrored by existing VoxVera mechanisms.
## Event Format
V1 should use a deterministic JSON payload in event `content`.
Recommended Nostr event kind for prototype: `30078` parameterized replaceable event, with a `d` tag.
Rationale:
- It avoids inventing a custom kind before the format proves itself.
- It allows stable named flyer records per author.
- It can later be migrated to a dedicated custom kind if needed.
Required tags:
```json
[
["d", "voxvera:<slug-or-id>"],
["t", "voxvera"],
["t", "flyer"]
]
```
Optional tags:
```json
[
["title", "OPERATION VOX VERA"],
["language", "en"],
["subject", "censorship"],
["relay", "wss://relay.example.org"]
]
```
Content schema:
```json
{
"type": "voxvera_flyer",
"version": 1,
"folder_name": "voxvera",
"lang": "en",
"name": "Vox Vera Printable Flyers",
"title": "TOP SECRET",
"subtitle": "DO ~~NOT~~ DISTRIBUTE",
"headline": "OPERATION VOX VERA",
"content": "Main flyer text.",
"url_message": "Follow this link to learn more.",
"url": "https://example.com/",
"footer_message": "0110010 0101011 0110010 0111101 0110100",
"attachment_path": "",
"attachment_filename": "",
"qr_target": "flyer_url"
}
```
`folder_name` remains the internal compatibility key for the existing VoxVera config/build path, but the static Nostr editor should label this field as `Flyer Name`. In Nostr mode it acts as the stable flyer slug used in the replaceable event `d` tag, not as a local Tor hosting folder name.
Allowed `qr_target` values:
- `flyer_url`: default; current VoxVera behavior.
- `nostr_event`: QR points to the source Nostr identifier.
- `content_url`: QR points to the `url` field.
The static Nostr editor should default to `flyer_url`, where the flyer URL is the hosted client URL with `?addr=<naddr>`. That avoids the raw event-ID circular dependency and lets a re-published replaceable event keep the same printed flyer URL.
## Normalization Rules
The importer maps the Nostr payload to the existing `config.json` schema.
Rules:
- Missing optional fields use current VoxVera defaults.
- Unknown fields are ignored unless explicitly preserved in metadata.
- `folder_name` / editor `Flyer Name` must be slug-safe and length-limited.
- `lang` must be one of the supported locale keys or fall back to `en`.
- Text fields must be strings, not arrays or objects.
- Text fields must have length limits.
- URLs must be validated before use.
- Attachments are disabled for V1 unless represented as ordinary links.
- The normalized config must be buildable by the current `build_assets` path.
- The static Nostr viewer should preserve the old generated site's multilingual token behavior: `landing.*` and `web.*` token paths should resolve through the existing locale dictionaries, with browser/default language selection and RTL handling where applicable. This is not fully implemented in the first static preview renderer yet.
Suggested field limits:
- `folder_name`: 64 characters.
- `name`: 120 characters.
- `title`: 80 characters.
- `subtitle`: 120 characters.
- `headline`: 160 characters.
- `content`: 10000 characters.
- `url_message`: 240 characters.
- `url`: 2048 characters.
- `footer_message`: 240 characters.
## Frontend Contract
The flyer frontend must continue to use the existing generated HTML and print surface.
Allowed changes:
- Add a separate loader/import page or app shell.
- Add client-side Nostr fetch code in the loader.
- Add local QR generation in the loader or build path.
- Add metadata outside the flyer if needed.
- Add minimal viewer controls outside the flyer, preferably as a hidden drawer or compact toggle.
- Add anonymous local key generation/signing in the editor shell.
Disallowed changes:
- Changing the visual layout of the generated flyer.
- Adding visible Nostr branding to the flyer by default.
- Adding JavaScript dependency to the generated flyer.
- Adding remote assets to the generated flyer.
- Injecting raw Nostr content as HTML.
- Printing the loader shell, event form, normalized config JSON, or preview chrome.
## Static Client Architecture
Recommended structure:
```text
site/nostr/
index.html
nostr-client.js
nostr-schema.js
nostr-render.js
vendor/
qrcode-generator/
nostr-tools/
```
The static client should:
- Be self-contained.
- Work from `file://` where practical.
- Work with JavaScript enabled for fetching/rendering.
- Produce/export a final flyer that remains static and Tor-safe.
- Avoid service workers in V1.
- Open `/nostr/#viewer` into a flyer-only viewer surface with a small control toggle.
- Keep `/nostr/#editor` as the form-based event authoring surface.
- Publish with a locally generated anonymous `npub` by default, stored only in browser local storage.
- Allow regenerating/rotating the anonymous `npub` from the editor.
The generated flyer remains separate from the loader:
```text
loader client: interactive, fetches Nostr
generated flyer: static, print-safe, JS-free
```
## Relay Strategy
Inputs:
- Relays embedded in `naddr`, when present.
- Relays embedded in `nevent1`, when present.
- User-provided relay list.
- Built-in fallback relay list.
Rules:
- Query multiple relays in parallel.
- Resolve `naddr` by querying kind `30078` with the addressed author pubkey and `d` tag, then use the newest matching replaceable event returned.
- Use the first valid matching event, then continue briefly to detect conflicts.
- Prefer exact event ID matches over replaceable address matches.
- Show event author, event ID, creation time, and relays used in the loader.
- Do not bake relay-specific state into the generated flyer unless explicitly requested.
## Security Model
Nostr content is untrusted external input.
Required protections:
- Parse JSON with strict schema validation.
- Escape all rendered text.
- Reject raw HTML/script fields.
- Do not load remote assets referenced by events.
- Validate URL schemes: allow `https`, `http`, `onion` URLs via `http://*.onion`, and Nostr identifiers. Reject `javascript:`, `data:`, and similar schemes.
- Put strict length limits on all fields.
- Treat relay responses as untrusted even if signed.
- Verify event signatures before accepting content.
- Do not require users to enter private keys for V1.
- If the browser editor stores an anonymous generated signing key in local storage, label it as a disposable local identity and provide a rotation path.
## Privacy Model
Fetching from public relays leaks interest in an event to those relays and network observers.
Mitigations:
- Allow custom relay lists.
- Allow Tor Browser usage as an optional transport choice.
- Allow offline import from pasted event JSON.
- Document that using public relays is not private.
- Do not phone home to VoxVera infrastructure.
## CLI Architecture
Suggested module split:
```text
voxvera/nostr/
__init__.py
ids.py # parse note1/nevent1/event IDs
relays.py # fetch events
schema.py # validate payloads
importers.py # normalize to config
qrcode.py # QR target selection helpers if needed
```
CLI command group:
```bash
voxvera nostr import <identifier> [--relay wss://...] [--folder-name ...]
voxvera nostr build <identifier> [--relay wss://...]
voxvera nostr serve <identifier> [--relay wss://...]
voxvera nostr validate <file-or-identifier>
```
Implementation should keep Nostr code behind a command group so the current non-Nostr workflow remains stable.
## Browser Dependency Strategy
Prefer a small, vendored Nostr client implementation or a carefully vendored library.
Requirements:
- No CDN.
- No dynamic remote imports.
- Browser bundle stored in repo/package.
- CLI dependency either optional extra or small pure-Python implementation.
- Reproducible vendoring instructions.
Candidate directions:
- Browser: vendored `nostr-tools` bundle or a minimal NIP-01/NIP-19 fetch/verify implementation.
- Python CLI: optional `websockets` plus signature verification, or shell out to a bundled helper only if packaging remains clean.
Decision should happen during implementation after checking package size and offline portability impact.
## QR Behavior
V1 default:
- QR and tear-offs point to the generated poster URL, matching current VoxVera behavior without requiring Tor-specific wording.
Optional future modes:
- QR points to source Nostr event.
- QR points to original content URL.
- Flyer includes a separate source QR outside the current print layout.
Any QR mode that changes visible flyer output must be opt-in.
## Testing Plan
Unit tests:
- Parse `note1`, `nevent1`, raw IDs.
- Validate good schema.
- Reject invalid JSON.
- Reject wrong `type`.
- Reject unsupported `version`.
- Reject unsafe URL schemes.
- Enforce field length limits.
- Normalize payload to current config schema.
- Preserve current build output for existing configs.
Integration tests:
- Import fixture event into `~/host/<folder>/config.json`.
- Build imported fixture and compare required output files.
- Serve imported fixture through existing serve path with mocks.
- Static client fixture renders same fields as CLI normalization.
Security tests:
- Script tag in content is escaped.
- HTML event handler attributes are escaped.
- `javascript:` URL rejected.
- Oversized payload rejected.
- Unknown fields ignored.
Manual validation:
- Open static loader from local file.
- Fetch known fixture event from test relay or local mock relay.
- Render/print flyer.
- Verify generated flyer remains usable with JavaScript disabled.
- Verify Tor Browser Safest mode compatibility for generated flyer.
## Implementation Phases
### Phase 0: Design Lock
- Finalize this spec.
- Decide event kind strategy.
- Decide dependency strategy.
- Create fixture events.
- Define schema in code comments/docs before implementation.
### Phase 1: CLI Import
- Add `voxvera nostr validate`.
- Add `voxvera nostr import`.
- Use fixture JSON first.
- Normalize into existing `config.json`.
- Add tests.
Implemented initial local-file commands:
```bash
voxvera nostr validate ./event.json
voxvera nostr import ./event.json
voxvera nostr build ./event.json
voxvera nostr serve ./event.json
```
Phase 1 intentionally rejects non-file `note1` / `nevent1` identifiers with a clear message. Relay fetch, NIP-19 parsing, and signature verification belong to Phase 2.
### Phase 2: Relay Fetch
- Add relay fetching for public events.
- Add NIP-19 parsing.
- Add signature verification.
- Add relay selection options.
- Add tests with mocked relay responses.
### Phase 3: Static Loader
- Add self-contained static loader under `site/nostr/`.
- Fetch event by identifier.
- Validate and normalize payload.
- Render existing flyer output or produce config for existing render path.
- Add export/download option.
Initial implementation exists:
- `site/nostr/index.html`
- `site/nostr/nostr-client.css`
- `site/nostr/nostr-client.js`
Current behavior:
- Editor mode collects existing VoxVera flyer fields.
- Editor can export unsigned kind `30078` event JSON.
- Editor can sign/publish through a NIP-07 browser signer and returns the signed event ID.
- Viewer mode fetches a raw 64-character event ID from configured relays.
- Viewer normalizes event content into existing VoxVera config JSON.
- Preview is client-side and print-oriented, but it is not yet the final generated flyer artifact.
Pending hardening:
- Vendor or implement browser-side QR generation without CDN usage.
- Add independent event ID/signature verification in the browser.
- Decide whether the static viewer should generate a full standalone flyer HTML file for download.
### Phase 4: QR Modes
- Keep default QR as generated flyer URL.
- Add optional `qr_target` handling.
- Add tests and docs.
### Phase 5: Publishing Support
Deferred.
Possible future commands:
```bash
voxvera nostr publish config.json
voxvera nostr sign config.json
```
This requires a separate key-management spec.
## Open Questions
- Should V1 use kind `30078`, a custom kind, or both?
- Should replaceable events be accepted by default, or should exact immutable event IDs be preferred for anti-tamper stability?
- Should the static loader render HTML directly in browser, or should it generate/download a standard VoxVera project bundle?
- What relay list should be bundled by default, if any?
- Should `qr_target: nostr_event` encode `nevent1` with relay hints or a bare `note1`?
- Should the generated flyer include source attribution metadata in comments only, or not at all?
## Acceptance Criteria
- Existing non-Nostr VoxVera workflow continues to pass tests.
- Existing generated flyer visual output is unchanged for normal configs.
- Nostr fixture event can be validated, imported, built, and served through existing paths.
- Static loader is self-contained and does not use remote dependencies.
- Generated flyer remains static and JS-free.
- Unsafe Nostr payloads are rejected or escaped.
- Docs explain relay privacy tradeoffs.
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VoxVera Nostr Client</title>
<link rel="stylesheet" href="nostr-client.css">
</head>
<body>
<header class="topbar">
<div>
<h1>VoxVera Nostr Client</h1>
<p>Static editor and viewer for decentralized flyer source events.</p>
</div>
<nav class="mode-tabs" aria-label="Client mode">
<button class="tab active" type="button" data-mode="editor">Editor</button>
<button class="tab" type="button" data-mode="viewer">Viewer</button>
</nav>
</header>
<div class="viewer-floating" aria-label="Viewer controls">
<button type="button" id="open-viewer-controls">Load Event</button>
<button type="button" id="viewer-print">Print</button>
<button type="button" id="viewer-editor">Editor</button>
</div>
<main>
<section id="editor-panel" class="panel active" aria-labelledby="editor-heading">
<div class="workspace">
<form id="editor-form" class="editor-form">
<div class="section-title">
<h2 id="editor-heading">Editor</h2>
<span id="signer-state" class="state-pill">Checking signer</span>
</div>
<label>
Relays
<textarea id="editor-relays" rows="3" spellcheck="false"></textarea>
</label>
<div class="identity-row">
<span id="identity-state" class="state-pill">Checking identity</span>
<button type="button" id="generate-anon-identity" class="secondary-button">Generate anonymous npub</button>
</div>
<div class="field-grid">
<label>
Flyer Name
<input id="field-folder-name" name="folder_name" maxlength="64" value="voxvera">
</label>
<label>
Language
<select id="field-lang" name="lang">
<option value="en">en</option>
<option value="es">es</option>
<option value="de">de</option>
<option value="fr">fr</option>
<option value="ru">ru</option>
<option value="he">he</option>
<option value="ar">ar</option>
<option value="ja">ja</option>
<option value="hi">hi</option>
<option value="sw">sw</option>
<option value="pt">pt</option>
<option value="tr">tr</option>
<option value="zh">zh</option>
<option value="fa">fa</option>
</select>
</label>
</div>
<label>
Page title
<input id="field-name" name="name" maxlength="120" value="Vox Vera Printable Flyers">
</label>
<label>
Title
<input id="field-title" name="title" maxlength="80" value="TOP SECRET">
</label>
<label>
Subtitle
<input id="field-subtitle" name="subtitle" maxlength="120" value="DO ~~NOT~~ DISTRIBUTE">
</label>
<label>
Headline
<input id="field-headline" name="headline" maxlength="160" value="OPERATION VOX VERA">
</label>
<label>
Content
<textarea id="field-content" name="content" rows="9" maxlength="10000"></textarea>
</label>
<label>
URL message
<input id="field-url-message" name="url_message" maxlength="240" value="Follow this link to learn more.">
</label>
<label>
Poster URL
<input id="field-url" name="url" maxlength="2048" value="https://voxvera.org/" readonly>
</label>
<label>
Footer message
<input id="field-footer-message" name="footer_message" maxlength="240" value="0110010 0101011 0110010 0111101 0110100">
</label>
<div class="actions">
<button type="button" id="preview-editor">Preview</button>
<button type="button" id="export-event">Export event JSON</button>
<button type="submit" id="publish-event">Sign and publish</button>
</div>
</form>
<aside class="result-panel">
<h2>Published Event</h2>
<dl class="result-list">
<dt>Event ID</dt>
<dd class="copy-value"><code id="event-id-output">Not published</code><button type="button" class="copy-button" data-copy-target="event-id-output">Copy</button></dd>
<dt>Author npub</dt>
<dd class="copy-value"><code id="author-npub-output">Not generated</code><button type="button" class="copy-button" data-copy-target="author-npub-output">Copy</button></dd>
<dt>Nostr address</dt>
<dd class="copy-value"><code id="naddr-output">Not generated</code><button type="button" class="copy-button" data-copy-target="naddr-output">Copy</button></dd>
<dt>Poster URL</dt>
<dd class="copy-value"><code id="poster-url-output">Not generated</code><button type="button" class="copy-button" data-copy-target="poster-url-output">Copy</button></dd>
<dt>Status</dt>
<dd id="publish-status">Idle</dd>
</dl>
<textarea id="event-json-output" rows="14" spellcheck="false" readonly></textarea>
</aside>
</div>
</section>
<section id="viewer-panel" class="panel viewer-drawer" aria-labelledby="viewer-heading">
<div class="workspace">
<form id="viewer-form" class="editor-form">
<div class="section-title">
<h2 id="viewer-heading">Viewer</h2>
<button type="button" id="close-viewer-controls" class="secondary-button">Close</button>
</div>
<label>
Event ID
<input id="viewer-event-id" spellcheck="false" placeholder="naddr, event id, note1, nevent1, or nostr: URL">
</label>
<label>
Relays
<textarea id="viewer-relays" rows="3" spellcheck="false"></textarea>
</label>
<div class="actions">
<button type="submit">Fetch and render</button>
<button type="button" id="copy-viewer-config">Copy config JSON</button>
</div>
<p id="viewer-status" class="status-line">Idle</p>
</form>
<aside class="result-panel">
<h2>Normalized Config</h2>
<textarea id="viewer-config-output" rows="16" spellcheck="false" readonly></textarea>
</aside>
</div>
</section>
<section class="preview-band" aria-label="Flyer preview">
<div class="preview-toolbar">
<h2>Preview</h2>
<button type="button" id="print-preview">Print preview</button>
</div>
<div id="flyer-preview" class="flyer-preview"></div>
</section>
</main>
<script src="vendor/qrcode-generator/qrcode.js"></script>
<script src="vendor/nostr-tools/nostr.bundle.js"></script>
<script src="nostr-client.js"></script>
</body>
</html>
+702
View File
@@ -0,0 +1,702 @@
:root {
color-scheme: light;
--ink: #111;
--muted: #5b5b5b;
--line: #c9c9c9;
--soft: #f3f3f1;
--accent: #b80000;
--ok: #1d6b3a;
--warn: #8a4d00;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background: #fff;
font-family: "Courier New", Courier, monospace;
line-height: 1.45;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
min-height: 40px;
padding: 8px 14px;
border: 1px solid #000;
background: #000;
color: #fff;
cursor: pointer;
}
button:hover {
background: #333;
}
.secondary-button {
background: #fff;
color: #000;
}
.secondary-button:hover {
background: #eee;
}
.topbar {
display: flex;
gap: 24px;
justify-content: space-between;
align-items: flex-end;
padding: 20px clamp(16px, 4vw, 42px);
border-bottom: 2px solid #000;
}
.topbar h1,
.topbar p,
.section-title h2,
.preview-toolbar h2 {
margin: 0;
}
.topbar h1 {
font-size: 24px;
}
.topbar p {
color: var(--muted);
}
.mode-tabs {
display: inline-flex;
border: 1px solid #000;
}
.tab {
border: 0;
border-right: 1px solid #000;
background: #fff;
color: #000;
}
.tab:last-child {
border-right: 0;
}
.tab.active {
background: #000;
color: #fff;
}
main {
padding: 18px clamp(16px, 4vw, 42px) 36px;
}
.panel {
display: none;
}
.panel.active {
display: block;
}
.viewer-floating {
display: none;
}
.workspace {
display: grid;
grid-template-columns: minmax(320px, 1.15fr) minmax(280px, 0.85fr);
gap: 24px;
align-items: start;
}
.editor-form,
.result-panel {
min-width: 0;
}
.section-title,
.preview-toolbar {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.state-pill {
display: inline-block;
padding: 4px 8px;
border: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
}
.identity-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 0 0 12px;
}
.identity-row .secondary-button {
min-height: 32px;
padding: 5px 10px;
font-size: 12px;
}
.editor-form label {
display: grid;
gap: 5px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 700;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #000;
border-radius: 0;
background: #fff;
color: #000;
padding: 8px;
}
textarea {
resize: vertical;
}
.field-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 12px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.result-list {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
gap: 8px 12px;
margin: 0 0 12px;
}
.result-list dt {
font-weight: 700;
}
.result-list dd {
margin: 0;
min-width: 0;
}
.copy-value {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.copy-button {
min-height: 28px;
padding: 4px 8px;
font-size: 11px;
}
.relay-results {
display: grid;
gap: 4px;
}
.relay-ok {
color: var(--ok);
font-weight: 700;
}
.relay-error {
color: var(--warn);
}
code {
display: block;
overflow-wrap: anywhere;
white-space: normal;
}
.status-line {
min-height: 22px;
color: var(--muted);
}
.preview-band {
margin-top: 26px;
border-top: 2px solid #000;
padding-top: 18px;
}
.flyer-preview {
overflow-x: auto;
padding-bottom: 8px;
}
.flyer-sheet {
display: flex;
width: 8.5in;
height: 11in;
border: 1px solid #000;
background: #fff;
color: #000;
overflow: hidden;
}
.flyer-sheet.no-tear-offs .tear-offs {
display: none;
}
.flyer-sheet.no-tear-offs .flyer-content {
width: 100%;
}
.tear-offs {
width: 3.75in;
padding: 8px;
border-right: 1px dashed #000;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 5px;
}
.tear-off {
display: flex;
gap: 12px;
height: 1in;
padding: 5px;
border: 1px dashed #000;
font-size: 10px;
overflow: hidden;
}
.qr-box {
flex: 0 0 0.72in;
width: 0.72in;
height: 0.72in;
border: 1px solid #000;
display: grid;
place-items: center;
font-size: 9px;
text-align: center;
}
.flyer-content {
width: 4.75in;
padding: 0.28in;
display: flex;
flex-direction: column;
justify-content: flex-start;
text-align: center;
}
.flyer-content h1 {
margin: 0 0 0.18in;
font-size: 34px;
line-height: 1.05;
}
.flyer-content .subtitle {
margin-bottom: 0.16in;
color: var(--accent);
font-weight: 700;
font-size: 21px;
}
.message {
margin-top: 0.12in;
text-align: left;
font-size: 15px;
white-space: pre-wrap;
}
.main-qr {
align-self: center;
margin-top: 0.18in;
width: 1.25in;
height: 1.25in;
}
.footer {
margin-top: auto;
font-size: 11px;
overflow-wrap: anywhere;
}
a {
color: var(--accent);
}
.container {
display: flex;
flex-direction: row;
width: 8.5in;
height: 11in;
border: 1px solid #000;
background: #fff;
color: #000;
box-sizing: border-box;
overflow: hidden;
page-break-inside: avoid;
}
.container.no-tear-offs .left-tear-offs {
display: none;
}
.container.no-tear-offs .content {
width: 100%;
}
.left-tear-offs {
width: 3.75in;
padding: 8px;
border-right: 1px dashed #000;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 5px;
box-sizing: border-box;
}
.tear-off-text {
flex: 1;
min-width: 0;
padding-top: 1px;
overflow-wrap: anywhere;
word-break: normal;
hyphens: none;
}
.qr-code {
width: 80px;
height: 80px;
flex-shrink: 0;
}
.qr-code svg {
display: block;
width: 100%;
height: 100%;
}
.qr-error {
display: grid;
width: 100%;
height: 100%;
place-items: center;
border: 1px solid #000;
font-size: 9px;
text-align: center;
}
.qr-placeholder {
width: 80px;
height: 80px;
flex-shrink: 0;
background:
linear-gradient(90deg, #000 8px, transparent 8px 16px, #000 16px 24px, transparent 24px 32px),
linear-gradient(#000 8px, transparent 8px 16px, #000 16px 24px, transparent 24px 32px);
background-color: #fff;
background-size: 32px 32px;
border: 8px solid #000;
}
.content {
width: calc(8.5in - 3.75in);
padding: 15px 20px;
text-align: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
page-break-inside: avoid;
overflow-wrap: break-word;
}
.content h1 {
margin: 0;
padding: 0;
font-size: 24px;
line-height: 1.2;
letter-spacing: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.distribute {
font-size: 18px;
line-height: 1.2;
letter-spacing: 6px;
margin: 2px 0;
padding: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.container .message {
flex: 1;
margin-top: 4px;
font-size: 14px;
line-height: 1.4;
text-align: left;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
white-space: pre-wrap;
box-sizing: border-box;
position: relative;
}
.qr-code-body {
width: 100%;
height: auto;
display: flex;
justify-content: flex-start;
align-items: center;
gap: 10px;
margin-top: 10px;
padding: 0;
box-sizing: border-box;
}
.qr-code-body .main-qr {
width: 80px;
height: 80px;
}
.qr-code-url {
font-size: 12px;
text-align: left;
overflow-wrap: anywhere;
word-break: normal;
hyphens: none;
flex: 1 1 auto;
min-width: 0;
padding-left: 4px;
}
hr {
border: none;
border-top: 1px solid #000;
width: 100%;
margin: 4px 0;
}
.container .footer {
font-size: 10px;
overflow-wrap: break-word;
text-align: center;
align-self: center;
width: 100%;
padding: 10px 0;
box-sizing: border-box;
border-top: 1px solid #000;
}
.container .footer p {
margin: 0;
padding: 0;
}
.container .footer .binary {
margin-bottom: 5px;
margin-top: 20px;
}
.container .footer .credit {
font-size: 12px;
margin-top: 5px;
}
body.mode-viewer {
background: #fff;
}
body.mode-viewer .topbar,
body.mode-viewer #editor-panel,
body.mode-viewer .preview-toolbar {
display: none;
}
body.mode-viewer main {
padding: 0;
}
body.mode-viewer .preview-band {
margin: 0;
padding: 0;
border: 0;
}
body.mode-viewer .flyer-preview {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px 0;
overflow: auto;
}
body.mode-viewer .viewer-floating {
position: fixed;
top: 12px;
right: 12px;
z-index: 20;
display: flex;
gap: 8px;
}
body.mode-viewer .viewer-floating button {
min-height: 34px;
padding: 6px 10px;
font-size: 12px;
}
body.mode-viewer #viewer-panel {
display: none;
}
body.mode-viewer #viewer-panel.drawer-open {
position: fixed;
top: 56px;
right: 12px;
bottom: 12px;
z-index: 19;
display: block;
width: min(680px, calc(100vw - 24px));
overflow: auto;
padding: 16px;
border: 2px solid #000;
background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
body.mode-viewer #viewer-panel.drawer-open .workspace {
display: block;
}
body.mode-viewer #viewer-panel.drawer-open .result-panel {
margin-top: 18px;
}
@media (max-width: 900px) {
.topbar,
.workspace,
.field-grid {
display: block;
}
.mode-tabs {
margin-top: 14px;
}
.result-panel {
margin-top: 22px;
}
.flyer-sheet,
.container {
transform: scale(0.58);
transform-origin: top left;
margin-bottom: -4.5in;
}
body.mode-viewer .flyer-preview {
justify-content: flex-start;
min-height: 0;
}
}
@media print {
@page {
size: Letter portrait;
margin: 0;
}
html,
body {
width: 8.5in;
height: 11in;
margin: 0;
padding: 0;
overflow: hidden;
}
body > header,
body > .viewer-floating,
.viewer-floating,
#open-viewer-controls,
#viewer-print,
#viewer-editor,
body > main > .panel,
.preview-toolbar,
.viewer-drawer {
display: none !important;
}
main {
display: block;
width: 8.5in;
height: 11in;
padding: 0;
margin: 0;
}
.preview-band {
display: block;
width: 8.5in;
height: 11in;
border: 0;
margin: 0;
padding: 0;
}
.flyer-preview {
display: block;
width: 8.5in;
height: 11in;
overflow: visible;
padding: 0;
margin: 0;
}
.flyer-sheet,
.container {
position: absolute;
inset: 0 auto auto 0;
width: 8.5in;
height: 11in;
transform: none;
margin: 0;
border: 0;
}
}
+748
View File
@@ -0,0 +1,748 @@
(function () {
"use strict";
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net"
];
const ANON_SECRET_STORAGE_KEY = "voxvera_nostr_anon_secret_hex";
const EVENT_KIND = 30078;
const FIELD_LIMITS = {
folder_name: 64,
name: 120,
title: 80,
subtitle: 120,
headline: 160,
content: 10000,
url_message: 240,
url: 2048,
footer_message: 240
};
const CONFIG_DEFAULTS = {
name: "Vox Vera Printable Flyers",
folder_name: "voxvera",
lang: "en",
title: "TOP SECRET",
subtitle: "DO ~~NOT~~ DISTRIBUTE",
headline: "OPERATION VOX VERA",
content: `Break free from censorship with VoxVera. An anonymous guerrilla marketing and message-spreading tool. Whether online or in the physical world, it can empower you to spread your ideas boldly, shielded by complete anonymity (if you host over Tor). Download the code, design a flyer site, host it online and amplify your message.
Use memetic power to share your ideas in your school, workplace, online communities, or even globally. VoxVera ensures your message resonates far and wide, with tear-off sections featuring unique URLs and QR codes for easy reprinting.
Privacy can be maintained. Flyers can be shared via the Tor network, protecting hoster and users from censorship.
Join us in a revolution that values truth and transparency. Together, we can build a network of informed citizens who are unafraid to speak out.`,
url_message: "Follow this link to learn more.",
url: "https://voxvera.org/",
tear_off_link: "",
footer_message: "0110010 0101011 0110010 0111101 0110100",
attachment_path: "",
attachment_filename: ""
};
const el = (id) => document.getElementById(id);
function setDefaultText() {
const relayText = DEFAULT_RELAYS.join("\n");
el("editor-relays").value = relayText;
el("viewer-relays").value = relayText;
el("field-content").value = CONFIG_DEFAULTS.content;
renderPreview(CONFIG_DEFAULTS);
refreshSignerState();
refreshIdentityState();
}
function refreshSignerState() {
const state = el("signer-state");
if (window.nostr && typeof window.nostr.signEvent === "function") {
state.textContent = "Anonymous signing is default; NIP-07 detected";
state.style.color = "#1d6b3a";
return;
}
state.textContent = "Anonymous signing available; no NIP-07 login required";
state.style.color = "#1d6b3a";
}
function nostrTools() {
if (!window.NostrTools) {
throw new Error("Nostr tools bundle did not load.");
}
return window.NostrTools;
}
function bytesToHex(bytes) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function hexToBytes(hex) {
if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Invalid anonymous secret key.");
const bytes = new Uint8Array(32);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = parseInt(hex.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
function getStoredAnonSecret() {
try {
const stored = window.localStorage.getItem(ANON_SECRET_STORAGE_KEY);
return stored && /^[a-f0-9]{64}$/i.test(stored) ? stored : "";
} catch (_) {
return "";
}
}
function storeAnonSecret(secretHex) {
try {
window.localStorage.setItem(ANON_SECRET_STORAGE_KEY, secretHex);
} catch (_) {}
}
function getOrCreateAnonIdentity(forceNew = false) {
const tools = nostrTools();
let secretHex = forceNew ? "" : getStoredAnonSecret();
if (!secretHex) {
secretHex = bytesToHex(tools.generateSecretKey());
storeAnonSecret(secretHex);
}
const secretKey = hexToBytes(secretHex);
const pubkey = tools.getPublicKey(secretKey);
const npub = tools.nip19.npubEncode(pubkey);
const nsec = tools.nip19.nsecEncode(secretKey);
return { secretKey, secretHex, pubkey, npub, nsec };
}
function refreshIdentityState() {
const state = el("identity-state");
const output = el("author-npub-output");
if (!state || !output) return;
try {
const secretHex = getStoredAnonSecret();
if (!secretHex) {
state.textContent = "No anonymous npub yet";
state.style.color = "#8a4d00";
output.textContent = "Not generated";
return;
}
const identity = getOrCreateAnonIdentity(false);
state.textContent = "Anonymous npub ready";
state.style.color = "#1d6b3a";
output.textContent = identity.npub;
updatePosterAddressOutputs();
} catch (error) {
state.textContent = error.message;
state.style.color = "#8a4d00";
}
}
function parseRelays(value) {
return value
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean)
.filter((item, index, list) => item.startsWith("wss://") && list.indexOf(item) === index);
}
function slugify(value) {
const slug = String(value || "voxvera")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, FIELD_LIMITS.folder_name);
return slug || "voxvera";
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function redactionToHtml(value) {
return escapeHtml(value).replace(/~~(.*?)~~/g, '<span style="text-decoration: line-through;">$1</span>');
}
function buildPayloadFromForm() {
const payload = {
type: "voxvera_flyer",
version: 1,
folder_name: slugify(el("field-folder-name").value),
lang: el("field-lang").value || "en",
name: el("field-name").value.trim(),
title: el("field-title").value.trim(),
subtitle: el("field-subtitle").value.trim(),
headline: el("field-headline").value.trim(),
content: el("field-content").value.trim(),
url_message: el("field-url-message").value.trim(),
url: el("field-url").value.trim(),
footer_message: el("field-footer-message").value.trim(),
attachment_path: "",
attachment_filename: "",
qr_target: "flyer_url"
};
validatePayload(payload);
return payload;
}
function validatePayload(payload) {
if (payload.type !== "voxvera_flyer") throw new Error("Payload type must be voxvera_flyer.");
if (payload.version !== 1) throw new Error("Payload version must be 1.");
Object.entries(FIELD_LIMITS).forEach(([key, limit]) => {
if (payload[key] && payload[key].length > limit) {
throw new Error(`${key} exceeds ${limit} characters.`);
}
});
Object.entries(payload).forEach(([key, value]) => {
if (typeof value === "string" && key !== "url" && /<\s*\/?\s*[a-z][^>]*>|on[a-z]+\s*=/i.test(value)) {
throw new Error(`${key} contains raw HTML.`);
}
});
if (payload.url) {
const parsed = new URL(payload.url);
if (!["http:", "https:", "nostr:"].includes(parsed.protocol)) {
throw new Error(`Unsupported URL scheme: ${parsed.protocol}`);
}
}
}
function normalizePayload(payload) {
validatePayload(payload);
return {
...CONFIG_DEFAULTS,
name: payload.name || CONFIG_DEFAULTS.name,
folder_name: slugify(payload.folder_name),
lang: payload.lang || "en",
title: payload.title || CONFIG_DEFAULTS.title,
subtitle: payload.subtitle || CONFIG_DEFAULTS.subtitle,
headline: payload.headline || CONFIG_DEFAULTS.headline,
content: payload.content || CONFIG_DEFAULTS.content,
url_message: payload.url_message || CONFIG_DEFAULTS.url_message,
url: payload.url || CONFIG_DEFAULTS.url,
footer_message: payload.footer_message || CONFIG_DEFAULTS.footer_message,
attachment_path: "",
attachment_filename: ""
};
}
function buildUnsignedEvent(payload) {
const folderName = slugify(payload.folder_name);
return {
kind: EVENT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", `voxvera:${folderName}`],
["t", "voxvera"],
["t", "flyer"],
["title", payload.headline || payload.title || folderName],
["language", payload.lang || "en"]
],
content: JSON.stringify(payload)
};
}
function flyerIdentifier(payload) {
return `voxvera:${slugify(payload.folder_name)}`;
}
function buildNaddr(identity, payload, relays) {
return nostrTools().nip19.naddrEncode({
identifier: flyerIdentifier(payload),
pubkey: identity.pubkey,
kind: EVENT_KIND,
relays
});
}
function clientBaseUrl() {
return `${window.location.origin}${window.location.pathname}`;
}
function posterUrlForNaddr(naddr) {
return `${clientBaseUrl()}?addr=${encodeURIComponent(naddr)}`;
}
function withPosterUrl(payload, identity, relays) {
const naddr = buildNaddr(identity, payload, relays);
return {
payload: {
...payload,
url: posterUrlForNaddr(naddr),
qr_target: "flyer_url"
},
naddr,
posterUrl: posterUrlForNaddr(naddr)
};
}
function updatePosterAddressOutputs() {
const naddrOutput = el("naddr-output");
const posterUrlOutput = el("poster-url-output");
if (!naddrOutput || !posterUrlOutput) return null;
try {
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const payload = buildPayloadFromForm();
const poster = withPosterUrl(payload, identity, relays);
naddrOutput.textContent = poster.naddr;
posterUrlOutput.textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
return poster;
} catch (_) {
return null;
}
}
async function signAndPublish(payload, relays) {
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
const signed = nostrTools().finalizeEvent(buildUnsignedEvent(poster.payload), identity.secretKey);
el("author-npub-output").textContent = identity.npub;
el("naddr-output").textContent = poster.naddr;
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
refreshIdentityState();
if (!signed.id || !signed.sig) {
throw new Error("Signer returned an event without id/sig.");
}
const results = await publishEvent(signed, relays);
return { event: signed, results, naddr: poster.naddr, posterUrl: poster.posterUrl, payload: poster.payload };
}
function publishEvent(event, relays) {
return Promise.all(relays.map((relay) => publishToRelay(event, relay)));
}
function publishToRelay(event, relay) {
return new Promise((resolve) => {
const ws = new WebSocket(relay);
const timeout = setTimeout(() => {
try { ws.close(); } catch (_) {}
resolve({ relay, ok: false, message: "timeout" });
}, 9000);
ws.onopen = () => ws.send(JSON.stringify(["EVENT", event]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "OK" && data[1] === event.id) {
clearTimeout(timeout);
ws.close();
resolve({ relay, ok: Boolean(data[2]), message: data[3] || "" });
}
} catch (_) {}
};
ws.onerror = () => {
clearTimeout(timeout);
resolve({ relay, ok: false, message: "connection error" });
};
});
}
function fetchEvent(eventId, relays) {
return new Promise((resolve, reject) => {
const sub = `voxvera-${Math.random().toString(16).slice(2)}`;
let found = null;
let remaining = relays.length;
const sockets = relays.map((relay) => {
const ws = new WebSocket(relay);
ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, { ids: [eventId], kinds: [EVENT_KIND], limit: 1 }]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "EVENT" && data[2] && data[2].id === eventId && !found) {
found = data[2];
sockets.forEach((socket) => {
try { socket.send(JSON.stringify(["CLOSE", sub])); socket.close(); } catch (_) {}
});
resolve(found);
}
} catch (_) {}
};
ws.onerror = () => {
remaining -= 1;
if (remaining <= 0 && !found) reject(new Error("No relay returned the event."));
};
return ws;
});
setTimeout(() => {
if (!found) {
sockets.forEach((socket) => {
try { socket.close(); } catch (_) {}
});
reject(new Error("Timed out fetching event."));
}
}, 12000);
});
}
function fetchReplaceable(address, relays) {
return new Promise((resolve, reject) => {
const sub = `voxvera-addr-${Math.random().toString(16).slice(2)}`;
let best = null;
let remaining = relays.length;
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
sockets.forEach((socket) => {
try { socket.send(JSON.stringify(["CLOSE", sub])); socket.close(); } catch (_) {}
});
if (best) {
resolve(best);
} else {
reject(new Error("No relay returned the addressed flyer."));
}
};
const sockets = relays.map((relay) => {
const ws = new WebSocket(relay);
ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, {
authors: [address.pubkey],
kinds: [address.kind],
"#d": [address.identifier],
limit: 1
}]));
ws.onmessage = (message) => {
try {
const data = JSON.parse(message.data);
if (data[0] === "EVENT" && data[2]) {
const event = data[2];
const hasMatchingD = Array.isArray(event.tags) && event.tags.some((tag) => tag[0] === "d" && tag[1] === address.identifier);
if (event.pubkey === address.pubkey && event.kind === address.kind && hasMatchingD && (!best || event.created_at > best.created_at)) {
best = event;
}
}
if (data[0] === "EOSE") {
remaining -= 1;
if (remaining <= 0) finish();
}
} catch (_) {}
};
ws.onerror = () => {
remaining -= 1;
if (remaining <= 0) finish();
};
return ws;
});
setTimeout(finish, 5000);
});
}
function parseEventReference(value) {
const raw = String(value || "").trim();
if (!raw) return null;
try {
const decoded = decodeURIComponent(raw);
const rawId = decoded.match(/\b[a-f0-9]{64}\b/i);
if (rawId) return { type: "event", id: rawId[0].toLowerCase(), relays: [] };
const nipMatch = decoded.match(/(?:nostr:)?((?:note1|nevent1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]+)/i);
if (!nipMatch) return null;
const tools = nostrTools();
const result = tools.nip19.decode(nipMatch[1].toLowerCase());
if (result.type === "note") {
return { type: "event", id: result.data, relays: [] };
}
if (result.type === "nevent") {
return { type: "event", id: result.data.id, relays: result.data.relays || [] };
}
if (result.type === "naddr") {
return {
type: "address",
address: result.data,
relays: result.data.relays || []
};
}
} catch (_) {
return null;
}
return null;
}
function getUrlEventReference() {
const search = new URLSearchParams(window.location.search);
const params = ["addr", "naddr", "event", "id", "note", "nevent"];
for (const param of params) {
const value = search.get(param);
const parsed = parseEventReference(value);
if (parsed) return parsed;
}
const hash = window.location.hash.replace(/^#/, "");
const hashParts = hash.split(/[?&/]/).filter(Boolean);
for (const part of hashParts) {
const parsed = parseEventReference(part);
if (parsed) return parsed;
}
return parseEventReference(window.location.href);
}
function mergeRelayList(existingValue, extraRelays) {
return parseRelays(`${existingValue}\n${(extraRelays || []).join("\n")}`).join("\n");
}
function payloadFromEvent(event) {
if (!event || event.kind !== EVENT_KIND) throw new Error("Event kind is not a VoxVera source kind.");
const hasTag = (name, value) => Array.isArray(event.tags) && event.tags.some((tag) => tag[0] === name && tag[1] === value);
if (!hasTag("t", "voxvera") || !hasTag("t", "flyer")) throw new Error("Event is missing VoxVera flyer tags.");
const payload = JSON.parse(event.content);
validatePayload(payload);
return payload;
}
function makeQrSvg(value) {
if (!value) return "";
if (typeof qrcode !== "function") {
return '<span class="qr-error">QR unavailable</span>';
}
try {
const qr = qrcode(0, "M");
qr.addData(value);
qr.make();
return qr.createSvgTag({
cellSize: 2,
margin: 2,
scalable: true
});
} catch (_) {
return '<span class="qr-error">QR too long</span>';
}
}
function renderPreview(config) {
const tearOff = config.tear_off_link || config.url || "";
const contentQr = config.url || tearOff;
const tearOffQrSvg = makeQrSvg(tearOff);
const contentQrSvg = makeQrSvg(contentQr);
const sheetClass = tearOff ? "container" : "container no-tear-offs";
const tearOffHtml = Array.from({ length: 10 }).map(() => `
<div class="tear-off">
<div class="tear-off-text">
Open this poster:<br>
<a href="${escapeHtml(tearOff)}">${escapeHtml(tearOff)}</a><br>
Share and reprint.
</div>
<div class="qr-code" aria-label="QR code for ${escapeHtml(tearOff)}">${tearOffQrSvg}</div>
</div>
`).join("");
el("flyer-preview").innerHTML = `
<div class="${sheetClass}">
<div class="left-tear-offs">${tearOffHtml}</div>
<div class="content">
<h1>${redactionToHtml(config.title)}</h1>
<div class="distribute">${redactionToHtml(config.subtitle)}</div>
<h1>${redactionToHtml(config.headline)}</h1>
<hr>
<div class="message">${redactionToHtml(config.content)}</div>
<div class="qr-code-body">
<div class="qr-code-url">
<span class="url-message">${redactionToHtml(config.url_message)}</span><br><br>
<a href="${escapeHtml(config.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(config.url)}</a>
</div>
<div class="qr-code main-qr" aria-label="QR code for ${escapeHtml(contentQr)}">${contentQrSvg}</div>
</div>
<hr>
<div class="footer">
<p class="credit">Built with <a href="https://github.com/PR0M3TH3AN/VoxVera">voxvera</a></p>
<p class="binary">${redactionToHtml(config.footer_message)}</p>
</div>
</div>
</div>
`;
}
function formToPreview() {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
el("author-npub-output").textContent = identity.npub;
el("naddr-output").textContent = poster.naddr;
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
refreshIdentityState();
renderPreview(normalizePayload(poster.payload));
}
function setMode(mode) {
document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.mode === mode));
el("editor-panel").classList.toggle("active", mode === "editor");
el("viewer-panel").classList.toggle("active", mode === "viewer");
document.body.classList.toggle("mode-editor", mode === "editor");
document.body.classList.toggle("mode-viewer", mode === "viewer");
if (mode === "viewer") {
closeViewerDrawer();
}
if (!window.location.hash || window.location.hash === "#editor" || window.location.hash === "#viewer") {
history.replaceState(null, "", `#${mode}`);
}
}
function openViewerDrawer() {
el("viewer-panel").classList.add("drawer-open");
}
function closeViewerDrawer() {
el("viewer-panel").classList.remove("drawer-open");
}
function downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function renderRelayResults(results) {
return `<div class="relay-results">${results.map((item) => `
<div>
<span>${escapeHtml(item.relay)}:</span>
<span class="${item.ok ? "relay-ok" : "relay-error"}">${item.ok ? "OK" : escapeHtml(item.message || "failed")}</span>
</div>
`).join("")}</div>`;
}
async function copyTextFromElement(targetId, button) {
const value = el(targetId).textContent.trim();
if (!value || value === "Not published" || value === "Not generated") return;
await navigator.clipboard.writeText(value);
const oldText = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = oldText;
}, 1200);
}
async function fetchAndRenderFromInput(eventInput, relaysValue, statusElement = el("viewer-status")) {
const parsed = parseEventReference(eventInput);
if (!parsed) throw new Error("Enter an naddr, event id, note1, or nevent1.");
const relays = parseRelays(`${relaysValue}\n${(parsed.relays || []).join("\n")}`);
if (!relays.length) throw new Error("Enter at least one wss:// relay.");
el("viewer-event-id").value = eventInput;
el("viewer-relays").value = relays.join("\n");
statusElement.textContent = "Fetching...";
const nostrEvent = parsed.type === "address"
? await fetchReplaceable(parsed.address, relays)
: await fetchEvent(parsed.id, relays);
const payload = payloadFromEvent(nostrEvent);
const config = normalizePayload(payload);
el("viewer-config-output").value = JSON.stringify(config, null, 2);
renderPreview(config);
statusElement.textContent = `Fetched ${parsed.type === "address" ? parsed.address.identifier : parsed.id}`;
closeViewerDrawer();
}
document.addEventListener("DOMContentLoaded", () => {
setDefaultText();
const urlEvent = getUrlEventReference();
setMode(window.location.hash === "#editor" && !urlEvent ? "editor" : "viewer");
if (urlEvent) {
const urlInput = window.location.href;
el("viewer-event-id").value = urlInput;
el("viewer-relays").value = mergeRelayList(el("viewer-relays").value, urlEvent.relays);
fetchAndRenderFromInput(urlInput, el("viewer-relays").value).catch((error) => {
el("viewer-status").textContent = error.message;
openViewerDrawer();
});
}
document.querySelectorAll(".tab").forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.mode));
});
el("open-viewer-controls").addEventListener("click", openViewerDrawer);
el("close-viewer-controls").addEventListener("click", closeViewerDrawer);
el("viewer-editor").addEventListener("click", () => setMode("editor"));
el("viewer-print").addEventListener("click", () => window.print());
document.querySelectorAll(".copy-button").forEach((button) => {
button.addEventListener("click", () => {
copyTextFromElement(button.dataset.copyTarget, button).catch((error) => {
el("publish-status").textContent = error.message;
});
});
});
el("generate-anon-identity").addEventListener("click", () => {
try {
const identity = getOrCreateAnonIdentity(true);
el("author-npub-output").textContent = identity.npub;
refreshIdentityState();
el("publish-status").textContent = `Anonymous npub generated: ${identity.npub}`;
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("preview-editor").addEventListener("click", () => {
try {
formToPreview();
el("publish-status").textContent = "Preview updated.";
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("export-event").addEventListener("click", () => {
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const poster = withPosterUrl(payload, identity, relays);
const event = buildUnsignedEvent(poster.payload);
el("author-npub-output").textContent = identity.npub;
el("naddr-output").textContent = poster.naddr;
el("poster-url-output").textContent = poster.posterUrl;
el("field-url").value = poster.posterUrl;
el("event-json-output").value = JSON.stringify(event, null, 2);
downloadJson(`${poster.payload.folder_name}-voxvera-event-unsigned.json`, event);
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("editor-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
if (!relays.length) throw new Error("Enter at least one wss:// relay.");
el("publish-status").textContent = "Signing and publishing...";
const result = await signAndPublish(payload, relays);
el("event-id-output").textContent = result.event.id;
el("event-json-output").value = JSON.stringify(result.event, null, 2);
el("viewer-event-id").value = result.naddr;
el("publish-status").innerHTML = renderRelayResults(result.results);
renderPreview(normalizePayload(result.payload));
} catch (error) {
el("publish-status").textContent = error.message;
}
});
el("viewer-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await fetchAndRenderFromInput(el("viewer-event-id").value, el("viewer-relays").value);
} catch (error) {
el("viewer-status").textContent = error.message;
}
});
el("copy-viewer-config").addEventListener("click", async () => {
const value = el("viewer-config-output").value;
if (!value) return;
await navigator.clipboard.writeText(value);
el("viewer-status").textContent = "Config copied.";
});
el("print-preview").addEventListener("click", () => window.print());
});
})();
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
+13
View File
@@ -0,0 +1,13 @@
nostr-tools 2.23.5
==================
Vendored browser bundle used by the static Nostr client for:
- NIP-19 parsing and encoding (`note1`, `nevent1`, `npub`, `nsec`)
- anonymous local key generation
- NIP-01 event finalization and signing
Source package: `nostr-tools`
License: Unlicense
The package license is included in `LICENSE`.
File diff suppressed because it is too large. Load diff
+11
View File
@@ -0,0 +1,11 @@
qrcode-generator 2.0.4
=======================
Vendored browser QR generator used by the static Nostr client.
Source package: `qrcode-generator`
Author: Kazuhiko Arase
License: MIT
The vendored `qrcode.js` file includes its original copyright and MIT
license header.
File diff suppressed because it is too large. Load diff
+344 -1
View File
@@ -602,6 +602,51 @@ def test_serve_updates_url(tmp_path, monkeypatch):
assert updated["tear_off_link"] == onion_url
def test_serve_recovers_onion_url_from_session_file(tmp_path, monkeypatch):
"""OnionShare 2.6 may not log the URL when using system Tor."""
_setup_tmp(monkeypatch, tmp_path)
cli.main(["build"])
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
folder_name = config["folder_name"]
dir_path = test_data_dir / "host" / folder_name
(dir_path / ".onionshare-session").write_text(
json.dumps({"general": {"service_id": "sessionserviceid"}}),
encoding="utf-8",
)
monkeypatch.setattr(cli, "require_cmd", lambda c: True)
orig_build = cli.build_assets
def safe_build_assets(cfg, download_path=None):
dest = cli.ROOT / "host" / json.load(open(cfg))["folder_name"] / "config.json"
if Path(cfg) == dest:
return
return orig_build(cfg, download_path=download_path)
monkeypatch.setattr(cli, "build_assets", safe_build_assets)
monkeypatch.setattr(time, "sleep", lambda x: None)
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.pid = 43
if stdout is not None:
stdout.write("Onion.start_onion_service: key_type=ED25519-V3\n")
stdout.flush()
def poll(self):
return None
monkeypatch.setattr(cli.subprocess, "Popen", FakePopen)
cli.main(["serve"])
updated = json.load(open(dir_path / "config.json"))
assert updated["tear_off_link"] == "http://sessionserviceid.onion"
def test_quickstart_noninteractive(tmp_path, monkeypatch):
"""Test quickstart builds and serves in one command."""
_setup_tmp(monkeypatch, tmp_path)
@@ -672,13 +717,311 @@ def test_install_systemd_autostart_writes_recovery_timer(tmp_path, monkeypatch):
service_text = (service_dir / "voxvera-start.service").read_text(encoding="utf-8")
timer_text = (service_dir / "voxvera-start.timer").read_text(encoding="utf-8")
assert "ExecStart=/tmp/voxvera start-all" in service_text
assert "ExecStart=/tmp/voxvera _linux_autostart_start" in service_text
assert "KillMode=process" in service_text
assert "OnUnitActiveSec=5min" in timer_text
assert "Persistent=true" in timer_text
assert any(args[:4] == ["systemctl", "--user", "enable", "--now"] and args[4] == "voxvera-start.timer" for args in commands)
assert any(args[:4] == ["systemctl", "--user", "start", "voxvera-start.service"] for args in commands)
def test_linux_autostart_start_sets_hardened_environment(monkeypatch):
events = []
monkeypatch.delenv("VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION", raising=False)
monkeypatch.delenv("VOXVERA_ONIONSHARE_SYSTEM_TOR", raising=False)
monkeypatch.setattr(cli, "cleanup_onionshare_tmp", lambda max_age_minutes=60: events.append(("cleanup", max_age_minutes)) or 0)
monkeypatch.setattr(cli, "_user_is_group_member", lambda group: False)
monkeypatch.setattr(cli, "start_all_servers", lambda: events.append(("start_all", None)))
cli.linux_autostart_start()
assert events == [("cleanup", 60), ("start_all", None)]
assert os.environ["VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION"] == "1"
assert os.environ["VOXVERA_ONIONSHARE_SYSTEM_TOR"] == "1"
def test_linux_autostart_start_switches_to_debian_tor_group(monkeypatch):
exec_call = {}
monkeypatch.setattr(cli, "cleanup_onionshare_tmp", lambda max_age_minutes=60: 0)
monkeypatch.setattr(cli, "_user_is_group_member", lambda group: group == "debian-tor")
monkeypatch.setattr(cli, "_current_group_names", lambda: {"user"})
monkeypatch.setattr(cli, "_find_voxvera_bin", lambda: "/tmp/vox vera")
def fake_execvp(binary, args):
exec_call["binary"] = binary
exec_call["args"] = args
raise RuntimeError("stop")
monkeypatch.setattr(cli.os, "execvp", fake_execvp)
with pytest.raises(RuntimeError, match="stop"):
cli.linux_autostart_start()
assert exec_call["binary"] == "sg"
assert exec_call["args"][:3] == ["sg", "debian-tor", "-c"]
assert "VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION=1" in exec_call["args"][3]
assert "VOXVERA_ONIONSHARE_SYSTEM_TOR=1" in exec_call["args"][3]
assert "'/tmp/vox vera' start-all" in exec_call["args"][3]
def test_serve_uses_internal_onionshare_when_no_await_requested(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
cli.main(["build"])
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
folder_name = config["folder_name"]
dir_path = test_data_dir / "host" / folder_name
monkeypatch.setenv("VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION", "1")
monkeypatch.setattr(cli, "require_cmd", lambda c: True)
monkeypatch.setattr(cli, "build_assets", lambda *args, **kwargs: None)
monkeypatch.setattr(time, "sleep", lambda x: None)
monkeypatch.setattr(cli.shutil, "which", lambda cmd: "/usr/bin/onionshare-cli" if cmd == "onionshare-cli" else None)
onion_url = "http://internalpath.onion"
popen_calls = []
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
popen_calls.append(cmd)
self.pid = 44
if stdout is not None:
stdout.write(f"URL: {onion_url}\n")
stdout.flush()
def poll(self):
return None
monkeypatch.setattr(cli.subprocess, "Popen", FakePopen)
cli.main(["serve"])
assert popen_calls
assert "_internal_onionshare" in popen_calls[0]
assert "/usr/bin/onionshare-cli" not in popen_calls[0]
updated = json.load(open(dir_path / "config.json"))
assert updated["tear_off_link"] == onion_url
def _write_nostr_event(path, payload_overrides=None, event_overrides=None):
payload = {
"type": "voxvera_flyer",
"version": 1,
"folder_name": "nostr-flyer",
"lang": "en",
"name": "Nostr Flyer",
"title": "PUBLIC NOTICE",
"subtitle": "DO ~~NOT~~ IGNORE",
"headline": "NOSTR SOURCE TEST",
"content": "This flyer came from a Nostr event.",
"url_message": "Read the source.",
"url": "https://example.com/source",
"footer_message": "npub test",
"attachment_path": "",
"attachment_filename": "",
"qr_target": "flyer_url",
}
if payload_overrides:
payload.update(payload_overrides)
event = {
"id": "f" * 64,
"pubkey": "a" * 64,
"created_at": 1760000000,
"kind": 30078,
"tags": [["d", "voxvera:nostr-flyer"], ["t", "voxvera"], ["t", "flyer"]],
"content": json.dumps(payload),
"sig": "b" * 128,
}
if event_overrides:
event.update(event_overrides)
path.write_text(json.dumps(event), encoding="utf-8")
return path
def test_nostr_validate_accepts_local_event_file(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "validate", str(event_path)])
assert "Valid VoxVera Nostr flyer: NOSTR SOURCE TEST" in capsys.readouterr().out
def test_nostr_import_writes_existing_config_schema(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "import", str(event_path)])
config_path = tmp_path / "data" / "host" / "nostr-flyer" / "config.json"
imported = json.loads(config_path.read_text(encoding="utf-8"))
assert imported["folder_name"] == "nostr-flyer"
assert imported["headline"] == "NOSTR SOURCE TEST"
assert imported["content"] == "This flyer came from a Nostr event."
assert imported["url"] == "https://example.com/source"
assert "Imported Nostr flyer to" in capsys.readouterr().out
def test_nostr_import_rejects_existing_site_without_overwrite(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "import", str(event_path)])
with pytest.raises(SystemExit):
cli.main(["nostr", "import", str(event_path)])
assert "already exists" in capsys.readouterr().err
def test_nostr_build_generates_existing_flyer_output(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "build", str(event_path)])
host_dir = tmp_path / "data" / "host" / "nostr-flyer"
assert (host_dir / "config.json").exists()
assert (host_dir / "index.html").exists()
html = (host_dir / "index.html").read_text(encoding="utf-8")
assert "NOSTR SOURCE TEST" in html
def test_nostr_serve_builds_then_uses_existing_serve_path(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
served = []
monkeypatch.setattr(cli, "serve", lambda config_path: served.append(Path(config_path).name) or "http://ok.onion")
cli.main(["nostr", "serve", str(event_path)])
assert served == ["config.json"]
assert (tmp_path / "data" / "host" / "nostr-flyer" / "index.html").exists()
def test_nostr_validate_rejects_unsafe_url_scheme(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json", {"url": "javascript:alert(1)"})
with pytest.raises(SystemExit):
cli.main(["nostr", "validate", str(event_path)])
assert "Unsupported URL scheme" in capsys.readouterr().err
def test_nostr_validate_rejects_raw_html(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json", {"content": "<script>alert(1)</script>"})
with pytest.raises(SystemExit):
cli.main(["nostr", "validate", str(event_path)])
assert "contains raw HTML" in capsys.readouterr().err
def test_nostr_validate_rejects_non_file_identifier_in_phase_one(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
with pytest.raises(SystemExit):
cli.main(["nostr", "validate", "note1example"])
assert "Phase 1 only supports local Nostr event JSON files" in capsys.readouterr().err
def test_is_server_running_returns_false_for_unreachable_onion(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
(host_dir / "server.pid").write_text("123\n", encoding="utf-8")
(host_dir / "config.json").write_text(
json.dumps({"folder_name": "voxvera", "tear_off_link": "http://examplehiddenservice.onion"}),
encoding="utf-8",
)
old = time.time() - 1200
os.utime(host_dir / "server.pid", (old, old))
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(cli.os, "kill", lambda pid, sig: None)
monkeypatch.setattr(cli.subprocess, "check_output", lambda *args, **kwargs: b"onionshare-cli --website")
monkeypatch.setattr(cli.shutil, "which", lambda cmd: "/usr/bin/curl" if cmd == "curl" else None)
class _Result:
returncode = 97
stdout = ""
monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: _Result())
assert cli.is_server_running("voxvera") is False
assert (host_dir / "server.pid").exists()
def test_is_server_running_keeps_recent_unreachable_onion_alive(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
(host_dir / "server.pid").write_text("123\n", encoding="utf-8")
(host_dir / "config.json").write_text(
json.dumps({"folder_name": "voxvera", "tear_off_link": "http://examplehiddenservice.onion"}),
encoding="utf-8",
)
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(cli.os, "kill", lambda pid, sig: None)
monkeypatch.setattr(cli.subprocess, "check_output", lambda *args, **kwargs: b"onionshare-cli --website")
monkeypatch.setattr(cli.shutil, "which", lambda cmd: "/usr/bin/curl" if cmd == "curl" else None)
class _Result:
returncode = 97
stdout = ""
monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: _Result())
assert cli.is_server_running("voxvera") is True
def test_start_all_servers_restarts_unhealthy_site(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
host_dir = tmp_path / "data" / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
(host_dir / "config.json").write_text(json.dumps({"folder_name": "voxvera"}), encoding="utf-8")
(host_dir / "server.pid").write_text("123\n", encoding="utf-8")
events = []
monkeypatch.setattr(cli, "_seed_bundled_sites", lambda: None)
monkeypatch.setattr(cli, "is_server_running", lambda name: False)
monkeypatch.setattr(cli, "stop_server", lambda name: events.append(("stop", name)))
monkeypatch.setattr(cli, "serve", lambda path: events.append(("serve", Path(path).name)) or "http://ok.onion")
cli.start_all_servers()
assert events == [("stop", "voxvera"), ("serve", "config.json")]
output = capsys.readouterr().out
assert "[RESTART] voxvera: existing process failed health check" in output
assert "[OK] voxvera: http://ok.onion" in output
def test_stop_server_kills_process_group_on_linux(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
host_dir = tmp_path / "data" / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
pid_file = host_dir / "server.pid"
pid_file.write_text("456\n", encoding="utf-8")
killed = []
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(cli.os, "killpg", lambda pid, sig: killed.append((pid, sig)))
cli.stop_server("voxvera")
assert killed == [(456, cli.signal.SIGTERM)]
assert not pid_file.exists()
assert "Stopped voxvera (PID 456)" in capsys.readouterr().out
def test_get_tor_ports_with_env_vars(monkeypatch):
"""Test that get_tor_ports respects environment variables."""
monkeypatch.setenv("TOR_SOCKS_PORT", "9999")
+312 -9
View File
@@ -3,10 +3,13 @@ import glob
import json
import os
import re
import shlex
import signal
import shutil
import subprocess
import sys
import time
import urllib.parse
from pathlib import Path
from importlib import resources
from importlib.resources.abc import Traversable
@@ -948,19 +951,22 @@ def _internal_onionshare():
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.
# Prevent user-local pip packages from shadowing system packages
os.environ["PYTHONNOUSERSITE"] = "1"
# Most invocations should prefer the system onionshare-cli binary. Linux
# autostart can request the import path so VoxVera can patch OnionShare's
# descriptor-publication wait without modifying system packages.
no_await_publication = _truthy_env("VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION")
onionshare_bin = shutil.which("onionshare-cli") or shutil.which("onionshare")
if onionshare_bin:
if onionshare_bin and not no_await_publication:
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:
if no_await_publication:
_patch_onionshare_await_publication()
from onionshare_cli import main
sys.argv = ["onionshare-cli"] + onionshare_args
sys.exit(main())
@@ -970,6 +976,105 @@ def _internal_onionshare():
sys.exit(1)
def _truthy_env(name: str) -> bool:
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _patch_onionshare_await_publication() -> None:
"""Force OnionShare CLI startup to bind locally before descriptor publication.
OnionShare 2.6 waits inside Tor ADD_ONION until the descriptor is published.
For a watchdog-managed hidden service this is brittle: the local website
port never opens, the parent process can appear hung, and repeated retries
can accumulate bundled-Tor temp state. Let Tor publish in the background.
"""
try:
from onionshare_cli.onionshare import OnionShare
except Exception:
return
if getattr(OnionShare.start_onion_service, "_voxvera_no_await_publication", False):
return
original = OnionShare.start_onion_service
def start_without_publication_wait(self, mode, mode_settings, await_publication=True):
return original(self, mode, mode_settings, False)
start_without_publication_wait._voxvera_no_await_publication = True
OnionShare.start_onion_service = start_without_publication_wait
def _onionshare_tmp_dir() -> Path:
return Path.home() / ".config" / "onionshare" / "tmp"
def cleanup_onionshare_tmp(max_age_minutes: int = 60) -> int:
"""Delete stale OnionShare temp dirs and return the number removed."""
tmp_dir = _onionshare_tmp_dir()
tmp_dir.mkdir(parents=True, exist_ok=True)
cutoff = time.time() - (max_age_minutes * 60)
removed = 0
for child in tmp_dir.iterdir():
try:
if child.stat().st_mtime > cutoff:
continue
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)
else:
child.unlink(missing_ok=True)
removed += 1
except Exception:
pass
return removed
def _configure_onionshare_system_tor_if_requested() -> Path | None:
"""Write an OnionShare config that uses the distro Tor control socket."""
if not _truthy_env("VOXVERA_ONIONSHARE_SYSTEM_TOR"):
return None
control_socket = Path("/run/tor/control")
if not control_socket.exists():
return None
config_dir = Path.home() / ".config" / "onionshare"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "onionshare.json"
data = {}
if config_path.exists():
try:
data = json.loads(config_path.read_text())
except Exception:
data = {}
data.update(
{
"version": data.get("version", "2.6"),
"connection_type": "socket_file",
"control_port_address": "127.0.0.1",
"control_port_port": 9051,
"socks_address": "127.0.0.1",
"socks_port": 9050,
"socket_file_path": str(control_socket),
"auth_type": "no_auth",
"auth_password": "",
"auto_connect": False,
"bridges_enabled": False,
"bridges_type": "built-in",
"bridges_builtin_pt": "obfs4",
"bridges_moat": "",
"bridges_custom": "",
"bridges_builtin": data.get("bridges_builtin", {}),
"persistent_tabs": data.get("persistent_tabs", []),
"locale": data.get("locale", "en"),
"theme": data.get("theme", 0),
}
)
config_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
return config_path
def serve(config_path: str) -> str | None:
socks, ctl = get_tor_ports()
@@ -995,7 +1100,11 @@ def serve(config_path: str) -> str | None:
# stale user-local copies break it.
env["PYTHONNOUSERSITE"] = "1"
onionshare_args = [
onionshare_config = _configure_onionshare_system_tor_if_requested()
onionshare_args = []
if onionshare_config is not None:
onionshare_args.extend(["--config", str(onionshare_config)])
onionshare_args.extend([
"--website",
"--public",
"--persistent",
@@ -1005,12 +1114,12 @@ def serve(config_path: str) -> str | None:
"--disable_csp",
"-v",
str(dir_path),
]
])
# 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:
if onionshare_bin and not _truthy_env("VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION"):
cmd = [onionshare_bin] + onionshare_args
else:
# Fallback: route through our hidden subcommand
@@ -1071,6 +1180,15 @@ def serve(config_path: str) -> str | None:
m = _re.search(r"https?://[a-z0-9]+\.onion", content)
if m:
onion_url = m.group(0)
elif "Can't connect to the Tor controller" not in content:
session_file = dir_path / ".onionshare-session"
try:
session_data = json.loads(session_file.read_text())
service_id = session_data.get("general", {}).get("service_id")
if service_id:
onion_url = f"http://{service_id}.onion"
except Exception:
pass
# update config with the onion URL in the host directory
config_file = dir_path / "config.json"
@@ -1225,6 +1343,67 @@ def import_multiple_sites(source_dir: str = None):
print("\nImport multiple complete.")
def _load_nostr_source(source: str) -> dict:
from voxvera.nostr.schema import load_source_file
return load_source_file(source)
def validate_nostr_source(source: str) -> dict:
from voxvera.nostr import validate_event_source
return validate_event_source(_load_nostr_source(source))
def import_nostr_source(source: str, folder_name: str | None = None, overwrite: bool = False) -> Path:
from voxvera.nostr import normalize_event_source
source_data = _load_nostr_source(source)
defaults = json.loads((_src_res("config.json")).read_text(encoding="utf-8"))
config = normalize_event_source(
source_data,
normalize_config(defaults),
folder_name=folder_name,
source_identifier=source,
)
dest_dir = DATA_DIR / "host" / config["folder_name"]
if dest_dir.exists() and not overwrite:
raise FileExistsError(f"Site '{config['folder_name']}' already exists. Use --overwrite to replace it.")
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
config_path = dest_dir / "config.json"
save_config(config, config_path)
return config_path
def handle_nostr_command(args) -> None:
from voxvera.nostr import NostrValidationError
try:
if args.nostr_action == "validate":
payload = validate_nostr_source(args.source)
label = payload.get("headline") or payload.get("title") or args.source
print(f"Valid VoxVera Nostr flyer: {label}")
return
config_path = import_nostr_source(
args.source,
folder_name=getattr(args, "folder_name", None),
overwrite=getattr(args, "overwrite", False),
)
print(f"Imported Nostr flyer to {config_path}")
if args.nostr_action in {"build", "serve"}:
build_assets(str(config_path))
print(f"Built Nostr flyer from {config_path}")
if args.nostr_action == "serve":
serve(str(config_path))
except (NostrValidationError, FileExistsError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
def export_keys(folder_name: str):
"""Export the onion service session key as base64 for easy copy/paste."""
console = Console()
@@ -1623,6 +1802,54 @@ def get_servers() -> list[str]:
return sorted(servers)
def _probe_onion_url(onion_url: str, socks_port: str = "9050", timeout: int = 20):
"""Return True/False when curl can verify the onion, or None if unavailable."""
if not onion_url:
return None
curl_bin = shutil.which("curl")
if not curl_bin:
return None
cmd = [
curl_bin,
"--silent",
"--show-error",
"--output",
"/dev/null",
"--write-out",
"%{http_code}",
"--max-time",
str(timeout),
"--socks5-hostname",
f"127.0.0.1:{socks_port}",
onion_url,
]
try:
result = subprocess.run(
cmd,
check=False,
capture_output=True,
text=True,
)
except OSError:
return None
if result.returncode != 0:
return False
return result.stdout.strip().startswith(("2", "3"))
def _read_onion_url(folder_name: str) -> str:
config_path = DATA_DIR / "host" / folder_name / "config.json"
if not config_path.exists():
return ""
try:
config = normalize_config(load_config(config_path))
except Exception:
return ""
return get_effective_tear_off_link(config)
def is_server_running(folder_name: str) -> bool:
pid_file = DATA_DIR / "host" / folder_name / "server.pid"
if not pid_file.exists():
@@ -1656,6 +1883,18 @@ def is_server_running(folder_name: str) -> bool:
try:
output = subprocess.check_output(["ps", "-p", str(pid), "-o", "command="], stderr=subprocess.STDOUT).decode().lower()
if "onionshare" in output or "python" in output or "tor" in output:
onion_url = _read_onion_url(folder_name)
if onion_url:
parsed = urllib.parse.urlparse(onion_url)
if parsed.hostname and parsed.hostname.endswith(".onion"):
probe = _probe_onion_url(onion_url)
if probe is False:
try:
if time.time() - pid_file.stat().st_mtime < 900:
return True
except Exception:
pass
return False
return True
return False
except Exception:
@@ -1670,11 +1909,14 @@ def stop_server(folder_name: str):
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
import platform, signal
import platform
if platform.system() == "Windows":
subprocess.run(["taskkill", "/F", "/PID", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
os.kill(pid, signal.SIGTERM)
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
os.kill(pid, signal.SIGTERM)
print(f"Stopped {folder_name} (PID {pid})")
except Exception as e:
print(f"Error stopping {folder_name}: {e}")
@@ -1702,6 +1944,10 @@ def start_all_servers():
if is_server_running(s):
print(f"[SKIP] {s} already running")
continue
pid_file = DATA_DIR / "host" / s / "server.pid"
if pid_file.exists():
print(f"[RESTART] {s}: existing process failed health check")
stop_server(s)
config_path = DATA_DIR / "host" / s / "config.json"
try:
url = serve(str(config_path))
@@ -1713,6 +1959,48 @@ def start_all_servers():
print(f"[FAIL] {s}: {e}")
def _current_group_names() -> set[str]:
import grp
names = set()
for gid in os.getgroups() + [os.getgid()]:
try:
names.add(grp.getgrgid(gid).gr_name)
except KeyError:
pass
return names
def _user_is_group_member(group_name: str) -> bool:
import grp
import pwd
try:
group = grp.getgrnam(group_name)
username = pwd.getpwuid(os.getuid()).pw_name
except KeyError:
return False
return username in group.gr_mem or os.getgid() == group.gr_gid
def linux_autostart_start():
"""Linux systemd entry point with cleanup and system-Tor hardening."""
cleanup_onionshare_tmp(max_age_minutes=60)
os.environ["VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION"] = "1"
os.environ["VOXVERA_ONIONSHARE_SYSTEM_TOR"] = "1"
if _user_is_group_member("debian-tor") and "debian-tor" not in _current_group_names():
voxvera_bin = _find_voxvera_bin()
command = (
"env VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION=1 "
"VOXVERA_ONIONSHARE_SYSTEM_TOR=1 "
f"{shlex.quote(voxvera_bin)} start-all"
)
os.execvp("sg", ["sg", "debian-tor", "-c", command])
start_all_servers()
def stop_all_servers():
"""Non-interactive: stop every running site."""
servers = get_servers()
@@ -2034,14 +2322,27 @@ def main(argv=None):
p_autostart = sub.add_parser("autostart", help="Install or inspect platform autostart support")
p_autostart.add_argument("action", nargs="?", choices=["install", "status", "uninstall"], default="install")
p_autostart.add_argument("--json", action="store_true", help="Emit machine-readable JSON for status")
p_nostr = sub.add_parser("nostr", help="Import and build flyers from Nostr event sources")
nostr_sub = p_nostr.add_subparsers(dest="nostr_action", required=True)
p_nostr_validate = nostr_sub.add_parser("validate", help="Validate a local VoxVera Nostr event JSON file")
p_nostr_validate.add_argument("source", help="Path to a local Nostr event JSON file")
for action in ("import", "build", "serve"):
p = nostr_sub.add_parser(action, help=f"{action.title()} a local VoxVera Nostr event JSON file")
p.add_argument("source", help="Path to a local Nostr event JSON file")
p.add_argument("--folder-name", help="Override the imported site folder name")
p.add_argument("--overwrite", action="store_true", help="Replace an existing site folder")
sub.add_parser("rebuild-all", help="Rebuild all existing sites from the current template (preserves keys and config)")
sub.add_parser("_internal_onionshare", help=argparse.SUPPRESS)
sub.add_parser("_linux_autostart_start", help=argparse.SUPPRESS)
args, unknown_args = parser.parse_known_args(argv)
if args.command == "_internal_onionshare":
_internal_onionshare()
return
if args.command == "_linux_autostart_start":
linux_autostart_start()
return
config_path = Path(args.config).resolve()
@@ -2129,6 +2430,8 @@ def main(argv=None):
uninstall_autostart()
else:
install_autostart()
elif args.command == "nostr":
handle_nostr_command(args)
elif args.command == "quickstart":
ensure_config_exists(config_path)
if current_lang:
+9
View File
@@ -0,0 +1,9 @@
"""Nostr source support for VoxVera."""
from .schema import NostrValidationError, normalize_event_source, validate_event_source
__all__ = [
"NostrValidationError",
"normalize_event_source",
"validate_event_source",
]
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import json
import re
import urllib.parse
from pathlib import Path
from typing import Any
EVENT_KIND = 30078
PAYLOAD_TYPE = "voxvera_flyer"
PAYLOAD_VERSION = 1
ALLOWED_QR_TARGETS = {"flyer_url", "nostr_event", "content_url"}
CONFIG_FIELDS = {
"name",
"folder_name",
"lang",
"title",
"subtitle",
"headline",
"content",
"url_message",
"url",
"footer_message",
"attachment_path",
"attachment_filename",
}
FIELD_LIMITS = {
"folder_name": 64,
"name": 120,
"title": 80,
"subtitle": 120,
"headline": 160,
"content": 10000,
"url_message": 240,
"url": 2048,
"footer_message": 240,
}
HTML_PATTERN = re.compile(r"<\s*/?\s*[A-Za-z][^>]*>|on[A-Za-z]+\s*=", re.IGNORECASE)
SUPPORTED_LANGS = {
"ar",
"de",
"en",
"es",
"fa",
"fr",
"he",
"hi",
"ja",
"pt",
"ru",
"sw",
"tr",
"zh",
}
class NostrValidationError(ValueError):
"""Raised when a Nostr flyer source is unsafe or malformed."""
def load_source_file(path: str | Path) -> dict[str, Any]:
source_path = Path(path)
if not source_path.exists():
raise NostrValidationError(
"Phase 1 only supports local Nostr event JSON files. "
"Relay fetching by note/nevent will be added in a later phase."
)
try:
data = json.loads(source_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise NostrValidationError(f"Invalid JSON: {exc.msg}") from exc
if not isinstance(data, dict):
raise NostrValidationError("Nostr source must be a JSON object.")
return data
def validate_event_source(source: dict[str, Any]) -> dict[str, Any]:
"""Validate a Nostr event or direct payload and return the flyer payload."""
payload = _extract_payload(source)
_validate_payload(payload)
return payload
def normalize_event_source(
source: dict[str, Any],
defaults: dict[str, Any],
*,
folder_name: str | None = None,
source_identifier: str | None = None,
) -> dict[str, Any]:
"""Return a normal VoxVera config dict from a validated Nostr source."""
payload = validate_event_source(source)
config = dict(defaults)
for key in CONFIG_FIELDS:
if key in payload:
config[key] = payload[key]
if folder_name:
config["folder_name"] = folder_name
config["folder_name"] = _slugify_folder_name(str(config.get("folder_name") or "voxvera"))
config["lang"] = _normalize_lang(str(config.get("lang") or "en"))
config.setdefault("tear_off_link", "")
qr_target = payload.get("qr_target", "flyer_url")
if qr_target == "content_url":
config["tear_off_link"] = str(config.get("url", ""))
elif qr_target == "nostr_event" and source_identifier:
config["tear_off_link"] = source_identifier
return config
def _extract_payload(source: dict[str, Any]) -> dict[str, Any]:
if source.get("type") == PAYLOAD_TYPE:
return source
if "content" not in source:
raise NostrValidationError("Nostr event is missing content.")
if source.get("kind") != EVENT_KIND:
raise NostrValidationError(f"Nostr event kind must be {EVENT_KIND}.")
if not _has_tag(source, "t", "voxvera") or not _has_tag(source, "t", "flyer"):
raise NostrValidationError("Nostr event must include #voxvera and #flyer tags.")
if not any(tag and tag[0] == "d" and len(tag) > 1 for tag in source.get("tags", [])):
raise NostrValidationError("Nostr event must include a d tag.")
content = source["content"]
if not isinstance(content, str):
raise NostrValidationError("Nostr event content must be a JSON string.")
try:
payload = json.loads(content)
except json.JSONDecodeError as exc:
raise NostrValidationError(f"Nostr event content is not valid JSON: {exc.msg}") from exc
if not isinstance(payload, dict):
raise NostrValidationError("Nostr event content must decode to a JSON object.")
return payload
def _validate_payload(payload: dict[str, Any]) -> None:
if payload.get("type") != PAYLOAD_TYPE:
raise NostrValidationError(f"Payload type must be {PAYLOAD_TYPE}.")
if payload.get("version") != PAYLOAD_VERSION:
raise NostrValidationError(f"Payload version must be {PAYLOAD_VERSION}.")
qr_target = payload.get("qr_target", "flyer_url")
if qr_target not in ALLOWED_QR_TARGETS:
raise NostrValidationError(f"Unsupported qr_target: {qr_target}.")
for key, value in payload.items():
if key in {"type", "version", "qr_target"}:
continue
if key not in CONFIG_FIELDS:
continue
if not isinstance(value, str):
raise NostrValidationError(f"Field {key} must be a string.")
limit = FIELD_LIMITS.get(key)
if limit is not None and len(value) > limit:
raise NostrValidationError(f"Field {key} exceeds {limit} characters.")
if key != "url" and HTML_PATTERN.search(value):
raise NostrValidationError(f"Field {key} contains raw HTML.")
if "folder_name" in payload:
_slugify_folder_name(payload["folder_name"])
if "lang" in payload:
_normalize_lang(payload["lang"])
if "url" in payload:
_validate_url(payload["url"])
if payload.get("attachment_path") or payload.get("attachment_filename"):
raise NostrValidationError("Attachments are not supported for Nostr flyer events in V1.")
def _has_tag(source: dict[str, Any], name: str, value: str) -> bool:
tags = source.get("tags", [])
if not isinstance(tags, list):
return False
return any(isinstance(tag, list) and len(tag) >= 2 and tag[0] == name and tag[1] == value for tag in tags)
def _normalize_lang(lang: str) -> str:
return lang if lang in SUPPORTED_LANGS else "en"
def _slugify_folder_name(folder_name: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", folder_name.strip()).strip("-_").lower()
if not slug:
slug = "voxvera"
if len(slug) > FIELD_LIMITS["folder_name"]:
slug = slug[: FIELD_LIMITS["folder_name"]].rstrip("-_") or "voxvera"
return slug
def _validate_url(url: str) -> None:
if not url:
return
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https", "nostr"}:
raise NostrValidationError(f"Unsupported URL scheme: {parsed.scheme or '(none)'}.")
if parsed.scheme in {"http", "https"} and not parsed.netloc:
raise NostrValidationError("URL must include a host.")
+2 -1
View File
@@ -38,7 +38,8 @@ Wants=network-online.target
[Service]
Type=oneshot
ExecStart={voxvera_bin} start-all
KillMode=process
ExecStart={voxvera_bin} _linux_autostart_start
"""
def render_systemd_timer(self) -> str: