mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
Let authors publish a flyer under a stable, identifiable key when they want one, while keeping anonymous-by-default. The editor now signs under one of three identities, routed through activeIdentity()/signActiveEvent(): - Anonymous (default, unchanged): the per-device key, signed locally. - NIP-07 extension: signs via window.nostr.signEvent; the secret never enters the page. - Imported nsec: decoded to a key used to sign. By default the secret lives only in memory for the session. "Remember on this device" stores it encrypted with a PIN (PBKDF2 SHA-256, 600k iterations, random salt -> AES-GCM-256); the plaintext secret is never written to storage. On reload a remembered key is locked until the PIN is entered; a session-only key falls back to anonymous. The signer pill, author npub, naddr, and poster URL all follow the active identity. New identity controls are localized across all 14 languages. Security note (also in docs): a short numeric PIN is brute-forceable offline if the encrypted blob leaks, so it guards casual snooping, not a targeted attacker — the NIP-07 path is stronger, and NIP-46 is a documented follow-up. The minimum PIN is 4 digits but longer is allowed and encouraged. Also updates README, the client spec (new "Publishing identity" section), the roadmap (#1/#2 marked in progress with phases shipped), and the e2e README. Covered by the cross-engine Playwright suite (default-anonymous, NIP-07 connect + publish, session-only nsec without persistence, invalid nsec, PIN encrypt -> lock on reload -> wrong/right PIN -> unlock, and forget). Co-Authored-By: Claude Opus 4.8 <[email protected]>
215 lines
10 KiB
HTML
215 lines
10 KiB
HTML
<!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 id="client-title">VoxVera Nostr Client</h1>
|
|
<p id="client-description">Static editor and viewer for decentralized flyer source events.</p>
|
|
</div>
|
|
<div class="topbar-controls">
|
|
<nav class="mode-tabs" aria-label="Client mode">
|
|
<button id="editor-tab" class="tab active" type="button" data-mode="editor">Editor</button>
|
|
<button id="viewer-tab" class="tab" type="button" data-mode="viewer">Viewer</button>
|
|
</nav>
|
|
<select id="topbar-lang" aria-label="Language"></select>
|
|
<select id="topbar-paper" aria-label="Paper size"></select>
|
|
</div>
|
|
</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>
|
|
<select id="viewer-lang" aria-label="Language"></select>
|
|
<select id="viewer-paper" aria-label="Paper size"></select>
|
|
</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>
|
|
<span id="label-relays">Relays</span>
|
|
<textarea id="editor-relays" rows="3" spellcheck="false"></textarea>
|
|
</label>
|
|
<div class="identity-row">
|
|
<span id="identity-state" class="state-pill">Checking identity</span>
|
|
</div>
|
|
<div class="identity-controls">
|
|
<button type="button" id="generate-anon-identity" class="secondary-button">Generate anonymous npub</button>
|
|
<button type="button" id="connect-nip07" class="secondary-button">Connect extension</button>
|
|
<button type="button" id="use-nsec-toggle" class="secondary-button">Use a private key (nsec)</button>
|
|
</div>
|
|
<!-- Import a private key. The secret signs in-page; it is only written
|
|
to storage (encrypted with the PIN) when "Remember" is checked. -->
|
|
<div id="nsec-import" class="identity-import" hidden>
|
|
<input id="nsec-input" type="password" autocomplete="off" spellcheck="false" placeholder="nsec1…">
|
|
<label class="nsec-remember-row">
|
|
<input type="checkbox" id="nsec-remember">
|
|
<span data-i18n-key="remember_pin">Remember on this device (PIN)</span>
|
|
</label>
|
|
<input id="nsec-pin" type="password" inputmode="numeric" autocomplete="off" placeholder="PIN (4+ digits)" hidden>
|
|
<button type="button" id="nsec-submit" class="secondary-button">Use this key</button>
|
|
</div>
|
|
<!-- Shown on load when a PIN-encrypted identity is stored. -->
|
|
<div id="identity-locked-row" class="identity-import" hidden>
|
|
<span id="identity-locked-note" data-i18n-key="identity_locked">Saved identity is locked. Enter your PIN to unlock it.</span>
|
|
<input id="unlock-pin" type="password" inputmode="numeric" autocomplete="off" placeholder="PIN">
|
|
<button type="button" id="unlock-key" class="secondary-button">Unlock</button>
|
|
<button type="button" id="forget-key" class="secondary-button">Forget</button>
|
|
</div>
|
|
<p id="identity-error" class="field-error" role="alert" hidden></p>
|
|
|
|
<div class="field-grid">
|
|
<label>
|
|
<span id="label-flyer-name">Flyer Name</span>
|
|
<input id="field-folder-name" name="folder_name" maxlength="64" value="voxvera">
|
|
</label>
|
|
<label>
|
|
<span id="label-language">Language</span>
|
|
<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>
|
|
<span id="label-page-title">Page title</span>
|
|
<input id="field-name" name="name" maxlength="120" value="Vox Vera Printable Flyers">
|
|
</label>
|
|
<label>
|
|
<span id="label-title">Title</span>
|
|
<input id="field-title" name="title" maxlength="80" value="TOP SECRET">
|
|
</label>
|
|
<label>
|
|
<span id="label-subtitle">Subtitle</span>
|
|
<input id="field-subtitle" name="subtitle" maxlength="120" value="DO ~~NOT~~ DISTRIBUTE">
|
|
</label>
|
|
<label>
|
|
<span id="label-headline">Headline</span>
|
|
<input id="field-headline" name="headline" maxlength="160" value="OPERATION VOX VERA">
|
|
</label>
|
|
<label>
|
|
<span id="label-content">Content</span>
|
|
<textarea id="field-content" name="content" rows="9" maxlength="10000"></textarea>
|
|
</label>
|
|
<label>
|
|
<span id="label-url-message">URL message</span>
|
|
<input id="field-url-message" name="url_message" maxlength="240" value="Follow this link to learn more.">
|
|
</label>
|
|
<label>
|
|
<span id="label-poster-url">Content URL</span>
|
|
<input id="field-url" name="url" maxlength="2048" value="https://voxvera.org/">
|
|
</label>
|
|
<label>
|
|
<span id="label-footer-message">Footer message</span>
|
|
<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 id="published-event-heading">Published Event</h2>
|
|
<dl class="result-list">
|
|
<dt id="result-event-id-label">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 id="result-author-npub-label">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 id="result-naddr-label">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 id="result-poster-url-label">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 id="result-status-label">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>
|
|
<span id="label-event-id">Event ID</span>
|
|
<input id="viewer-event-id" spellcheck="false" placeholder="naddr, event id, note1, nevent1, or nostr: URL">
|
|
</label>
|
|
<label>
|
|
<span id="label-viewer-relays">Relays</span>
|
|
<textarea id="viewer-relays" rows="3" spellcheck="false"></textarea>
|
|
</label>
|
|
<div class="actions">
|
|
<button id="viewer-fetch-render" 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 id="normalized-config-heading">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 id="preview-heading">Preview</h2>
|
|
<button type="button" id="print-preview">Print preview</button>
|
|
</div>
|
|
<div id="flyer-preview" class="flyer-preview"></div>
|
|
<p class="board-link"><a href="board.html" data-i18n-key="bulletin_board">Bulletin board</a></p>
|
|
</section>
|
|
</main>
|
|
|
|
<div id="publish-modal" class="modal-overlay" hidden>
|
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="publish-modal-title">
|
|
<h2 id="publish-modal-title" data-i18n-key="published_title">Flyer published</h2>
|
|
<p id="publish-modal-message" data-i18n-key="published_message">Copy this URL to see your new flyer:</p>
|
|
<div class="modal-url-row">
|
|
<input id="publish-modal-url" type="text" readonly aria-label="Flyer URL">
|
|
<button type="button" id="publish-modal-copy">Copy</button>
|
|
</div>
|
|
<button type="button" id="publish-modal-close" class="modal-close" data-i18n-key="close">Close</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="locales.js"></script>
|
|
<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>
|