Files
bitvid/js/submissions/submissionService.js
T
thePR0M3TH3ANandClaude Opus 4.8 2803f6b56f Submissions: labeled actions + real appeal-driven unblock
Make each admin action self-explanatory and give content appeals a real
effect instead of a dead-end "Dismiss".

- submissionsSection.js: per-type action sets, each button paired with a
  one-line description. Applications show Approve (adds to whitelist) / Deny;
  appeals that name a real event show "Approve & unblock" (removes the video
  from the event block list) / "Keep blocked"; other types show "Mark handled".
- submissionService.js: parse an appeal's `e` tag into targetEventId (trusted
  only when it's a well-formed 64-char hex id).
- iframe-content-appeals-form.html: add a required "Blocked video link or
  event ID" field; regex-validate + normalize a share link / nevent / note /
  hex to the hex event id, tag it as `e`, and include it in the body. Block
  submission when no valid reference is provided.
- CSS: stacked action + hint styling; iframe-form .form-hint.
- tests: cover targetEventId parsing (valid, malformed, absent).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 09:52:04 -04:00

140 lines
4.1 KiB
JavaScript

// js/submissions/submissionService.js
//
// Structured bitvid submissions (#23) — pure core. A submission is a public,
// addressable kind-30083 event (SUBMISSION_KIND) published from an ephemeral key
// so a public form needs no login; the `applicant` tag carries the CLAIMED npub
// an admin vouches for. This module builds/parses those events with no relay or
// signer, so it's fully unit-testable. Fetch/publish/approve/deny live in the
// facade that wires this to the live client.
import { buildSubmissionEvent, SUBMISSION_KIND } from "../nostrEventSchemas.js";
export { SUBMISSION_KIND };
export const SUBMISSION_TYPES = Object.freeze({
APPLICATION: "application",
APPEAL: "appeal",
BUG: "bug",
FEATURE: "feature",
FEEDBACK: "feedback",
});
const KNOWN_TYPES = new Set(Object.values(SUBMISSION_TYPES));
// Human labels for the admin UI.
export const SUBMISSION_TYPE_LABELS = Object.freeze({
application: "Whitelist application",
appeal: "Content appeal",
bug: "Bug report",
feature: "Feature request",
feedback: "Feedback",
other: "Submission",
});
function firstTagValue(tags, name) {
const tag = tags.find(
(t) => Array.isArray(t) && t[0] === name && typeof t[1] === "string",
);
return tag ? tag[1] : "";
}
const HEX_EVENT_ID_REGEX = /^[0-9a-f]{64}$/i;
// The `e` tag on an appeal points at the blocked video's event id. We only trust
// a well-formed 64-char hex id — the admin approve path feeds it straight into
// the event block list.
function firstEventTarget(tags) {
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "e" && typeof tag[1] === "string") {
const id = tag[1].trim().toLowerCase();
if (HEX_EVENT_ID_REGEX.test(id)) {
return id;
}
}
}
return "";
}
/**
* Parse a kind-30083 event into a structured submission. Returns null for a
* non-submission event or one missing a `d` identifier.
* @param {Object} event
* @returns {null | {
* id: string, eventId: string, type: string, applicant: string,
* targetEventId: string, recipient: string, content: string,
* createdAt: number, pubkey: string,
* }}
*/
export function parseSubmissionEvent(event) {
if (!event || typeof event !== "object" || !Array.isArray(event.tags)) {
return null;
}
if (Number.isFinite(event.kind) && event.kind !== SUBMISSION_KIND) {
return null;
}
const tags = event.tags;
const id = firstTagValue(tags, "d").trim();
if (!id) {
return null;
}
const rawType = firstTagValue(tags, "k").trim() || "application";
return {
id,
eventId: typeof event.id === "string" ? event.id : "",
type: KNOWN_TYPES.has(rawType) ? rawType : "other",
applicant: firstTagValue(tags, "applicant").trim(),
targetEventId: firstEventTarget(tags),
recipient: firstTagValue(tags, "p").trim(),
content: typeof event.content === "string" ? event.content : "",
createdAt: Number.isFinite(event.created_at) ? event.created_at : 0,
pubkey:
typeof event.pubkey === "string" ? event.pubkey.trim().toLowerCase() : "",
};
}
/**
* Build an unsigned, signable submission event.
* @param {{
* pubkey: string, id: string, type?: string, applicantNpub?: string,
* recipientPubkey?: string, content?: string, created_at?: number,
* }} submission
* @returns {Object} unsigned kind-30083 event
*/
export function buildSubmission({
pubkey,
id,
type = "application",
applicantNpub = "",
recipientPubkey = "",
content = "",
created_at,
} = {}) {
return buildSubmissionEvent({
pubkey,
created_at: Number.isFinite(created_at)
? created_at
: Math.floor(Date.now() / 1000),
dTagValue: id,
submissionType: type,
applicantNpub,
recipientPubkey,
content,
});
}
/** Generate a stable-ish submission id for the `d` tag. */
export function generateSubmissionId() {
const time = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 10);
return `sub-${time}-${rand}`;
}
export default {
SUBMISSION_KIND,
SUBMISSION_TYPES,
SUBMISSION_TYPE_LABELS,
parseSubmissionEvent,
buildSubmission,
generateSubmissionId,
};