mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-08 22:02:54 +00:00
Add moderation service and reporting flow
This commit is contained in:
1 parent
8b08dc676a
commit
071a8a3671
7 files changed
+1446
-3
No files matched your search
@@ -0,0 +1,784 @@
|
||||
import { nostrClient } from "../nostr.js";
|
||||
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logHandler = null) {
|
||||
this.listeners = new Map();
|
||||
this.logHandler = typeof logHandler === "function" ? logHandler : null;
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof eventName !== "string" || typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (error) {
|
||||
if (this.logHandler) {
|
||||
try {
|
||||
this.logHandler(`moderationService listener for "${eventName}" threw`, error);
|
||||
} catch (logError) {
|
||||
logger.user.warn("[moderationService] listener logger threw", logError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLogger(candidate) {
|
||||
if (typeof candidate === "function") {
|
||||
return candidate;
|
||||
}
|
||||
if (candidate && typeof candidate.log === "function") {
|
||||
return (...args) => candidate.log(...args);
|
||||
}
|
||||
if (
|
||||
candidate &&
|
||||
typeof candidate.dev === "object" &&
|
||||
typeof candidate.dev.log === "function"
|
||||
) {
|
||||
return (...args) => candidate.dev.log(...args);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function normalizeHex(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed && /^[0-9a-f]{40,64}$/i.test(trimmed) ? trimmed : "";
|
||||
}
|
||||
|
||||
function normalizeEventId(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed && /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed : "";
|
||||
}
|
||||
|
||||
function normalizeReportType(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
return trimmed ? trimmed : "";
|
||||
}
|
||||
|
||||
function cloneSummary(summary) {
|
||||
if (!summary || typeof summary !== "object") {
|
||||
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
|
||||
}
|
||||
const types = summary.types && typeof summary.types === "object" ? summary.types : {};
|
||||
const clonedTypes = {};
|
||||
for (const [key, value] of Object.entries(types)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
clonedTypes[key] = {
|
||||
trusted: Number.isFinite(value.trusted) ? value.trusted : 0,
|
||||
total: Number.isFinite(value.total) ? value.total : 0,
|
||||
latest: Number.isFinite(value.latest) ? value.latest : 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
eventId: typeof summary.eventId === "string" ? summary.eventId : "",
|
||||
totalTrusted: Number.isFinite(summary.totalTrusted) ? summary.totalTrusted : 0,
|
||||
types: clonedTypes,
|
||||
updatedAt: Number.isFinite(summary.updatedAt) ? summary.updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelayList(client, { write = false } = {}) {
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
const source = write ? client.writeRelays : client.relays;
|
||||
const fallback = client.relays;
|
||||
const raw = Array.isArray(source) && source.length ? source : fallback;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const urls = [];
|
||||
const seen = new Set();
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
urls.push(trimmed);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function findReportedEventId(event) {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return "";
|
||||
}
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (tag[0] !== "e") {
|
||||
continue;
|
||||
}
|
||||
const candidate = normalizeEventId(tag[1]);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractReportType(event, targetEventId = "") {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const [marker, value] = tag;
|
||||
if (marker === "report" || marker === "type") {
|
||||
const normalized = normalizeReportType(value);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetEventId) {
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "e") {
|
||||
continue;
|
||||
}
|
||||
if (normalizeEventId(tag[1]) !== targetEventId) {
|
||||
continue;
|
||||
}
|
||||
const typeCandidate = normalizeReportType(tag[2] || "");
|
||||
if (typeCandidate) {
|
||||
return typeCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "t") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeReportType(tag[1] || "");
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function ensureNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export class ModerationService {
|
||||
constructor({ nostrClient: client = null, logger: log = null } = {}) {
|
||||
this.nostrClient = client;
|
||||
this.log = normalizeLogger(log);
|
||||
|
||||
this.viewerPubkey = "";
|
||||
this.trustedContacts = new Set();
|
||||
|
||||
this.contactSubscription = null;
|
||||
this.contactListPromise = null;
|
||||
|
||||
this.reportEvents = new Map();
|
||||
this.reportSummaries = new Map();
|
||||
this.activeSubscriptions = new Map();
|
||||
this.activeEventIds = new Set();
|
||||
|
||||
this.emitter = new SimpleEventEmitter((message, error) => {
|
||||
try {
|
||||
this.log(message, error);
|
||||
} catch (logError) {
|
||||
logger.user.warn("[moderationService] logger threw", logError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLogger(newLogger) {
|
||||
this.log = normalizeLogger(newLogger);
|
||||
}
|
||||
|
||||
setNostrClient(client) {
|
||||
if (client && client !== this.nostrClient) {
|
||||
this.nostrClient = client;
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
async ensurePool() {
|
||||
if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") {
|
||||
throw new Error("nostr-client-unavailable");
|
||||
}
|
||||
return this.nostrClient.ensurePool();
|
||||
}
|
||||
|
||||
async refreshViewerFromClient() {
|
||||
const clientPubkey = normalizeHex(this.nostrClient?.pubkey);
|
||||
if (clientPubkey === this.viewerPubkey) {
|
||||
if (this.contactListPromise) {
|
||||
try {
|
||||
await this.contactListPromise;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] contact hydration failed", error);
|
||||
}
|
||||
}
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
return this.setViewerPubkey(clientPubkey);
|
||||
}
|
||||
|
||||
async setViewerPubkey(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (normalized === this.viewerPubkey && this.contactListPromise) {
|
||||
try {
|
||||
await this.contactListPromise;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] contact hydration failed", error);
|
||||
}
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
this.viewerPubkey = normalized;
|
||||
this.trustedContacts.clear();
|
||||
this.recomputeAllSummaries();
|
||||
|
||||
if (this.contactSubscription && typeof this.contactSubscription.unsub === "function") {
|
||||
try {
|
||||
this.contactSubscription.unsub();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to teardown contact subscription", error);
|
||||
}
|
||||
}
|
||||
this.contactSubscription = null;
|
||||
|
||||
if (!normalized) {
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
this.contactListPromise = this.fetchTrustedContacts(normalized)
|
||||
.catch((error) => {
|
||||
this.log("[moderationService] failed to load contact list", error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.contactListPromise = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.contactListPromise;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] contact promise rejected", error);
|
||||
}
|
||||
|
||||
this.subscribeToContactList(normalized).catch((error) => {
|
||||
this.log("[moderationService] failed to subscribe to contact list", error);
|
||||
});
|
||||
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
async fetchTrustedContacts(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensurePool();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] ensurePool failed while fetching contacts", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = { kinds: [3], authors: [normalized], limit: 1 };
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await this.nostrClient.pool.list(relays, [filter]);
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to list contact events", error);
|
||||
events = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
this.recomputeAllSummaries();
|
||||
return;
|
||||
}
|
||||
|
||||
let latest = events[0];
|
||||
for (const event of events) {
|
||||
if (!event || typeof event.created_at !== "number") {
|
||||
continue;
|
||||
}
|
||||
if (!latest || ensureNumber(event.created_at) > ensureNumber(latest.created_at)) {
|
||||
latest = event;
|
||||
}
|
||||
}
|
||||
|
||||
this.applyContactEvent(latest);
|
||||
}
|
||||
|
||||
applyContactEvent(event) {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
this.trustedContacts.clear();
|
||||
this.emit("contacts", { size: 0 });
|
||||
this.recomputeAllSummaries();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSet = new Set();
|
||||
for (const tag of event.tags) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "p") {
|
||||
continue;
|
||||
}
|
||||
const candidate = normalizeHex(tag[1]);
|
||||
if (candidate) {
|
||||
nextSet.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
this.trustedContacts = nextSet;
|
||||
this.emit("contacts", { size: nextSet.size });
|
||||
this.recomputeAllSummaries();
|
||||
}
|
||||
|
||||
async subscribeToContactList(pubkey) {
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensurePool();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] ensurePool failed while subscribing to contacts", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = { kinds: [3], authors: [normalized], limit: 1 };
|
||||
|
||||
try {
|
||||
const sub = this.nostrClient.pool.sub(relays, [filter]);
|
||||
sub.on("event", (event) => {
|
||||
this.applyContactEvent(event);
|
||||
});
|
||||
sub.on("eose", () => {});
|
||||
this.contactSubscription = {
|
||||
unsub: typeof sub.unsub === "function" ? () => sub.unsub() : null,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to create contact subscription", error);
|
||||
}
|
||||
}
|
||||
|
||||
async setActiveEventIds(ids = []) {
|
||||
const nextIds = new Set();
|
||||
if (Array.isArray(ids) || ids instanceof Set) {
|
||||
for (const value of ids) {
|
||||
const normalized = normalizeEventId(value);
|
||||
if (normalized) {
|
||||
nextIds.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const existing of Array.from(this.activeSubscriptions.keys())) {
|
||||
if (!nextIds.has(existing)) {
|
||||
this.teardownReportSubscription(existing);
|
||||
}
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
for (const id of nextIds) {
|
||||
if (this.activeSubscriptions.has(id)) {
|
||||
const entry = this.activeSubscriptions.get(id);
|
||||
if (entry?.promise) {
|
||||
tasks.push(entry.promise.catch(() => {}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const promise = this.subscribeToReports(id);
|
||||
tasks.push(promise.catch((error) => {
|
||||
this.log(`(moderationService) failed to subscribe to reports for ${id}`, error);
|
||||
}));
|
||||
}
|
||||
|
||||
this.activeEventIds = nextIds;
|
||||
|
||||
if (tasks.length) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
teardownReportSubscription(eventId) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
const entry = this.activeSubscriptions.get(normalized);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (typeof entry.unsub === "function") {
|
||||
try {
|
||||
entry.unsub();
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to unsubscribe from report stream", error);
|
||||
}
|
||||
}
|
||||
this.activeSubscriptions.delete(normalized);
|
||||
}
|
||||
|
||||
async subscribeToReports(eventId) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = { unsub: null, promise: null };
|
||||
this.activeSubscriptions.set(normalized, record);
|
||||
|
||||
record.promise = (async () => {
|
||||
await this.ensurePool();
|
||||
const relays = resolveRelayList(this.nostrClient);
|
||||
if (!relays.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = { kinds: [1984], "#e": [normalized], limit: 500 };
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
events = await this.nostrClient.pool.list(relays, [filter]);
|
||||
} catch (error) {
|
||||
this.log(`(moderationService) failed to backfill reports for ${normalized}`, error);
|
||||
events = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(events)) {
|
||||
for (const event of events) {
|
||||
this.ingestReportEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sub = this.nostrClient.pool.sub(relays, [filter]);
|
||||
sub.on("event", (event) => {
|
||||
this.ingestReportEvent(event);
|
||||
});
|
||||
sub.on("eose", () => {});
|
||||
record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null;
|
||||
} catch (error) {
|
||||
this.log(`(moderationService) failed to subscribe to reports for ${normalized}`, error);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await record.promise;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] report subscription promise rejected", error);
|
||||
} finally {
|
||||
record.promise = null;
|
||||
}
|
||||
}
|
||||
|
||||
ingestReportEvent(event) {
|
||||
if (!event || event.kind !== 1984) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reporter = normalizeHex(event.pubkey);
|
||||
if (!reporter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetEventId = findReportedEventId(event);
|
||||
const normalizedEventId = normalizeEventId(targetEventId);
|
||||
if (!normalizedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reportType = extractReportType(event, normalizedEventId);
|
||||
if (!reportType) {
|
||||
return;
|
||||
}
|
||||
|
||||
let eventReports = this.reportEvents.get(normalizedEventId);
|
||||
if (!eventReports) {
|
||||
eventReports = new Map();
|
||||
this.reportEvents.set(normalizedEventId, eventReports);
|
||||
}
|
||||
|
||||
let reporterEntry = eventReports.get(reporter);
|
||||
if (!reporterEntry) {
|
||||
reporterEntry = new Map();
|
||||
eventReports.set(reporter, reporterEntry);
|
||||
}
|
||||
|
||||
const existing = reporterEntry.get(reportType);
|
||||
const createdAt = ensureNumber(event.created_at);
|
||||
|
||||
if (!existing || createdAt >= ensureNumber(existing.created_at)) {
|
||||
reporterEntry.set(reportType, {
|
||||
created_at: createdAt,
|
||||
id: typeof event.id === "string" ? event.id : "",
|
||||
});
|
||||
}
|
||||
|
||||
this.recomputeSummaryForEvent(normalizedEventId);
|
||||
}
|
||||
|
||||
recomputeAllSummaries() {
|
||||
for (const eventId of this.reportEvents.keys()) {
|
||||
this.recomputeSummaryForEvent(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
recomputeSummaryForEvent(eventId) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventReports = this.reportEvents.get(normalized);
|
||||
if (!eventReports || !eventReports.size) {
|
||||
this.reportSummaries.delete(normalized);
|
||||
this.emit("summary", { eventId: normalized, summary: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const typeStats = new Map();
|
||||
let totalTrusted = 0;
|
||||
|
||||
for (const [reporter, typeMap] of eventReports.entries()) {
|
||||
const isTrusted = this.trustedContacts.has(reporter);
|
||||
for (const [type, detail] of typeMap.entries()) {
|
||||
let stats = typeStats.get(type);
|
||||
if (!stats) {
|
||||
stats = { trusted: 0, total: 0, latest: 0 };
|
||||
typeStats.set(type, stats);
|
||||
}
|
||||
stats.total += 1;
|
||||
const createdAt = ensureNumber(detail?.created_at);
|
||||
if (createdAt > stats.latest) {
|
||||
stats.latest = createdAt;
|
||||
}
|
||||
if (isTrusted) {
|
||||
stats.trusted += 1;
|
||||
totalTrusted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const types = {};
|
||||
for (const [type, stats] of typeStats.entries()) {
|
||||
types[type] = {
|
||||
trusted: stats.trusted,
|
||||
total: stats.total,
|
||||
latest: stats.latest,
|
||||
};
|
||||
}
|
||||
|
||||
const summary = {
|
||||
eventId: normalized,
|
||||
totalTrusted,
|
||||
types,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.reportSummaries.set(normalized, summary);
|
||||
this.emit("summary", { eventId: normalized, summary: cloneSummary(summary) });
|
||||
}
|
||||
|
||||
getTrustedReportSummary(eventId) {
|
||||
const normalized = normalizeEventId(eventId);
|
||||
if (!normalized) {
|
||||
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
|
||||
}
|
||||
const summary = this.reportSummaries.get(normalized);
|
||||
if (!summary) {
|
||||
return { eventId: normalized, totalTrusted: 0, types: {}, updatedAt: 0 };
|
||||
}
|
||||
return cloneSummary(summary);
|
||||
}
|
||||
|
||||
trustedReportCount(eventId, type) {
|
||||
const normalizedEventId = normalizeEventId(eventId);
|
||||
if (!normalizedEventId) {
|
||||
return 0;
|
||||
}
|
||||
const summary = this.reportSummaries.get(normalizedEventId);
|
||||
if (!summary) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedType = normalizeReportType(type);
|
||||
const entry = normalizedType ? summary.types[normalizedType] : null;
|
||||
if (!entry) {
|
||||
return 0;
|
||||
}
|
||||
return Number.isFinite(entry.trusted) ? entry.trusted : 0;
|
||||
}
|
||||
|
||||
async submitReport({ eventId, type, targetPubkey = "", content = "" } = {}) {
|
||||
const normalizedEventId = normalizeEventId(eventId);
|
||||
if (!normalizedEventId) {
|
||||
const error = new Error("invalid-event-id");
|
||||
error.code = "invalid-event-id";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedType = normalizeReportType(type);
|
||||
if (!normalizedType) {
|
||||
const error = new Error("invalid-type");
|
||||
error.code = "invalid-type";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reporterPubkey = normalizeHex(this.nostrClient?.pubkey);
|
||||
if (!reporterPubkey) {
|
||||
const error = new Error("viewer-not-logged-in");
|
||||
error.code = "viewer-not-logged-in";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const extension = typeof window !== "undefined" ? window.nostr : null;
|
||||
if (!extension) {
|
||||
const error = new Error("nostr-extension-missing");
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof this.nostrClient?.ensureExtensionPermissions === "function") {
|
||||
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
|
||||
"sign_event",
|
||||
"get_public_key",
|
||||
]);
|
||||
if (!permissionResult?.ok) {
|
||||
const error = new Error("extension-permission-denied");
|
||||
error.code = "extension-permission-denied";
|
||||
error.details = permissionResult?.error || null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["e", normalizedEventId, "", normalizedType],
|
||||
["report", normalizedType],
|
||||
["t", normalizedType],
|
||||
];
|
||||
|
||||
const normalizedTarget = normalizeHex(targetPubkey);
|
||||
if (normalizedTarget) {
|
||||
tags.push(["p", normalizedTarget]);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 1984,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: typeof content === "string" ? content : "",
|
||||
pubkey: reporterPubkey,
|
||||
};
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await extension.signEvent(event);
|
||||
} catch (error) {
|
||||
const wrapped = new Error("signature-failed");
|
||||
wrapped.code = "signature-failed";
|
||||
wrapped.details = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
await this.ensurePool();
|
||||
const relays = resolveRelayList(this.nostrClient, { write: true });
|
||||
if (!relays.length) {
|
||||
const error = new Error("no-relays-configured");
|
||||
error.code = "no-relays-configured";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent);
|
||||
assertAnyRelayAccepted(results, { context: "report" });
|
||||
} catch (error) {
|
||||
const wrapped = new Error("report-publish-failed");
|
||||
wrapped.code = "report-publish-failed";
|
||||
wrapped.details = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
this.ingestReportEvent(signedEvent);
|
||||
return { ok: true, event: signedEvent, results };
|
||||
}
|
||||
}
|
||||
|
||||
const moderationService = new ModerationService({
|
||||
nostrClient,
|
||||
logger,
|
||||
});
|
||||
|
||||
export default moderationService;
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import moderationService from "./moderationService.js";
|
||||
import {
|
||||
getVideosMap as getStoredVideosMap,
|
||||
setVideosMap as setStoredVideosMap,
|
||||
@@ -113,6 +114,14 @@ export class NostrService {
|
||||
}
|
||||
});
|
||||
this.videosMap = null;
|
||||
this.moderationService = moderationService || null;
|
||||
try {
|
||||
if (this.moderationService && typeof this.moderationService.setNostrClient === "function") {
|
||||
this.moderationService.setNostrClient(this.nostrClient);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to attach moderation service", error);
|
||||
}
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
@@ -131,6 +140,29 @@ export class NostrService {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
getModerationService() {
|
||||
if (!this.moderationService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof this.moderationService.setNostrClient === "function" &&
|
||||
this.moderationService.nostrClient !== this.nostrClient
|
||||
) {
|
||||
this.moderationService.setNostrClient(this.nostrClient);
|
||||
}
|
||||
|
||||
if (typeof this.moderationService.refreshViewerFromClient === "function") {
|
||||
this.moderationService.refreshViewerFromClient();
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to synchronize moderation service", error);
|
||||
}
|
||||
|
||||
return this.moderationService;
|
||||
}
|
||||
|
||||
ensureVideosMap() {
|
||||
if (this.videosMap instanceof Map) {
|
||||
return this.videosMap;
|
||||
@@ -518,6 +550,11 @@ export class NostrService {
|
||||
}
|
||||
|
||||
isViewerVideoAuthor(video) {
|
||||
try {
|
||||
this.getModerationService();
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostrService] Failed to refresh moderation context", error);
|
||||
}
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user