mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(forms): validate submitter npub on submission forms (#22)
The embed submission forms accepted any free-text identifier and published it, so they were an easy spam vector. Add a shared, testable NIP-19 validator and require a well-formed npub before a submission is sent. - js/utils/validateNpub.js: validateNpubHex() decodes via nostr-tools nip19 and returns the 64-char hex pubkey, or null for anything that isn't a real npub (free text, wrong prefix, bad checksum, wrong NIP-19 entity). Also exposed as window.validateNpubHex for the classic iframe form scripts. - Application form (required npub): submit is blocked unless valid; the decoded hex is written into the application body for canonical storage/matching. - Feature-request / bug-fix / feedback (optional npub): stays optional, but a non-empty value must be a valid npub or submit is blocked with an inline error. - Tests: tests/validate-npub.test.mjs (real nostr-tools, no implementation mirror). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
fdcf034464
commit
e4e5c552c7
7 files changed
+203
-5
No files matched your search
@@ -9,6 +9,8 @@
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
|
||||
<script type="module" src="../../js/utils/validateNpub.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
@@ -295,6 +297,21 @@
|
||||
const applicantNpub = document
|
||||
.getElementById("applicantNpub")
|
||||
.value.trim();
|
||||
|
||||
// Anti-spam: the application identifier must be a valid npub.
|
||||
const applicantPubHex =
|
||||
typeof window.validateNpubHex === "function"
|
||||
? window.validateNpubHex(applicantNpub)
|
||||
: null;
|
||||
if (!applicantPubHex) {
|
||||
log(
|
||||
"Please enter a valid Nostr public key (npub1…) before submitting. Your application was not sent.",
|
||||
"error"
|
||||
);
|
||||
document.getElementById("applicantNpub")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const contactMethod = document
|
||||
.getElementById("contactMethod")
|
||||
.value.trim();
|
||||
@@ -353,6 +370,7 @@
|
||||
|
||||
**1. Applicant Information**
|
||||
- **Nostr Public Key (npub):** ${applicantNpub}
|
||||
- **Pubkey (hex):** ${applicantPubHex}
|
||||
- **Preferred Contact Method:** ${contactMethod || "N/A"}
|
||||
- **Username or Alias:** ${username || "N/A"}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
|
||||
<script type="module" src="../../js/utils/validateNpub.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
@@ -275,6 +277,22 @@
|
||||
try {
|
||||
// Section 1: User Information
|
||||
const userNpub = document.getElementById("userNpub").value.trim();
|
||||
|
||||
// Anti-spam: npub is optional here, but if provided it must be a
|
||||
// valid npub so the submission can't carry free-text garbage.
|
||||
if (
|
||||
userNpub &&
|
||||
(typeof window.validateNpubHex !== "function" ||
|
||||
!window.validateNpubHex(userNpub))
|
||||
) {
|
||||
log(
|
||||
"That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
|
||||
"error"
|
||||
);
|
||||
document.getElementById("userNpub")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const roleNodes = document.querySelectorAll(
|
||||
'input[name="userRole"]:checked'
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
|
||||
<script type="module" src="../../js/utils/validateNpub.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
@@ -212,6 +214,22 @@
|
||||
try {
|
||||
// Section 1: User Information
|
||||
const userNpub = document.getElementById("userNpub").value.trim();
|
||||
|
||||
// Anti-spam: npub is optional here, but if provided it must be a
|
||||
// valid npub so the submission can't carry free-text garbage.
|
||||
if (
|
||||
userNpub &&
|
||||
(typeof window.validateNpubHex !== "function" ||
|
||||
!window.validateNpubHex(userNpub))
|
||||
) {
|
||||
log(
|
||||
"That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
|
||||
"error"
|
||||
);
|
||||
document.getElementById("userNpub")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const roleNodes = document.querySelectorAll(
|
||||
'input[name="userRole"]:checked'
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<link rel="stylesheet" href="../../css/tailwind.generated.css" />
|
||||
<!-- Load nostr‑tools v2.17.0 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js"></script>
|
||||
<!-- Shared anti-spam npub validation (sets window.validateNpubHex) -->
|
||||
<script type="module" src="../../js/utils/validateNpub.js"></script>
|
||||
</head>
|
||||
<body class="iframe-form" data-ds="new">
|
||||
<div class="container">
|
||||
@@ -235,6 +237,21 @@
|
||||
try {
|
||||
// Section 1: User Information
|
||||
const userNpub = document.getElementById("userNpub").value.trim();
|
||||
|
||||
// Anti-spam: npub is optional here, but if provided it must be a
|
||||
// valid npub so the submission can't carry free-text garbage.
|
||||
if (
|
||||
userNpub &&
|
||||
(typeof window.validateNpubHex !== "function" ||
|
||||
!window.validateNpubHex(userNpub))
|
||||
) {
|
||||
log(
|
||||
"That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.",
|
||||
"error"
|
||||
);
|
||||
document.getElementById("userNpub")?.focus();
|
||||
return;
|
||||
}
|
||||
const roleNodes = document.querySelectorAll(
|
||||
'input[name="userRole"]:checked'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Shared, anti-spam npub (NIP-19) validation for the embed submission forms
|
||||
// (application / feature-request / bug-fix / feedback). A submission identifier
|
||||
// is only accepted if it decodes to a canonical 64-char hex pubkey, so the forms
|
||||
// stop publishing free-text garbage.
|
||||
//
|
||||
// Usable two ways:
|
||||
// - import { validateNpubHex } from "../utils/validateNpub.js" (tests / app)
|
||||
// - as a classic global in the iframe forms: window.validateNpubHex(value)
|
||||
// (the module assigns the global below and resolves nostr-tools at call time
|
||||
// from window.NostrTools, which the forms already load).
|
||||
|
||||
/**
|
||||
* Decode a user-entered npub and return its hex pubkey, or null if it isn't a
|
||||
* well-formed npub.
|
||||
*
|
||||
* @param {string} value Raw input (may include surrounding whitespace).
|
||||
* @param {{ nip19?: { decode: Function } }} [deps] Inject a nip19 implementation
|
||||
* (defaults to the global NostrTools bundle the embed forms load).
|
||||
* @returns {string|null} 64-char lowercase hex pubkey, or null when invalid.
|
||||
*/
|
||||
export function validateNpubHex(value, { nip19 } = {}) {
|
||||
const decoder =
|
||||
nip19 ||
|
||||
(typeof globalThis !== "undefined" ? globalThis.NostrTools?.nip19 : null);
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("npub1")) {
|
||||
return null;
|
||||
}
|
||||
if (!decoder || typeof decoder.decode !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decoder.decode(trimmed);
|
||||
if (
|
||||
decoded &&
|
||||
decoded.type === "npub" &&
|
||||
typeof decoded.data === "string" &&
|
||||
/^[0-9a-f]{64}$/.test(decoded.data)
|
||||
) {
|
||||
return decoded.data;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
// Expose to the iframe forms' classic inline scripts. Resolves NostrTools at
|
||||
// call time, so load order relative to the nostr-tools bundle doesn't matter.
|
||||
globalThis.validateNpubHex = (value) => validateNpubHex(value);
|
||||
}
|
||||
|
||||
export default validateNpubHex;
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as nostrTools from "nostr-tools";
|
||||
|
||||
import { validateNpubHex } from "../js/utils/validateNpub.js";
|
||||
|
||||
// Use the real nostr-tools nip19 so we exercise actual bech32 decoding, not a
|
||||
// mirror of the implementation. Generate a real key pair for the happy path.
|
||||
const nip19 = nostrTools.nip19;
|
||||
const secret = nostrTools.generateSecretKey();
|
||||
const hex = nostrTools.getPublicKey(secret);
|
||||
const npub = nip19.npubEncode(hex);
|
||||
|
||||
test("accepts a well-formed npub and returns its lowercase hex pubkey", () => {
|
||||
const result = validateNpubHex(npub, { nip19 });
|
||||
assert.equal(result, hex);
|
||||
assert.match(result, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("tolerates surrounding whitespace", () => {
|
||||
assert.equal(validateNpubHex(` ${npub}\n`, { nip19 }), hex);
|
||||
});
|
||||
|
||||
test("rejects free-text spam that is not an npub", () => {
|
||||
for (const junk of [
|
||||
"",
|
||||
" ",
|
||||
"hello world",
|
||||
"[email protected]",
|
||||
"not-an-npub",
|
||||
npub.replace("npub1", "note1"), // wrong NIP-19 prefix
|
||||
]) {
|
||||
assert.equal(validateNpubHex(junk, { nip19 }), null, `should reject: ${junk}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an npub with a corrupted checksum", () => {
|
||||
// Flip the last character — the bech32 checksum no longer validates.
|
||||
const tampered = npub.slice(0, -1) + (npub.endsWith("q") ? "p" : "q");
|
||||
assert.equal(validateNpubHex(tampered, { nip19 }), null);
|
||||
});
|
||||
|
||||
test("rejects a wrong NIP-19 entity (nprofile) even though it decodes", () => {
|
||||
const nprofile = nip19.nprofileEncode({ pubkey: hex, relays: [] });
|
||||
assert.equal(validateNpubHex(nprofile, { nip19 }), null);
|
||||
});
|
||||
|
||||
test("rejects non-string input", () => {
|
||||
for (const value of [null, undefined, 42, {}, [], true]) {
|
||||
assert.equal(validateNpubHex(value, { nip19 }), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null (instead of throwing) when no decoder is available", () => {
|
||||
assert.equal(validateNpubHex(npub, { nip19: null }), null);
|
||||
});
|
||||
@@ -414,11 +414,22 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
Source still to pin: the loop issuing 30000/30002/30005/30015 per contact.
|
||||
|
||||
### 22. Anti-spam: validate npub on application / submission forms
|
||||
- [ ] Application/submission forms accept too much spam. Require the submitter's
|
||||
identifier to be a valid `npub` (NIP-19) — regex/decoder validation client-side
|
||||
so submission only succeeds for a well-formed npub (and normalize to hex for
|
||||
storage). Reject/disable submit otherwise with a clear inline error. Pairs with
|
||||
#23 (the submissions should become structured data, not DMs).
|
||||
- [x] **DONE 2026-06-24.** Added a shared, testable NIP-19 validator
|
||||
(`js/utils/validateNpub.js`, exposes `window.validateNpubHex`) and wired it
|
||||
into all four embed submission forms (`components/iframe_forms/`):
|
||||
- **Application** (`applicantNpub`, required): submit is blocked unless the
|
||||
npub decodes to a 64-char hex pubkey; the hex is also written into the
|
||||
application body for canonical storage/whitelist matching.
|
||||
- **Feature-request / bug-fix / feedback** (`userNpub`, optional): the npub
|
||||
stays optional, but a non-empty value must be a valid npub or submit is
|
||||
blocked with a clear inline error.
|
||||
Validation decodes via the real nostr-tools `nip19` (checksum + prefix +
|
||||
hex shape), so spam/free-text/`note1…`/tampered npubs are rejected.
|
||||
Unit tests: `tests/validate-npub.test.mjs` (real nostr-tools). Pairs with #23
|
||||
(submissions should become structured data, not DMs).
|
||||
- [ ] **VERIFY live**: open each form embed — application rejects a junk npub and
|
||||
only sends with a valid npub1…; the optional forms send when the npub is
|
||||
blank but reject a malformed one.
|
||||
|
||||
### 23. Admin submissions tab — structured submissions + approve/deny UI (SCALING)
|
||||
- [ ] Replace the current DM-based submission flow with **custom structured data**
|
||||
|
||||
Reference in new issue
Block a user