mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
Add VoxVera Nostr static client
This commit is contained in:
1 parent
285d730369
commit
d415eb26a3
14 files changed
+12651
-11
No files matched your search
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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`.
|
||||
+7336
File diff suppressed because it is too large.
Load diff
+11
@@ -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.
|
||||
+2297
File diff suppressed because it is too large.
Load diff
+344
-1
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.")
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user