summaryrefslogtreecommitdiffstats
path: root/static
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-06 16:34:47 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-06 16:34:47 +0200
commit16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c (patch)
tree7ac7c321cacd62795cc23939bebdc9c48975377e /static
parent4fd6b4fefa7243f68fae7e24df3da46ee1a8e356 (diff)
downloadnymdrop-16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c.tar.gz
nymdrop-16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c.tar.xz
nymdrop-16854e8b24a3b00d9d5dbd285bbe3b93ee47ce1c.zip
Guard key material in RAM with memguard, add Markdown preview for message field
Reader now keeps the private key bytes and the per-submission ECDH/HKDF secrets in memguard.LockedBuffer (mlock + guaranteed wipe) instead of a manual zero loop. Added a regression test for the decrypt path. Message field gets a self-hosted Markdown toggle preview (no external library): headers, bold/italic, inline code, code blocks, blockquotes, lists, links. Output is HTML-escaped and link schemes are whitelisted before rendering. Submitted content is still the raw Markdown source, unchanged on the wire.
Diffstat (limited to 'static')
-rw-r--r--static/crypto.js7
-rw-r--r--static/index.html9
-rw-r--r--static/markdown.js149
-rw-r--r--static/style.css73
4 files changed, 236 insertions, 2 deletions
diff --git a/static/crypto.js b/static/crypto.js
index f1cc6c9..e196826 100644
--- a/static/crypto.js
+++ b/static/crypto.js
@@ -161,6 +161,13 @@ document.getElementById("drop-form").addEventListener("submit", async (e) => {
if (resp.ok) {
document.getElementById("drop-form").reset();
document.getElementById("filename").textContent = "";
+ const preview = document.getElementById("message-preview");
+ const previewToggle = document.getElementById("preview-toggle");
+ if (preview && previewToggle && !preview.hidden) {
+ preview.hidden = true;
+ document.getElementById("message").hidden = false;
+ previewToggle.textContent = "Preview";
+ }
status.textContent = "Delivered. No record of this submission exists on the server.";
} else {
status.className = "error";
diff --git a/static/index.html b/static/index.html
index 33e5868..abf2e8c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -27,8 +27,12 @@
</div>
<form id="drop-form">
- <label for="message">Message</label>
- <textarea id="message" placeholder="Write your message here. It will be encrypted before leaving your browser."></textarea>
+ <div class="message-header">
+ <label for="message">Message</label>
+ <button type="button" id="preview-toggle" class="preview-toggle">Preview</button>
+ </div>
+ <textarea id="message" placeholder="Write your message here (Markdown supported). It will be encrypted before leaving your browser."></textarea>
+ <div id="message-preview" class="md-preview" hidden></div>
<div class="file-section">
<label>Attachment (optional)</label>
@@ -54,6 +58,7 @@
</div>
<div class="footer">NYMDROP &nbsp;·&nbsp; ZERO KNOWLEDGE &nbsp;·&nbsp; ZERO LOGS</div>
<script src="/crypto.js"></script>
+<script src="/markdown.js"></script>
<script src="/app.js"></script>
</body>
</html>
diff --git a/static/markdown.js b/static/markdown.js
new file mode 100644
index 0000000..bad1a6d
--- /dev/null
+++ b/static/markdown.js
@@ -0,0 +1,149 @@
+// NymDrop — minimal self-hosted Markdown preview for the message field.
+// No external library: a small, auditable subset is enough for a
+// submission form, and it avoids pulling a third-party JS dependency into
+// a page whose whole point is minimizing trust in client-side code.
+//
+// The rendered HTML never leaves the browser — the encrypted submission
+// still carries the raw Markdown source text untouched (see app.js). This
+// is purely a "what will this look like" aid for the person writing it.
+
+function mdEscapeHtml(str) {
+ return str
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
+
+// Only allow link schemes that can't execute script in the rendered preview.
+function mdSafeHref(url) {
+ if (/^(https?:|mailto:)/i.test(url)) return mdEscapeHtml(url);
+ return "#";
+}
+
+function mdInline(text) {
+ let out = mdEscapeHtml(text);
+ out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
+ out = out.replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (m, a, b) => `<strong>${a || b}</strong>`);
+ out = out.replace(/\*([^*]+)\*|_([^_]+)_/g, (m, a, b) => `<em>${a || b}</em>`);
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) =>
+ `<a href="${mdSafeHref(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
+ return out;
+}
+
+function renderMarkdown(source) {
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
+ const html = [];
+ let i = 0;
+ let para = [];
+
+ function flushPara() {
+ if (para.length) {
+ html.push("<p>" + para.map(mdInline).join("<br>") + "</p>");
+ para = [];
+ }
+ }
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (/^```/.test(line)) {
+ flushPara();
+ const code = [];
+ i++;
+ while (i < lines.length && !/^```/.test(lines[i])) {
+ code.push(lines[i]);
+ i++;
+ }
+ html.push("<pre><code>" + mdEscapeHtml(code.join("\n")) + "</code></pre>");
+ i++;
+ continue;
+ }
+
+ const heading = line.match(/^(#{1,3})\s+(.*)$/);
+ if (heading) {
+ flushPara();
+ const level = heading[1].length;
+ html.push(`<h${level}>${mdInline(heading[2])}</h${level}>`);
+ i++;
+ continue;
+ }
+
+ if (/^(---|\*\*\*)\s*$/.test(line)) {
+ flushPara();
+ html.push("<hr>");
+ i++;
+ continue;
+ }
+
+ if (/^>\s?/.test(line)) {
+ flushPara();
+ const quote = [];
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
+ quote.push(lines[i].replace(/^>\s?/, ""));
+ i++;
+ }
+ html.push("<blockquote>" + renderMarkdown(quote.join("\n")) + "</blockquote>");
+ continue;
+ }
+
+ if (/^[-*]\s+/.test(line)) {
+ flushPara();
+ const items = [];
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
+ items.push(`<li>${mdInline(lines[i].replace(/^[-*]\s+/, ""))}</li>`);
+ i++;
+ }
+ html.push("<ul>" + items.join("") + "</ul>");
+ continue;
+ }
+
+ if (/^\d+\.\s+/.test(line)) {
+ flushPara();
+ const items = [];
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
+ items.push(`<li>${mdInline(lines[i].replace(/^\d+\.\s+/, ""))}</li>`);
+ i++;
+ }
+ html.push("<ol>" + items.join("") + "</ol>");
+ continue;
+ }
+
+ if (line.trim() === "") {
+ flushPara();
+ i++;
+ continue;
+ }
+
+ para.push(line);
+ i++;
+ }
+ flushPara();
+
+ return html.join("\n");
+}
+
+(function() {
+ const textarea = document.getElementById("message");
+ const preview = document.getElementById("message-preview");
+ const toggle = document.getElementById("preview-toggle");
+ if (!textarea || !preview || !toggle) return;
+
+ let showingPreview = false;
+
+ toggle.addEventListener("click", function() {
+ showingPreview = !showingPreview;
+ if (showingPreview) {
+ preview.innerHTML = renderMarkdown(textarea.value) ||
+ '<p class="md-empty">Nothing to preview yet.</p>';
+ textarea.hidden = true;
+ preview.hidden = false;
+ toggle.textContent = "Edit";
+ } else {
+ preview.hidden = true;
+ textarea.hidden = false;
+ toggle.textContent = "Preview";
+ }
+ });
+})();
diff --git a/static/style.css b/static/style.css
index d463876..a2cb765 100644
--- a/static/style.css
+++ b/static/style.css
@@ -193,6 +193,79 @@ textarea {
textarea:focus { border-color: var(--accent-green); box-shadow: 0 0 0 2px var(--badge-green-bg); }
+.message-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.message-header label { margin-bottom: 0.4rem; }
+
+.preview-toggle {
+ background: var(--file-label-bg);
+ border: 1px solid var(--field-border);
+ border-radius: 6px;
+ color: var(--label);
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 0.68rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 0.25rem 0.6rem;
+ margin-bottom: 0.5rem;
+ cursor: pointer;
+ transition: border-color 0.2s, color 0.2s;
+}
+
+.preview-toggle:hover { border-color: var(--accent-blue-text); color: var(--accent-blue-text); }
+
+.md-preview {
+ width: 100%;
+ min-height: 160px;
+ max-height: 400px;
+ overflow-y: auto;
+ background: var(--field-bg);
+ border: 1px solid var(--field-border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.88rem;
+ line-height: 1.6;
+ padding: 0.9rem;
+ margin-bottom: 1.4rem;
+ box-sizing: border-box;
+}
+
+.md-preview .md-empty { color: var(--tagline); font-style: italic; }
+.md-preview h1, .md-preview h2, .md-preview h3 { color: var(--heading); margin: 0.6rem 0 0.4rem; line-height: 1.3; }
+.md-preview h1 { font-size: 1.2rem; }
+.md-preview h2 { font-size: 1.08rem; }
+.md-preview h3 { font-size: 0.98rem; }
+.md-preview p { margin-bottom: 0.7rem; }
+.md-preview p:last-child { margin-bottom: 0; }
+.md-preview ul, .md-preview ol { margin: 0 0 0.7rem 1.4rem; }
+.md-preview blockquote {
+ border-left: 3px solid var(--field-border);
+ color: var(--tagline);
+ margin: 0 0 0.7rem;
+ padding: 0.1rem 0 0.1rem 0.8rem;
+}
+.md-preview code {
+ background: var(--file-label-bg);
+ border-radius: 3px;
+ padding: 0.1rem 0.3rem;
+ font-size: 0.85em;
+}
+.md-preview pre {
+ background: var(--file-label-bg);
+ border: 1px solid var(--field-border);
+ border-radius: 6px;
+ padding: 0.7rem;
+ overflow-x: auto;
+ margin-bottom: 0.7rem;
+}
+.md-preview pre code { background: none; padding: 0; }
+.md-preview a { color: var(--accent-blue-text); }
+.md-preview hr { border: none; border-top: 1px solid var(--field-border); margin: 0.8rem 0; }
+
.file-section { margin-bottom: 1.8rem; }
.file-label {