Sanitize NIP-71 hashtag inputs

This commit is contained in:
thePR0M3TH3AN committed 2025-10-08 16:39:30 -04:00
1 parent 55b2dc77e7
commit 0ec2d48142
4 files changed
+146 -8

No files matched your search

+3
View File
@@ -537,6 +537,9 @@
Add hashtag
</button>
</div>
<p class="text-xs text-gray-400">
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
</p>
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
<template id="editNip71HashtagTemplate" data-nip71-template="t">
<div class="flex items-center gap-2" data-nip71-entry="t">
+4 -2
View File
@@ -599,14 +599,16 @@
Add hashtag
</button>
</div>
<p class="text-xs text-gray-400">Enter bare keywords without the <code>#</code> prefix.</p>
<p class="text-xs text-gray-400">
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
</p>
<div id="nip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
<template id="nip71HashtagTemplate" data-nip71-template="t">
<div class="flex items-center gap-2" data-nip71-entry="t">
<input
type="text"
data-nip71-field="value"
placeholder="nostr"
placeholder="#nostr"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<button
+65 -6
View File
@@ -227,8 +227,12 @@ export class Nip71FormManager {
this.setFieldValue(entry, "thumbnail", value.thumbnail);
});
this.hydrateRepeater(key, "t", metadata.hashtags ?? metadata.t, (entry, value) => {
this.setFieldValue(entry, "value", value);
const sanitizedHashtags = this.sanitizeHashtags(
metadata.hashtags ?? metadata.t,
{ dedupe: true }
);
this.hydrateRepeater(key, "t", sanitizedHashtags, (entry, value) => {
this.setFieldValue(entry, "value", this.renderHashtagValue(value));
});
this.hydrateRepeater(
@@ -337,10 +341,13 @@ export class Nip71FormManager {
return hasContent ? segment : null;
});
const hashtags = this.collectRepeaterValues(key, "t", (entry) => {
const value = this.getFieldValue(entry, "value");
return value || null;
});
const hashtags = this.sanitizeHashtags(
this.collectRepeaterValues(key, "t", (entry) => {
const value = this.getFieldValue(entry, "value");
return value || null;
}),
{ dedupe: true }
);
const participants = this.collectRepeaterValues(key, "p", (entry) => {
const participant = {
@@ -402,6 +409,58 @@ export class Nip71FormManager {
return results;
}
sanitizeHashtags(values, { dedupe = false } = {}) {
if (!Array.isArray(values)) {
return [];
}
const results = [];
const seen = new Set();
values.forEach((value) => {
const sanitized = this.sanitizeHashtagValue(value);
if (!sanitized) {
return;
}
const dedupeKey = sanitized.toLowerCase();
if (dedupe) {
if (seen.has(dedupeKey)) {
return;
}
seen.add(dedupeKey);
}
results.push(sanitized);
});
return results;
}
sanitizeHashtagValue(value) {
if (typeof value === "number") {
value = Number.isFinite(value) ? `${value}` : "";
}
if (typeof value !== "string") {
value = `${value ?? ""}`;
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const withoutHash = trimmed.replace(/^#+/, "").trim();
if (!withoutHash) {
return "";
}
return withoutHash.toLowerCase();
}
renderHashtagValue(value) {
const sanitized = this.sanitizeHashtagValue(value);
return sanitized ? `#${sanitized}` : "";
}
collectNestedValues(entry, nestedKey) {
if (!entry || !nestedKey) {
return [];
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { Nip71FormManager } from "../js/ui/components/nip71FormManager.js";
function createManagerWithSection(html) {
const dom = new JSDOM(html);
const document = dom.window.document;
const manager = new Nip71FormManager();
const sectionRoot = document.querySelector("#nip71-section-root");
manager.registerSection("test", sectionRoot);
return { manager, document };
}
test("collectSection sanitizes and deduplicates hashtags", () => {
const { manager } = createManagerWithSection(`
<form id="nip71-section-root">
<section data-nip71-repeater="t">
<div data-nip71-list="t">
<div data-nip71-entry="t" data-nip71-primary="true">
<input data-nip71-field="value" value=" #Nostr " />
</div>
<div data-nip71-entry="t">
<input data-nip71-field="value" value="#nostr" />
</div>
<div data-nip71-entry="t">
<input data-nip71-field="value" value=" #Video " />
</div>
</div>
<template data-nip71-template="t">
<div data-nip71-entry="t">
<input data-nip71-field="value" />
</div>
</template>
</section>
</form>
`);
const collected = manager.collectSection("test");
assert.deepEqual(collected.hashtags, ["nostr", "video"]);
});
test("hydrateSection renders sanitized hashtags with prefix", () => {
const { manager, document } = createManagerWithSection(`
<form id="nip71-section-root">
<section data-nip71-repeater="t">
<div data-nip71-list="t">
<div data-nip71-entry="t" data-nip71-primary="true">
<input data-nip71-field="value" value="" />
</div>
</div>
<template data-nip71-template="t">
<div data-nip71-entry="t">
<input data-nip71-field="value" />
</div>
</template>
</section>
</form>
`);
manager.hydrateSection("test", {
hashtags: [" #Nostr ", "#Nostr", " #Video " ],
});
const entries = Array.from(
document.querySelectorAll('[data-nip71-entry="t"]')
);
assert.equal(entries.length, 2);
const values = entries.map((entry) =>
entry.querySelector('[data-nip71-field="value"]').value
);
assert.deepEqual(values, ["#nostr", "#video"]);
});