summaryrefslogtreecommitdiffstats
path: root/internal/assets/web/templates/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'internal/assets/web/templates/index.html')
-rw-r--r--internal/assets/web/templates/index.html1577
1 files changed, 1577 insertions, 0 deletions
diff --git a/internal/assets/web/templates/index.html b/internal/assets/web/templates/index.html
new file mode 100644
index 0000000..0d2d820
--- /dev/null
+++ b/internal/assets/web/templates/index.html
@@ -0,0 +1,1577 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>N2Usenet Gateway v2.7 Nym</title>
+ <link rel="icon" href="/favicon.ico" sizes="any">
+ <link rel="shortcut icon" href="/favicon.ico">
+ <style>
+ :root {
+ --background: #f9f9f9;
+ --card-bg: #fff;
+ --text: #333;
+ --border: #ccc;
+ --primary: #4caf50;
+ --primary-hover: #45a049;
+ --tab-bg: #eee;
+ --input-bg: #fff;
+ --input-readonly: #eee;
+ --progress-bg: #ddd;
+ --success: #28a745;
+ --warning: #ffc107;
+ --error: #dc3545;
+ }
+
+ .dark-theme {
+ --background: #1a1a1a;
+ --card-bg: #2c2c2c;
+ --text: #e0e0e0;
+ --border: #444;
+ --primary: #5cbb60;
+ --primary-hover: #4caf50;
+ --tab-bg: #333;
+ --input-bg: #3c3c3c;
+ --input-readonly: #2a2a2a;
+ --progress-bg: #444;
+ }
+
+ body {
+ background: var(--background);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ margin: 0;
+ padding: 0;
+ transition: background 0.3s ease;
+ }
+
+ .container {
+ max-width: 800px;
+ margin: auto;
+ padding: 20px;
+ }
+
+ header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+
+ .theme-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .toggle {
+ position: relative;
+ width: 60px;
+ height: 30px;
+ background: #ccc;
+ border-radius: 30px;
+ padding: 4px;
+ cursor: pointer;
+ transition: 0.3s;
+ }
+
+ .toggle:before {
+ content: '';
+ position: absolute;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background: white;
+ top: 2px;
+ left: 2px;
+ transition: 0.3s;
+ }
+
+ input[type="checkbox"]:checked + .toggle {
+ background: #4caf50;
+ }
+
+ input[type="checkbox"]:checked + .toggle:before {
+ transform: translateX(30px);
+ }
+
+ input[type="checkbox"] {
+ display: none;
+ }
+
+ .notification {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ max-width: 400px;
+ padding: 15px 20px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ z-index: 10000;
+ animation: slideIn 0.3s ease;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .notification.success { background: var(--success); color: white; }
+ .notification.error { background: var(--error); color: white; }
+ .notification.warning { background: var(--warning); color: #333; }
+ .notification.info { background: #007bff; color: white; }
+
+ @keyframes slideIn {
+ from { transform: translateX(400px); opacity: 0; }
+ to { transform: translateX(0); opacity: 1; }
+ }
+
+ @keyframes pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(156, 39, 176, 0.7); }
+ 50% { box-shadow: 0 0 0 15px rgba(156, 39, 176, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(156, 39, 176, 0); }
+ }
+
+ .notification-icon { font-size: 1.5em; }
+ .notification-close { margin-left: auto; cursor: pointer; font-size: 1.2em; opacity: 0.8; }
+ .notification-close:hover { opacity: 1; }
+
+ .section-info {
+ background: var(--card-bg);
+ border-left: 4px solid var(--primary);
+ padding: 10px 15px;
+ margin-bottom: 15px;
+ border-radius: 0 4px 4px 0;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ .tabs {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 15px;
+ }
+
+ .tabs button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 12px 15px;
+ border: none;
+ background: var(--tab-bg);
+ color: var(--text);
+ cursor: pointer;
+ border-radius: 4px;
+ font-weight: bold;
+ transition: background 0.2s, transform 0.1s;
+ position: relative;
+ }
+
+ .tabs button:hover { background: var(--primary); color: white; }
+ .tabs button:active { transform: scale(0.98); }
+
+ .tabs button.completed::after {
+ content: '✓';
+ position: absolute;
+ right: 15px;
+ background: var(--success);
+ color: white;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.9em;
+ }
+
+ .tab-content {
+ display: none !important;
+ padding: 20px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+ margin-bottom: 20px;
+ }
+
+ .tab-content.active {
+ display: block !important;
+ }
+
+ label {
+ display: block;
+ margin-top: 15px;
+ font-weight: bold;
+ margin-bottom: 5px;
+ }
+
+ input, textarea, select {
+ width: 100%;
+ padding: 10px;
+ margin-top: 5px;
+ box-sizing: border-box;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--input-bg);
+ color: var(--text);
+ font-family: inherit;
+ }
+
+ input:focus, textarea:focus, select:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
+ }
+
+ input.error, textarea.error { border-color: var(--error); }
+
+ input[readonly], textarea[readonly] {
+ background: var(--input-readonly);
+ cursor: not-allowed;
+ border: 2px solid #999;
+ }
+
+ input.prefilled {
+ background: #e8f5e9;
+ border: 2px solid var(--primary);
+ }
+
+ .dark-theme input.prefilled {
+ background: #1b3d1b;
+ }
+
+ button {
+ margin-top: 15px;
+ padding: 12px 20px;
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: bold;
+ transition: background 0.2s, opacity 0.2s;
+ font-family: inherit;
+ }
+
+ button:hover:not(:disabled) { background: var(--primary-hover); }
+ button:disabled { opacity: 0.6; cursor: not-allowed; }
+
+ .btn-secondary {
+ background: #6c757d;
+ }
+ .btn-secondary:hover:not(:disabled) { background: #5a6268; }
+
+ .progress-bar {
+ width: 100%;
+ background: var(--progress-bg);
+ border-radius: 5px;
+ overflow: hidden;
+ height: 20px;
+ margin-top: 10px;
+ }
+
+ .progress-bar-inner {
+ height: 100%;
+ width: 0;
+ background: var(--primary);
+ text-align: center;
+ color: white;
+ line-height: 20px;
+ transition: width 0.3s;
+ font-size: 0.85em;
+ }
+
+ .output-field {
+ margin-top: 15px;
+ padding: 10px;
+ background: var(--input-readonly);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ word-break: break-all;
+ min-height: 30px;
+ font-family: monospace;
+ font-size: 0.9em;
+ }
+
+ .output-field.empty { color: #999; font-style: italic; }
+
+ .gateway-info {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ padding: 15px;
+ margin: 15px 0;
+ border-radius: 4px;
+ border-left: 4px solid #007bff;
+ }
+
+ .gateway-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .gateway-item:last-child { border-bottom: none; }
+
+ .gateway-priority {
+ background: #007bff;
+ color: white;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.8em;
+ margin-right: 10px;
+ min-width: 70px;
+ text-align: center;
+ }
+
+ .gateway-address {
+ font-family: monospace;
+ background: var(--input-readonly);
+ padding: 4px 8px;
+ border-radius: 4px;
+ flex: 1;
+ font-size: 0.85em;
+ }
+
+ .prefill-banner {
+ background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
+ color: white;
+ padding: 12px 15px;
+ border-radius: 4px;
+ margin-bottom: 15px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
+ }
+
+ .prefill-banner.reply {
+ background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
+ }
+
+ .prefill-banner-icon { font-size: 1.5em; }
+ .prefill-banner-text { flex: 1; }
+ .prefill-banner-text strong { display: block; margin-bottom: 2px; }
+ .prefill-banner-text small { opacity: 0.9; }
+
+ .keypair-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 15px;
+ }
+
+ .keypair-actions button {
+ flex: 1;
+ min-width: 150px;
+ }
+
+ .identity-loaded {
+ background: #d4edda;
+ border: 1px solid #c3e6cb;
+ padding: 10px 15px;
+ border-radius: 4px;
+ margin-top: 15px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .dark-theme .identity-loaded {
+ background: #1e3d1e;
+ border-color: #2e5d2e;
+ }
+
+ .identity-loaded-icon { font-size: 1.5em; }
+ .identity-loaded-text { flex: 1; }
+
+ .identicon-preview {
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 10px;
+ }
+
+ .identicon-preview img {
+ width: 32px;
+ height: 32px;
+ image-rendering: pixelated;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ }
+
+ .identicon-section {
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-left: 4px solid #9c27b0;
+ padding: 15px;
+ margin: 15px 0;
+ border-radius: 0 4px 4px 0;
+ text-align: center;
+ }
+
+ .identicon-section h4 {
+ margin: 0 0 15px 0;
+ color: #9c27b0;
+ }
+
+ .identicon-large-preview {
+ margin: 15px 0;
+ }
+
+ .identicon-hash {
+ font-size: 0.8em;
+ color: #666;
+ word-break: break-all;
+ margin: 10px 0;
+ }
+
+ .identicon-hash code {
+ background: var(--input-readonly);
+ padding: 4px 8px;
+ border-radius: 4px;
+ display: inline-block;
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
+ .identicon-actions {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+ flex-wrap: wrap;
+ margin: 15px 0;
+ }
+
+ .identicon-actions button {
+ margin: 0;
+ padding: 8px 15px;
+ font-size: 0.9em;
+ }
+
+ .identicon-tip {
+ background: #f3e5f5;
+ padding: 10px;
+ border-radius: 4px;
+ margin-top: 10px;
+ }
+
+ .dark-theme .identicon-tip {
+ background: #2d1f30;
+ }
+
+ footer {
+ text-align: center;
+ margin-top: 40px;
+ padding-top: 20px;
+ border-top: 1px solid var(--border);
+ font-size: 0.9em;
+ color: #888;
+ }
+
+ .footer-links {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ margin-top: 10px;
+ }
+
+ .footer-links a {
+ color: var(--primary);
+ text-decoration: none;
+ transition: color 0.2s;
+ }
+
+ .footer-links a:hover {
+ color: var(--primary-hover);
+ text-decoration: underline;
+ }
+
+ @media (max-width: 600px) {
+ .container { padding: 10px; }
+ .tab-content { padding: 15px 10px; }
+ .footer-links { flex-direction: column; gap: 10px; }
+ .gateway-item { flex-direction: column; align-items: flex-start; gap: 5px; }
+ .gateway-address { width: 100%; }
+ .notification { right: 10px; left: 10px; max-width: none; }
+ .keypair-actions { flex-direction: column; }
+ .keypair-actions button { min-width: 100%; }
+ }
+ </style>
+ <script src="/static/nacl.min.js"></script>
+ <script src="/static/nacl-util.min.js"></script>
+</head>
+<body>
+<div class="container">
+ <header>
+ <h1>N2Usenet Gateway v2.7 Nym</h1>
+ <div class="theme-toggle">
+ <span>🌞</span>
+ <input type="checkbox" id="themeToggle">
+ <label for="themeToggle" class="toggle"></label>
+ <span>🌙</span>
+ </div>
+ </header>
+
+ <div id="prefillBanner" class="prefill-banner" style="display: none;">
+ <span class="prefill-banner-icon">📝</span>
+ <div class="prefill-banner-text">
+ <strong id="prefillTitle">New Post</strong>
+ <small id="prefillDetails">Fields pre-filled from Onion Newsreader</small>
+ </div>
+ </div>
+
+ <div class="tabs">
+ <button id="tabBtn1" onclick="showTab('pow')">
+ <span>1. Generate Hashcash Token</span>
+ </button>
+ <button id="tabBtn2" onclick="showTab('sign')">
+ <span>2. Sign Message (Identity)</span>
+ </button>
+ <button id="tabBtn3" onclick="showTab('send')">
+ <span>3. Send Message</span>
+ </button>
+ </div>
+
+ <div id="pow" class="tab-content active">
+ <h2>1. Load Identity or Start Fresh</h2>
+
+ <div class="identity-loader" style="background: var(--card-bg); border: 2px dashed var(--primary); border-radius: 8px; padding: 20px; margin-bottom: 20px; text-align: center;">
+ <p style="margin: 0 0 15px 0;"><strong>🔑 Have a saved identity?</strong> Load it to auto-fill all fields.</p>
+ <button id="loadIdentityBtn" class="btn-primary" style="font-size: 1.1em; padding: 12px 30px;">📂 Load Identity File</button>
+ <input type="file" id="identityFileInput" accept=".json" style="display:none;">
+ <p style="margin: 15px 0 0 0; font-size: 0.9em; color: var(--text); opacity: 0.7;">Or fill the fields below to create a new identity.</p>
+ </div>
+
+ <div id="identityLoadedPow" class="identity-loaded" style="display:none; margin-bottom: 20px;">
+ <span class="identity-loaded-icon">✅</span>
+ <div class="identity-loaded-text">
+ <strong id="loadedIdentityName">Identity Loaded</strong>
+ <small id="loadedIdentityEmail">email@example.com</small>
+ </div>
+ <div class="identicon-preview">
+ <img id="identiconPreviewPow" alt="Your identicon" style="width: 48px; height: 48px; image-rendering: pixelated; border-radius: 4px;">
+ </div>
+ </div>
+
+ <div class="section-info">
+ <p><strong>About N2Usenet:</strong> A privacy-focused standalone gateway that posts to Usenet newsgroups over Nym to the TCPReset Mail2News and NNTP infrastructure. No access logs are kept. To post you must first create, or load, a <strong>VFACE pseudonymous identity</strong>: an Ed25519 keypair whose public key deterministically generates your identicon. Same key &rarr; same identicon &rarr; same person over time.</p>
+ <p><strong>What is Proof-of-Work?</strong> This step generates a hashcash token that prevents spam by requiring your computer to perform some calculations.</p>
+ </div>
+
+ <label>Username (your pseudonym): <input type="text" id="fromName" placeholder="YourPseudonym" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
+ <label>Email (for identity hash): <input type="email" id="hcEmail" placeholder="your@email.example" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></label>
+ <label>Difficulty (bits):
+ <select id="hcBits">
+ <option value="16">16 bits (very fast, ~instant - recommended for hardened browsers)</option>
+ <option value="20" selected>20 bits (fast, ~few seconds)</option>
+ <option value="24">24 bits (medium, ~30-60 seconds)</option>
+ <option value="28">28 bits (slow, ~several minutes)</option>
+ </select>
+ </label>
+ <button id="genTokenBtn">Generate Token</button>
+ <div class="progress-bar"><div id="tokenProgress" class="progress-bar-inner">Ready</div></div>
+ <label>Generated Token:</label>
+ <div id="tokenOutput" class="output-field empty">Token will appear here after generation</div>
+ </div>
+
+ <div id="sign" class="tab-content">
+ <h2>2. Sign Your Message</h2>
+
+ <div id="identiconSection" class="identicon-section" style="display:none;">
+ <h4>🎨 Your Visual Identity</h4>
+ <div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
+ <div class="identicon-large-preview">
+ <img id="identiconPreviewLarge" alt="Your identicon" style="width:96px;height:96px;image-rendering:pixelated;border:2px solid var(--border);border-radius:8px;">
+ </div>
+ <div style="flex: 1; min-width: 200px;">
+ <p style="margin: 0 0 5px 0;"><strong id="identityUsername">Username</strong></p>
+ <p style="margin: 0 0 10px 0; font-size: 0.9em; color: var(--text); opacity: 0.8;" id="identityEmail">email@example.com</p>
+ <p class="identicon-hash" style="margin: 0;">Hash: <code id="identityHashDisplay"></code></p>
+ </div>
+ </div>
+ <p style="margin-top: 15px; text-align: center; font-size: 0.95em;">Identity material stays only in browser memory for this tab and is wiped after a successful send.</p>
+ <p style="margin-top: 15px; text-align: center; font-size: 0.9em;">
+ <a href="https://identicons.virebent.art" target="_blank" rel="noopener noreferrer" style="color: #9c27b0; text-decoration: none;">🔐 VFACE Verifier - Verify an identity</a>
+ </p>
+ </div>
+
+ <div class="section-info">
+ <p><strong>What is this?</strong> Your keypair is generated and your message is signed <strong>locally in your browser</strong> with the self-hosted TweetNaCl (<code>nacl</code>) library, and your Ed25519 secret key never leaves your device. Only the public identity (username, email, public key) is sent, to render the identicon. The signature proves you authored this post.</p>
+ <p><strong>VFACE / Face Header:</strong> Your identicon is produced by the original <code>identicons-cli</code> engine, the same backend as <code>identicons.virebent.art</code>, so it is fully deterministic: the same key always yields the same identicon. It is embedded as a folded <code>Face:</code> header (RFC&nbsp;4021/2822) alongside <code>X-Ed25519-Pub</code> and <code>X-Ed25519-Sig</code>, visible in newsreaders like Newsgrouper and re-verifiable by any reader from your public key.</p>
+ </div>
+
+ <label>Email (from PoW):</label>
+ <input type="text" id="readonlyEmailSign" readonly>
+
+ <label>Message to Sign:</label>
+ <textarea id="messageToSign" rows="6" placeholder="Write your message here..." autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required></textarea>
+
+ <div class="keypair-actions">
+ <button id="genKeyBtn">🔑 Generate New Keypair</button>
+ </div>
+ <input type="file" id="keyFileInput" accept=".json" style="display:none;">
+
+ <button id="signMsgBtn" disabled>✍️ Sign Message</button>
+
+ <label>Public Key:</label>
+ <div id="pubKeyOutput" class="output-field empty">Public key will appear here</div>
+ <label>Signature:</label>
+ <div id="signatureOutput" class="output-field empty">Signature will appear here</div>
+ </div>
+
+ <div id="send" class="tab-content">
+ <h2>Send Message</h2>
+ <div class="section-info">
+ <p><strong>What is this?</strong> This final step sends your signed message to Usenet via Nym, Mail2News, and the <code>news.tcpreset.net</code> NNTP service.</p>
+ <div class="gateway-info">
+ <h4>🔐 Nym to TCPReset Posting Path</h4>
+ <p style="margin-bottom: 15px; font-size: 0.95em;">Your message is submitted over HTTPS, then relayed from this server through Nym to the Mail2News gateway and finally into <code>news.tcpreset.net</code> NNTP:</p>
+
+ <div class="gateway-item">
+ <span class="gateway-priority">Step 1</span>
+ <div style="flex: 1;">
+ <strong>Nym SMTP transport</strong>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Nym SOCKS5/requester transport to the TCPReset Mail2News SMTP gateway.</p>
+ </div>
+ </div>
+ <details style="margin-top: 8px; font-size: 0.8em;">
+ <summary style="cursor: pointer; color: #007bff;">Show relay nodes</summary>
+ <ul style="margin: 8px 0; padding-left: 20px; font-family: monospace; color: #555;">
+ <li><strong>SMTP:</strong> mail2news.tcpreset.net:587 STARTTLS via Nym SOCKS5</li>
+ </ul>
+ </details>
+
+ <div class="gateway-item">
+ <span class="gateway-priority">Step 2</span>
+ <div style="flex: 1;">
+ <strong>Mail2News Gateway</strong>
+ <div class="gateway-address" style="margin-top: 5px;">mail2news@mail2news.tcpreset.net</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Converts the signed email payload into a Usenet post for the TCPReset news service.</p>
+ </div>
+ </div>
+
+ <div class="gateway-item">
+ <span class="gateway-priority">Step 3</span>
+ <div style="flex: 1;">
+ <strong>TCPReset NNTP</strong>
+ <div class="gateway-address" style="margin-top: 5px;">nntp://news.tcpreset.net</div>
+ <p style="font-size: 0.85em; margin: 5px 0 0 0; color: #666;">Final posting target for the standalone N2Usenet path.</p>
+ </div>
+ </div>
+
+ <p style="margin-top: 15px; padding: 10px; background: rgba(0,123,255,0.1); border-radius: 4px; font-size: 0.9em;">
+ <strong>🔒 Privacy Guarantee:</strong> The browser-to-server hop is HTTPS. The server-to-Mail2News hop uses Nym plus SMTP TLS. Mail2News then posts into <code>news.tcpreset.net</code> NNTP.
+ </p>
+ </div>
+ </div>
+
+ <label>Email used for PoW:</label>
+ <input type="text" id="readonlyEmailSend" readonly>
+ <form id="sendForm" method="POST" action="/submit" autocomplete="off">
+ <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
+ <label>From (Name):</label>
+ <input type="text" id="readonlyFromName" readonly>
+ <input type="hidden" name="from" id="fromFull">
+ <label>Newsgroups (max 3, comma separated):
+ <input type="text" name="newsgroups" id="newsgroups" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required placeholder="e.g. alt.privacy, comp.security">
+ </label>
+ <label>Subject: <input type="text" name="subject" id="subject" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required placeholder="Message Subject"></label>
+ <label>References (optional): <input type="text" name="references" id="references" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Message-ID of post you're replying to"></label>
+ <label>X-Hashcash Token: <input type="text" name="xhashcash" id="hcToken" required readonly></label>
+ <label>Message:</label>
+ <textarea name="message" id="messageContent" rows="8" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required placeholder="Your message will appear here after signing..."></textarea>
+ <input type="hidden" name="x-ed25519-pub" id="x-ed25519-pub">
+ <input type="hidden" name="x-ed25519-sig" id="x-ed25519-sig">
+ <button type="submit" id="sendBtn">Send Message</button>
+ </form>
+ </div>
+
+ <footer>
+ <div>N2Usenet Gateway v2.7.0 © 2025 - Privacy-focused Usenet posting via Nym</div>
+ <div class="footer-links">
+ <a href="https://yamn.virebent.art">Home</a>
+ <a href="&#109;&#97;&#105;&#108;&#116;&#111;&#58;%69%6E%66%6F%40%76%69%72%65%62%65%6E%74%2E%61%72%74">Contact</a>
+ <a href="https://github.com/gabrix73/m2usenet-go">Code</a>
+ </div>
+ </footer>
+</div>
+
+<script>
+// ============================================================================
+// GLOBAL FUNCTIONS - MUST BE DEFINED FIRST
+// ============================================================================
+
+function showTab(id) {
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
+ document.getElementById(id).classList.add('active');
+}
+
+function showNotification(message, type, duration) {
+ type = type || 'info';
+ duration = duration === undefined ? 5000 : duration;
+ const icons = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
+ const notification = document.createElement('div');
+ notification.className = `notification ${type}`;
+ notification.innerHTML = `
+ <span class="notification-icon">${icons[type] || icons.info}</span>
+ <span class="notification-message">${message}</span>
+ <span class="notification-close" onclick="this.parentElement.remove()">×</span>
+ `;
+ document.body.appendChild(notification);
+ if (duration > 0) {
+ setTimeout(() => { if (notification.parentElement) notification.remove(); }, duration);
+ }
+}
+
+// ============================================================================
+// STATE MANAGEMENT
+// ============================================================================
+
+const appState = {
+ step1Complete: false,
+ step2Complete: false,
+ step3Complete: false,
+ prefillMode: null
+};
+
+function updateTabIndicators() {
+ document.getElementById('tabBtn1').classList.toggle('completed', appState.step1Complete);
+ document.getElementById('tabBtn2').classList.toggle('completed', appState.step2Complete);
+ document.getElementById('tabBtn3').classList.toggle('completed', appState.step3Complete);
+}
+
+let keyPair = null;
+let workersSupported = true;
+
+function setOutputField(id, value, emptyText) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ if (value) {
+ el.innerText = value;
+ el.classList.remove('empty');
+ return;
+ }
+ el.innerText = emptyText;
+ el.classList.add('empty');
+}
+
+function resetEphemeralState(messageID) {
+ if (keyPair) {
+ if (keyPair.secretKey && typeof keyPair.secretKey.fill === 'function') keyPair.secretKey.fill(0);
+ if (keyPair.publicKey && typeof keyPair.publicKey.fill === 'function') keyPair.publicKey.fill(0);
+ }
+ keyPair = null;
+ currentIdenticonData = null;
+
+ appState.step1Complete = false;
+ appState.step2Complete = false;
+ appState.step3Complete = false;
+ updateTabIndicators();
+
+ [
+ 'fromName', 'hcEmail', 'readonlyEmailSign', 'readonlyEmailSend',
+ 'readonlyFromName', 'fromFull', 'newsgroups', 'subject', 'references',
+ 'hcToken', 'messageToSign', 'messageContent', 'x-ed25519-pub',
+ 'x-ed25519-sig'
+ ].forEach((id) => {
+ const el = document.getElementById(id);
+ if (el) el.value = '';
+ });
+
+ ['identityFileInput', 'keyFileInput'].forEach((id) => {
+ const el = document.getElementById(id);
+ if (el) el.value = '';
+ });
+
+ document.querySelectorAll('input, textarea').forEach((el) => {
+ el.classList.remove('error', 'prefilled');
+ });
+
+ const hcBits = document.getElementById('hcBits');
+ if (hcBits) hcBits.value = '20';
+
+ setOutputField('tokenOutput', '', 'Token will appear here after generation');
+ setOutputField('pubKeyOutput', '', 'Public key will appear here');
+ setOutputField('signatureOutput', '', 'Signature will appear here');
+
+ const tokenProgress = document.getElementById('tokenProgress');
+ if (tokenProgress) {
+ tokenProgress.style.width = '0';
+ tokenProgress.textContent = 'Ready';
+ }
+
+ const identityLoaded = document.getElementById('identityLoadedPow');
+ if (identityLoaded) identityLoaded.style.display = 'none';
+ const identiconSection = document.getElementById('identiconSection');
+ if (identiconSection) identiconSection.style.display = 'none';
+ const prefillBanner = document.getElementById('prefillBanner');
+ if (prefillBanner) {
+ prefillBanner.style.display = 'none';
+ prefillBanner.classList.remove('reply');
+ }
+
+ ['identiconPreviewPow', 'identiconPreviewLarge'].forEach((id) => {
+ const img = document.getElementById(id);
+ if (img) img.removeAttribute('src');
+ });
+ const identityHash = document.getElementById('identityHashDisplay');
+ if (identityHash) identityHash.textContent = '';
+ const loadedName = document.getElementById('loadedIdentityName');
+ if (loadedName) loadedName.textContent = 'Identity Loaded';
+ const loadedEmail = document.getElementById('loadedIdentityEmail');
+ if (loadedEmail) loadedEmail.textContent = 'Identity kept only in memory';
+ const identityUsername = document.getElementById('identityUsername');
+ if (identityUsername) identityUsername.textContent = 'Username';
+ const identityEmail = document.getElementById('identityEmail');
+ if (identityEmail) identityEmail.textContent = 'email@example.com';
+
+ const genKeyBtn = document.getElementById('genKeyBtn');
+ if (genKeyBtn) {
+ genKeyBtn.disabled = false;
+ genKeyBtn.textContent = '🔑 Generate New Keypair';
+ }
+ const signBtn = document.getElementById('signMsgBtn');
+ if (signBtn) signBtn.disabled = true;
+ const sendBtn = document.getElementById('sendBtn');
+ if (sendBtn) {
+ sendBtn.disabled = false;
+ sendBtn.textContent = 'Send Message';
+ sendBtn.style.opacity = '';
+ sendBtn.style.cursor = '';
+ }
+ const progressDiv = document.getElementById('sending-progress');
+ if (progressDiv) progressDiv.remove();
+
+ if (window.history && window.history.replaceState) {
+ window.history.replaceState({}, document.title, window.location.pathname);
+ }
+
+ showTab('pow');
+
+ if (messageID) {
+ showNotification(`✓ Message sent. Message-ID: ${messageID}. Identity and form data cleared from memory.`, 'success', 12000);
+ }
+}
+
+try {
+ if (typeof Worker === 'undefined') {
+ workersSupported = false;
+ }
+} catch (e) {
+ workersSupported = false;
+}
+
+// ============================================================================
+// THEME TOGGLE
+// ============================================================================
+
+document.getElementById('themeToggle').addEventListener('change', function() {
+ document.body.classList.toggle('dark-theme', this.checked);
+});
+
+// ============================================================================
+// IDENTICON GENERATION (client-side)
+// ============================================================================
+
+let currentIdenticonData = null;
+
+async function generateIdenticon(username, email, pubkeyBase64) {
+ // Generate the identicon with the ORIGINAL identicons-cli engine (server-side),
+ // the same backend used by identicons.virebent.art (Ch1ffr3punk algorithm).
+ // Only PUBLIC data leaves the browser (username|email|ed25519-pubkey).
+ const body = new URLSearchParams({ username: username, email: email, pubkey: pubkeyBase64 });
+ const resp = await fetch('identicon.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString()
+ });
+ if (!resp.ok) throw new Error('identicon engine error ' + resp.status);
+ const r = await resp.json();
+ if (!r.face48 || !r.preview) throw new Error('identicon engine returned no data');
+
+ currentIdenticonData = {
+ dataUrl: r.preview, // 256x256 PNG dataurl (display/download)
+ hash: r.hash, // sha256(username|email|pubkey)
+ face48: r.face48, // raw base64 PNG (48x48) -> Face: header
+ faceHeader: r.faceHeader, // folded "Face: ..." header (RFC 2822)
+ username: username,
+ email: email,
+ pubkey: pubkeyBase64,
+ claim: JSON.stringify({
+ version: 2,
+ type: 'n2usenet-identity',
+ username: username,
+ email: email,
+ pubkey: pubkeyBase64,
+ identityHash: r.hash,
+ created: new Date().toISOString(),
+ algorithm: 'identicons-cli sha256(username|email|pubkey) [VFACE]'
+ }, null, 2)
+ };
+ return r.preview;
+}
+
+async function updateIdenticonPreview() {
+ if (!keyPair) return;
+
+ const pubB64 = nacl.util.encodeBase64(keyPair.publicKey);
+ const username = document.getElementById('fromName').value || 'Anonymous';
+ const email = document.getElementById('hcEmail').value || 'anonymous@n2usenet.local';
+
+ try {
+ const identiconUrl = await generateIdenticon(username, email, pubB64);
+
+ document.getElementById('identiconPreviewPow').src = identiconUrl;
+ document.getElementById('identiconPreviewLarge').src = identiconUrl;
+ document.getElementById('identityHashDisplay').textContent = currentIdenticonData.hash;
+ document.getElementById('identityUsername').textContent = username;
+ document.getElementById('identityEmail').textContent = email;
+ document.getElementById('identiconSection').style.display = 'block';
+
+ } catch (e) {
+ console.error('Identicon generation failed:', e);
+ }
+}
+
+// ============================================================================
+// INITIALIZATION
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', function() {
+ if (typeof nacl === 'undefined') {
+ showNotification('Cryptography library failed to load. Please refresh the page.', 'error', 0);
+ return;
+ }
+
+ if (typeof nacl.util === 'undefined') {
+ nacl.util = {
+ decodeUTF8: function(str) { return new TextEncoder().encode(str); },
+ encodeUTF8: function(arr) { return new TextDecoder().decode(arr); },
+ encodeBase64: function(arr) {
+ return btoa(Array.from(new Uint8Array(arr)).map(byte => String.fromCharCode(byte)).join(''));
+ },
+ decodeBase64: function(b64) {
+ const bin = atob(b64);
+ const arr = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
+ return arr;
+ }
+ };
+ }
+
+ showTab('pow');
+ showNotification('N2Usenet Gateway v2.7.0 ready. No identity or message data is persisted.', 'success', 4000);
+});
+
+// ============================================================================
+// SHA-1 IMPLEMENTATION
+// ============================================================================
+
+function sha1(str) {
+ function rotate_left(n, s) { return (n << s) | (n >>> (32 - s)); }
+ function cvt_hex(val) {
+ let str = '';
+ for (let i = 7; i >= 0; i--) {
+ const v = (val >>> (i * 4)) & 0x0f;
+ str += v.toString(16);
+ }
+ return str;
+ }
+ function utf8Encode(str) { return unescape(encodeURIComponent(str)); }
+
+ let blockstart, i, j;
+ const W = new Array(80);
+ let H0 = 0x67452301, H1 = 0xEFCDAB89, H2 = 0x98BADCFE, H3 = 0x10325476, H4 = 0xC3D2E1F0;
+ let A, B, C, D, E, temp;
+
+ str = utf8Encode(str);
+ const str_len = str.length;
+ const word_array = [];
+
+ for (i = 0; i < str_len - 3; i += 4) {
+ j = str.charCodeAt(i) << 24 | str.charCodeAt(i + 1) << 16 | str.charCodeAt(i + 2) << 8 | str.charCodeAt(i + 3);
+ word_array.push(j);
+ }
+
+ switch (str_len % 4) {
+ case 0: i = 0x080000000; break;
+ case 1: i = str.charCodeAt(str_len - 1) << 24 | 0x0800000; break;
+ case 2: i = str.charCodeAt(str_len - 2) << 24 | str.charCodeAt(str_len - 1) << 16 | 0x08000; break;
+ case 3: i = str.charCodeAt(str_len - 3) << 24 | str.charCodeAt(str_len - 2) << 16 | str.charCodeAt(str_len - 1) << 8 | 0x80; break;
+ }
+
+ word_array.push(i);
+ while ((word_array.length % 16) != 14) word_array.push(0);
+ word_array.push(str_len >>> 29);
+ word_array.push((str_len << 3) & 0x0ffffffff);
+
+ for (blockstart = 0; blockstart < word_array.length; blockstart += 16) {
+ for (i = 0; i < 16; i++) W[i] = word_array[blockstart + i];
+ for (i = 16; i <= 79; i++) W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1);
+
+ A = H0; B = H1; C = H2; D = H3; E = H4;
+
+ for (i = 0; i <= 19; i++) {
+ temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff;
+ E = D; D = C; C = rotate_left(B, 30); B = A; A = temp;
+ }
+ for (i = 20; i <= 39; i++) {
+ temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff;
+ E = D; D = C; C = rotate_left(B, 30); B = A; A = temp;
+ }
+ for (i = 40; i <= 59; i++) {
+ temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff;
+ E = D; D = C; C = rotate_left(B, 30); B = A; A = temp;
+ }
+ for (i = 60; i <= 79; i++) {
+ temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff;
+ E = D; D = C; C = rotate_left(B, 30); B = A; A = temp;
+ }
+
+ H0 = (H0 + A) & 0x0ffffffff;
+ H1 = (H1 + B) & 0x0ffffffff;
+ H2 = (H2 + C) & 0x0ffffffff;
+ H3 = (H3 + D) & 0x0ffffffff;
+ H4 = (H4 + E) & 0x0ffffffff;
+ }
+
+ return cvt_hex(H0) + cvt_hex(H1) + cvt_hex(H2) + cvt_hex(H3) + cvt_hex(H4);
+}
+
+function mineSingleThread(prefix, targetZeros, progressCallback, foundCallback) {
+ const target = '0'.repeat(targetZeros);
+ let nonce = 0, checked = 0;
+ const batchSize = 100;
+
+ function mineNextBatch() {
+ const endNonce = nonce + batchSize;
+ while (nonce < endNonce) {
+ const token = prefix + nonce;
+ const hash = sha1(token);
+ checked++;
+ if (hash.startsWith(target)) {
+ foundCallback(nonce, checked);
+ return;
+ }
+ nonce++;
+ }
+ progressCallback(checked);
+ setTimeout(mineNextBatch, 0);
+ }
+ mineNextBatch();
+}
+
+// ============================================================================
+// STEP 1: HASHCASH TOKEN GENERATION
+// ============================================================================
+
+document.getElementById('genTokenBtn').onclick = () => {
+ const email = document.getElementById('hcEmail').value.trim();
+ const bits = parseInt(document.getElementById('hcBits').value);
+
+ if (!email) {
+ showNotification('⚠ Please enter your email address first!', 'warning');
+ document.getElementById('hcEmail').classList.add('error');
+ document.getElementById('hcEmail').focus();
+ return;
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ showNotification('⚠ Please enter a valid email address!', 'warning');
+ document.getElementById('hcEmail').classList.add('error');
+ document.getElementById('hcEmail').focus();
+ return;
+ }
+
+ document.getElementById('hcEmail').classList.remove('error');
+ showNotification(`🔨 Starting token generation (${bits} bits)...`, 'info');
+
+ const btn = document.getElementById('genTokenBtn');
+ btn.disabled = true;
+ btn.textContent = 'Mining Token...';
+
+ const now = new Date();
+ const timestamp = now.getUTCFullYear().toString().slice(2) +
+ ("0"+(now.getUTCMonth()+1)).slice(-2) +
+ ("0"+now.getUTCDate()).slice(-2) +
+ ("0"+now.getUTCHours()).slice(-2) +
+ ("0"+now.getUTCMinutes()).slice(-2) +
+ ("0"+now.getUTCSeconds()).slice(-2);
+ const ext = "";
+ const rand = Math.floor(Math.random()*1e6);
+ const prefix = `1:${bits}:${timestamp}:${email}:${ext}:${rand}:`;
+ const zeros = bits/4;
+
+ const progBar = document.getElementById('tokenProgress');
+ progBar.style.width = '0%';
+ progBar.innerText = 'Mining...';
+
+ let totalChecked = 0;
+ let startTime = Date.now();
+
+ function updateProgress(checked) {
+ totalChecked += checked;
+ const elapsed = (Date.now() - startTime) / 1000;
+ const hashRate = Math.round(totalChecked / elapsed);
+ progBar.style.width = Math.min(95, (totalChecked / 10000)) + '%';
+ progBar.innerText = `${totalChecked} hashes (${hashRate}/s)`;
+ }
+
+ function onFound(nonce, totalHashes) {
+ const token = prefix + nonce;
+ const outputField = document.getElementById('tokenOutput');
+ outputField.innerText = token;
+ outputField.classList.remove('empty');
+ document.getElementById('hcToken').value = token;
+
+ const fromName = document.getElementById('fromName').value || 'Anonymous';
+ document.getElementById('fromFull').value = `${fromName} <${email}>`;
+ document.getElementById('readonlyFromName').value = fromName;
+ document.getElementById('readonlyEmailSign').value = email;
+ document.getElementById('readonlyEmailSend').value = email;
+
+ btn.disabled = false;
+ btn.textContent = 'Generate Token';
+ progBar.style.width = '100%';
+ progBar.innerText = `Complete! (${totalHashes} hashes)`;
+
+ appState.step1Complete = true;
+ updateTabIndicators();
+
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+ showNotification(`✓ Token generated in ${elapsed}s!`, 'success', 8000);
+
+ setTimeout(() => showTab('sign'), 2000);
+ }
+
+ if (workersSupported) {
+ try {
+ const cores = navigator.hardwareConcurrency || 2;
+ let found = false;
+ let coreChecked = Array(cores).fill(0);
+ const workers = [];
+
+ function updateCombinedProgress() {
+ const sum = coreChecked.reduce((a,b)=>a+b,0);
+ updateProgress(sum - totalChecked);
+ }
+
+ for (let i = 0; i < cores; i++) {
+ try {
+ const w = new Worker('/static/powWorker.js');
+ w.postMessage({ prefix, targetZeros: zeros, startNonce: i, step: cores });
+
+ w.onmessage = e => {
+ if (found) return;
+ if (e.data.type === 'found') {
+ found = true;
+ workers.forEach(x => x.terminate());
+ onFound(e.data.nonce, e.data.checked);
+ }
+ if (e.data.type === 'progress') {
+ coreChecked[i] = e.data.checked;
+ updateCombinedProgress();
+ }
+ };
+
+ w.onerror = (err) => {
+ if (i === 0) {
+ workers.forEach(x => x.terminate());
+ mineSingleThread(prefix, zeros, updateProgress, onFound);
+ }
+ };
+
+ workers.push(w);
+ } catch (err) {
+ if (i === 0) {
+ mineSingleThread(prefix, zeros, updateProgress, onFound);
+ return;
+ }
+ }
+ }
+
+ } catch (err) {
+ mineSingleThread(prefix, zeros, updateProgress, onFound);
+ }
+ } else {
+ mineSingleThread(prefix, zeros, updateProgress, onFound);
+ }
+};
+
+// ============================================================================
+// STEP 2: ED25519 SIGNATURE + KEYPAIR MANAGEMENT
+// ============================================================================
+
+document.getElementById('genKeyBtn').onclick = async function() {
+ try {
+ showNotification('🔐 Generating Ed25519 key pair...', 'info');
+ keyPair = nacl.sign.keyPair();
+
+ const pubB64 = nacl.util.encodeBase64(keyPair.publicKey);
+
+ const pubOutput = document.getElementById('pubKeyOutput');
+ pubOutput.innerText = pubB64;
+ pubOutput.classList.remove('empty');
+ document.getElementById('x-ed25519-pub').value = pubB64;
+ document.getElementById('signMsgBtn').disabled = false;
+
+ const username = document.getElementById('fromName').value || 'Anonymous';
+ const email = document.getElementById('hcEmail').value || '';
+ document.getElementById('identityLoadedPow').style.display = 'flex';
+ document.getElementById('loadedIdentityName').textContent = username;
+ document.getElementById('loadedIdentityEmail').textContent = email || 'Identity active only in memory';
+
+ await updateIdenticonPreview();
+ showNotification('✓ Identity generated in memory. Now write and sign your message.', 'success', 6000);
+ } catch (error) {
+ showNotification('✗ Error: ' + error.message, 'error');
+ }
+};
+
+document.getElementById('loadIdentityBtn').onclick = function() {
+ document.getElementById('identityFileInput').click();
+};
+
+document.getElementById('identityFileInput').onchange = async function(e) {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text);
+
+ if (!data.publicKey || !data.secretKey) {
+ throw new Error('Invalid identity file format');
+ }
+
+ const pubKey = nacl.util.decodeBase64(data.publicKey);
+ const secKey = nacl.util.decodeBase64(data.secretKey);
+
+ if (pubKey.length !== 32 || secKey.length !== 64) {
+ throw new Error('Invalid key lengths');
+ }
+
+ keyPair = { publicKey: pubKey, secretKey: secKey };
+
+ const pubB64 = data.publicKey;
+ const pubOutput = document.getElementById('pubKeyOutput');
+ pubOutput.innerText = pubB64;
+ pubOutput.classList.remove('empty');
+ document.getElementById('x-ed25519-pub').value = pubB64;
+ document.getElementById('signMsgBtn').disabled = false;
+
+ if (data.username) {
+ document.getElementById('fromName').value = data.username;
+ document.getElementById('readonlyFromName').value = data.username;
+ }
+ if (data.email) {
+ document.getElementById('hcEmail').value = data.email;
+ const fromName = document.getElementById('fromName').value || 'Anonymous';
+ document.getElementById('fromFull').value = `${fromName} <${data.email}>`;
+ document.getElementById('readonlyEmailSign').value = data.email;
+ document.getElementById('readonlyEmailSend').value = data.email;
+ }
+
+ const username = data.username || 'Anonymous';
+ const email = data.email || '';
+ document.getElementById('identityLoadedPow').style.display = 'flex';
+ document.getElementById('loadedIdentityName').textContent = username;
+ document.getElementById('loadedIdentityEmail').textContent = email;
+
+ await updateIdenticonPreview();
+
+ if (currentIdenticonData && currentIdenticonData.dataUrl) {
+ document.getElementById('identiconPreviewPow').src = currentIdenticonData.dataUrl;
+ }
+
+ document.getElementById('genKeyBtn').textContent = '🔑 Replace Keypair';
+ showNotification('✓ Identity loaded into memory only. Now generate PoW token.', 'success', 6000);
+ } catch (error) {
+ showNotification('✗ Failed to load identity: ' + error.message, 'error');
+ }
+
+ e.target.value = '';
+};
+
+// Usenet line-wrap convention (RFC 1855 Netiquette Guidelines): wrap plain-text
+// body lines at ~72 columns for compatibility with old-style newsreaders.
+// Must run BEFORE signing, not after, so the Ed25519 signature covers the
+// exact bytes that end up posted to Usenet.
+function wrapUsenetText(text, width) {
+ width = width || 72;
+ return text.split('\n').map(function(line) {
+ if (line.length === 0 || line.startsWith('>')) return line;
+ const words = line.split(' ');
+ const wrapped = [];
+ let current = '';
+ for (const word of words) {
+ if (current === '') {
+ current = word;
+ } else if ((current + ' ' + word).length <= width) {
+ current += ' ' + word;
+ } else {
+ wrapped.push(current);
+ current = word;
+ }
+ }
+ wrapped.push(current);
+ return wrapped.join('\n');
+ }).join('\n');
+}
+
+document.getElementById('signMsgBtn').onclick = function() {
+ try {
+ if (!keyPair) {
+ showNotification('⚠ Please generate or load a key pair first!', 'warning');
+ return;
+ }
+
+ const msg = wrapUsenetText(document.getElementById('messageToSign').value.trim());
+ if (!msg) {
+ showNotification('⚠ Please write a message!', 'warning');
+ document.getElementById('messageToSign').classList.add('error');
+ document.getElementById('messageToSign').focus();
+ return;
+ }
+
+ if (msg.length < 10) {
+ showNotification('⚠ Message too short (min 10 characters)', 'warning');
+ return;
+ }
+
+ document.getElementById('messageToSign').classList.remove('error');
+ showNotification('✍ Signing message...', 'info');
+
+ const msgBytes = nacl.util.decodeUTF8(msg);
+ const sig = nacl.sign.detached(msgBytes, keyPair.secretKey);
+ const sigB64 = nacl.util.encodeBase64(sig);
+
+ const sigOutput = document.getElementById('signatureOutput');
+ sigOutput.innerText = sigB64;
+ sigOutput.classList.remove('empty');
+ document.getElementById('x-ed25519-sig').value = sigB64;
+
+ document.getElementById('messageContent').value = msg + "\n\n--- Digital Signature ---\n" + sigB64;
+
+ appState.step2Complete = true;
+ updateTabIndicators();
+ showNotification('✓ Message signed with your in-memory identity.', 'success', 8000);
+ setTimeout(() => showTab('send'), 1200);
+ } catch (error) {
+ showNotification('✗ Error: ' + error.message, 'error');
+ }
+};
+
+document.getElementById('fromName').addEventListener('input', function() {
+ const email = document.getElementById('hcEmail').value;
+ document.getElementById('readonlyFromName').value = this.value;
+ if (email) {
+ document.getElementById('fromFull').value = `${this.value} <${email}>`;
+ }
+ if (keyPair) {
+ updateIdenticonPreview();
+ }
+});
+
+// ============================================================================
+// STEP 3: SEND FORM
+// ============================================================================
+
+document.getElementById('sendForm').addEventListener('submit', async function(e) {
+ e.preventDefault();
+
+ const requiredFields = [
+ {id: 'fromFull', name: 'From'},
+ {id: 'newsgroups', name: 'Newsgroups'},
+ {id: 'subject', name: 'Subject'},
+ {id: 'hcToken', name: 'Hashcash Token'},
+ {id: 'messageContent', name: 'Message'},
+ {id: 'x-ed25519-pub', name: 'Public Key'},
+ {id: 'x-ed25519-sig', name: 'Signature'}
+ ];
+
+ const missing = [];
+ requiredFields.forEach(field => {
+ const el = document.getElementById(field.id);
+ if (!el.value.trim()) {
+ missing.push(field.name);
+ el.classList.add('error');
+ } else {
+ el.classList.remove('error');
+ }
+ });
+
+ if (missing.length > 0) {
+ showNotification(`⚠ Missing: ${missing.join(', ')}`, 'warning', 8000);
+ return;
+ }
+
+ const messageLen = document.getElementById('messageContent').value.length;
+ if (messageLen < 10) {
+ showNotification('⚠ Message too short', 'warning');
+ return;
+ }
+
+ const newsgroups = document.getElementById('newsgroups').value.split(',').map(g => g.trim()).filter(g => g);
+ if (newsgroups.length === 0) {
+ showNotification('⚠ Enter at least one newsgroup!', 'warning');
+ return;
+ }
+
+ if (newsgroups.length > 3) {
+ showNotification('⚠ Max 3 newsgroups!', 'warning');
+ return;
+ }
+
+ const confirmMsg = `Send message?\n\nNewsgroups: ${newsgroups.join(', ')}\nSubject: ${document.getElementById('subject').value}\nLength: ${messageLen} chars\n\nDelivery via Nym may take up to 120 seconds.`;
+
+ if (!confirm(confirmMsg)) {
+ return;
+ }
+
+ showNotification('📤 Sending message...', 'info', 0);
+ const sendBtn = document.getElementById('sendBtn');
+ sendBtn.disabled = true;
+ sendBtn.textContent = 'Sending...';
+ sendBtn.style.opacity = '0.6';
+ sendBtn.style.cursor = 'not-allowed';
+
+ const progressDiv = document.createElement('div');
+ progressDiv.id = 'sending-progress';
+ progressDiv.style.cssText = 'margin-top: 10px; padding: 10px; background: #fff3cd; border-left: 4px solid #ffc107; color: #856404;';
+ progressDiv.innerHTML = '<strong>⏳ Sending message...</strong><br>This may take up to 120 seconds via Nym. Please wait.';
+ sendBtn.parentNode.insertBefore(progressDiv, sendBtn.nextSibling);
+
+ try {
+ const resp = await fetch('/submit', {
+ method: 'POST',
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
+ },
+ body: new URLSearchParams(new FormData(this)).toString(),
+ credentials: 'same-origin',
+ cache: 'no-store'
+ });
+ let data = {};
+ try {
+ data = await resp.json();
+ } catch (err) {}
+ if (!resp.ok) {
+ throw new Error(data.error || 'Delivery failed.');
+ }
+ appState.step3Complete = true;
+ updateTabIndicators();
+ resetEphemeralState(data.messageId || '');
+ } catch (error) {
+ if (progressDiv.parentNode) progressDiv.remove();
+ sendBtn.disabled = false;
+ sendBtn.textContent = 'Send Message';
+ sendBtn.style.opacity = '';
+ sendBtn.style.cursor = '';
+ showNotification('✗ ' + error.message, 'error', 10000);
+ }
+});
+
+// ============================================================================
+// ONION-NEWSREADER INTEGRATION
+// ============================================================================
+
+(function() {
+ function decode(s) {
+ if (!s) return '';
+ try {
+ return decodeURIComponent(s.replace(/\+/g, ' '));
+ } catch(e) {
+ return s;
+ }
+ }
+
+ function fillField(id, value, highlight) {
+ highlight = highlight !== false;
+ const field = document.getElementById(id);
+ if (field && value) {
+ field.value = value;
+ if (highlight) field.classList.add('prefilled');
+ return true;
+ }
+ return false;
+ }
+
+ function showPrefillBanner(isReply, details) {
+ const banner = document.getElementById('prefillBanner');
+ const title = document.getElementById('prefillTitle');
+ const detailsEl = document.getElementById('prefillDetails');
+
+ if (banner) {
+ banner.style.display = 'flex';
+ if (isReply) {
+ banner.classList.add('reply');
+ title.textContent = '💬 Reply Mode';
+ } else {
+ title.textContent = '📝 New Post';
+ }
+ detailsEl.textContent = details;
+ }
+ }
+
+ let prefillData = null;
+ let prefillSource = null;
+ const params = new URLSearchParams(window.location.search);
+ const action = params.get('action');
+
+ if (action === 'new' || action === 'reply' || params.has('newsgroups')) {
+ prefillData = {
+ newsgroups: decode(params.get('newsgroups') || ''),
+ subject: decode(params.get('subject') || ''),
+ references: decode(params.get('references') || ''),
+ body: decode(params.get('body') || '')
+ };
+ prefillSource = 'URL';
+ }
+
+ if (prefillData) {
+ let filledFields = [];
+
+ if (prefillData.newsgroups) {
+ fillField('newsgroups', prefillData.newsgroups);
+ filledFields.push('Newsgroups');
+ }
+
+ if (prefillData.subject) {
+ fillField('subject', prefillData.subject);
+ filledFields.push('Subject');
+ }
+
+ if (prefillData.references) {
+ fillField('references', prefillData.references);
+ filledFields.push('References');
+ }
+
+ if (prefillData.body) {
+ fillField('messageToSign', prefillData.body);
+ filledFields.push('Quoted message');
+ }
+
+ const isReply = !!(prefillData.references ||
+ (prefillData.subject && prefillData.subject.toLowerCase().startsWith('re:')));
+
+ if (filledFields.length > 0) {
+ const details = `Pre-filled: ${filledFields.join(', ')} (from ${prefillSource})`;
+ showPrefillBanner(isReply, details);
+
+ if (isReply) {
+ showNotification(`💬 Reply mode: ${prefillData.newsgroups}`, 'info', 5000);
+ } else {
+ showNotification(`📝 New post to: ${prefillData.newsgroups}`, 'success', 5000);
+ }
+ }
+
+ setTimeout(() => showTab('pow'), 300);
+ }
+})();
+</script>
+
+</body>
+</html>