Appeals: diagnose and handle author-level blocks too

Approving a content appeal now checks WHY the video is hidden and applies
the right fix, instead of only ever touching the per-event block list.

- submissionFacade.js: resolveEventAuthorHex({ eventId }) looks up the event
  and returns its author hex — the authoritative author, so we don't rely on
  the appellant to supply it.
- submissionsSection.js approveAppeal: branch on the real block state.
  * Author-level block (whole npub) → its own strongly-worded confirmation
    ("restores ALL their content"); on confirm, removeFromBlacklist plus any
    event block. Never bundled into the default unblock.
  * Event-level block only → unblock this video (as before).
  * Neither → distinguish "nothing to unblock" from "couldn't verify on
    relays" and just mark the appeal resolved.
- tests: resolveEventAuthorHex (author normalization, blank/not-found).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-07 10:32:38 -04:00
1 parent 9ca726bc99
commit 60157eb743
3 files changed
+150 -22

No files matched your search

+35
View File
@@ -96,6 +96,40 @@ export async function fetchPendingSubmissions({
return [...byApplicant.values()];
}
/**
* Look up a single event by id and return its author's hex pubkey (or "").
* The appeal-approval flow uses this to discover WHY a video is hidden — the
* event's own pubkey is the authoritative author, so we don't rely on the
* appellant to supply it.
* @param {{ eventId: string, client?: any }} opts
* @returns {Promise<string>} lowercased author hex, or "" if not found
*/
export async function resolveEventAuthorHex({ eventId, client = nostrClient } = {}) {
const id = normHex(eventId);
if (!id) {
return "";
}
const { manager, relays } = getManagerAndRelays(client);
if (!manager || typeof manager.list !== "function" || !relays.length) {
return "";
}
try {
const events = await manager.list({
relays,
filters: [{ ids: [id], limit: 1 }],
});
const found = (Array.isArray(events) ? events : []).find(
(event) => normHex(event?.id) === id,
);
return found && typeof found.pubkey === "string"
? found.pubkey.trim().toLowerCase()
: "";
} catch (error) {
devLogger.warn("[submissions] Failed to resolve event author:", error);
return "";
}
}
async function fetchResolvedIds({ editorHexes, manager, relays }) {
const authors = [...new Set(editorHexes.map(normHex).filter(Boolean))];
const ids = new Set();
@@ -201,4 +235,5 @@ export default {
SUBMISSIONS_RESOLVED_DTAG,
fetchPendingSubmissions,
markSubmissionResolved,
resolveEventAuthorHex,
};
+89 -22
View File
@@ -9,10 +9,12 @@
import {
fetchPendingSubmissions,
markSubmissionResolved,
resolveEventAuthorHex,
} from "../../submissions/submissionFacade.js";
import { SUBMISSION_TYPE_LABELS } from "../../submissions/submissionService.js";
import { FEATURE_SUBMISSIONS } from "../../constants.js";
import { safeDecodeNpub } from "../../utils/nostrHelpers.js";
import { safeDecodeNpub, safeEncodeNpub } from "../../utils/nostrHelpers.js";
import { formatShortNpub } from "../../utils/formatters.js";
import { showConfirm } from "../confirmDialog.js";
import { devLogger } from "../../utils/logger.js";
@@ -202,7 +204,7 @@ function actionsForSubmission(submission) {
actions.push({
action: "approve-appeal",
label: "Approve & unblock",
hint: "Removes the video from the block list so it's visible again.",
hint: "Finds why it's hidden and lifts the block (author-level is confirmed separately).",
primary: true,
});
}
@@ -288,9 +290,12 @@ async function approveSubmission(controller, submission) {
}
}
// Approve a content appeal: actually remove the named video from the per-event
// block list, then mark the appeal resolved. Requires a well-formed target id
// (parsed from the submission's `e` tag) — the button isn't shown without one.
// Diagnose WHY the appealed video is hidden, then apply the right fix. A video
// can be hidden by an event-level block (just that video) or an author-level
// block (the whole npub — hides ALL their content). We resolve the event's
// author from relays and branch: author-level unblock is a bigger hammer, so it
// gets its own explicit, strongly-worded confirmation and is never bundled into
// the default "unblock this video".
async function approveAppeal(controller, submission) {
const services = controller.mainController.services;
const accessControl = services?.accessControl;
@@ -299,38 +304,100 @@ async function approveAppeal(controller, submission) {
if (!actorNpub || !accessControl || !targetEventId) {
return;
}
const ok = await showConfirm(
"Unblock this video? It'll be removed from the block list and become visible again.",
{ title: "Approve appeal", confirmLabel: "Unblock" },
);
if (!ok) {
return;
const eventBlocked =
typeof accessControl.isEventBlacklisted === "function"
? Boolean(accessControl.isEventBlacklisted(targetEventId))
: false;
let authorHex = "";
try {
authorHex = await resolveEventAuthorHex({ eventId: targetEventId });
} catch (error) {
authorHex = "";
}
const authorNpub = authorHex ? safeEncodeNpub(authorHex) || "" : "";
const authorBlocked =
authorHex && typeof accessControl.isBlacklisted === "function"
? Boolean(accessControl.isBlacklisted(authorHex))
: false;
// Pick the action + confirmation for the real block state. `apply` runs the
// unblock(s) after the admin confirms; `successMessage` reports what happened.
let apply = null;
let successMessage = "";
if (authorBlocked) {
const who = authorNpub ? formatShortNpub(authorNpub) : "this creator";
const confirmed = await showConfirm(
`This video is hidden because its author (${who}) is blocked platform-wide. Unblocking restores ALL of their content, not just this video. Unblock the author?`,
{ title: "Author is blocked", confirmLabel: "Unblock author" },
);
if (!confirmed) {
return;
}
apply = async () => {
const result = await accessControl.removeFromBlacklist(actorNpub, authorNpub);
if (!result || result.ok === false) {
throw new Error(result?.error || "author-unblock-failed");
}
// Also clear any event-level block so the video is fully visible.
if (eventBlocked && typeof accessControl.removeFromEventBlacklist === "function") {
await accessControl.removeFromEventBlacklist(actorNpub, targetEventId);
}
};
successMessage = "Appeal approved — the author has been unblocked.";
} else if (eventBlocked) {
const confirmed = await showConfirm(
"Unblock this video? It'll be removed from the block list and become visible again.",
{ title: "Approve appeal", confirmLabel: "Unblock" },
);
if (!confirmed) {
return;
}
apply = async () => {
const result = await accessControl.removeFromEventBlacklist(
actorNpub,
targetEventId,
);
if (!result || result.ok === false) {
throw new Error(result?.error || "unblock-failed");
}
};
successMessage = "Appeal approved — the video has been unblocked.";
} else {
// Not on either block list. Distinguish "confirmed not blocked" from
// "couldn't verify" (event not found on connected relays).
const message = authorHex
? "This video isn't on the event or author block list — there's nothing to unblock (it may already be unblocked, or hidden by trusted moderation reports). Mark the appeal as resolved?"
: "Couldn't find this video on connected relays to check its block status. If you've already lifted the block, mark the appeal as resolved — otherwise cancel and check the admin block lists.";
const confirmed = await showConfirm(message, {
title: authorHex ? "Nothing to unblock" : "Couldn't verify",
confirmLabel: "Mark resolved",
});
if (!confirmed) {
return;
}
apply = async () => {};
successMessage = "Appeal marked as resolved.";
}
controller.pendingSubmissions = (controller.pendingSubmissions || []).filter(
(s) => s.eventId !== submission.eventId,
);
renderSubmissions(controller);
try {
const result = await accessControl.removeFromEventBlacklist(
actorNpub,
targetEventId,
);
if (!result || result.ok === false) {
throw new Error(result?.error || "unblock-failed");
}
await apply();
await markSubmissionResolved({
submission,
status: "approved",
actingHex: safeDecodeNpub(actorNpub),
});
controller.mainController.showSuccess?.(
"Appeal approved — the video has been unblocked.",
);
controller.mainController.showSuccess?.(successMessage);
controller.populateAdminLists?.();
emitSubmissionsChanged();
} catch (error) {
devLogger.warn("[profileModal] Approve appeal failed:", error);
controller.mainController.showError?.(
"Couldn't unblock — please try again.",
"Couldn't complete the appeal — please try again.",
);
void populateSubmissions(controller);
}
+26
View File
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
fetchPendingSubmissions,
markSubmissionResolved,
resolveEventAuthorHex,
RESOLVED_LIST_KIND,
SUBMISSIONS_RESOLVED_DTAG,
} from "../js/submissions/submissionFacade.js";
@@ -47,6 +48,7 @@ function mockClient({ events = [], onPublish } = {}) {
list: async ({ filters }) => {
const f = filters[0] || {};
return events.filter((ev) => {
if (f.ids && !f.ids.includes(ev.id)) return false;
if (f.kinds && !f.kinds.includes(ev.kind)) return false;
if (f.authors && !f.authors.includes(ev.pubkey)) return false;
if (f["#p"]) {
@@ -121,6 +123,30 @@ test("markSubmissionResolved appends to the acting moderator's resolved-set", as
assert.equal(newTag[3], "npub-bob", "records applicant");
});
test("resolveEventAuthorHex returns the blocked event's author (lowercased)", async () => {
const blockedEventId = "f".repeat(64);
const authorHex = "C".repeat(64); // stored upper-case to prove normalization
const events = [
{ id: blockedEventId, kind: 30078, pubkey: authorHex, tags: [], content: "" },
];
const author = await resolveEventAuthorHex({
eventId: blockedEventId,
client: mockClient({ events }),
});
assert.equal(author, "c".repeat(64));
});
test("resolveEventAuthorHex returns '' for a blank id or an event not found", async () => {
assert.equal(await resolveEventAuthorHex({ eventId: "", client: mockClient({}) }), "");
assert.equal(
await resolveEventAuthorHex({
eventId: "a".repeat(64),
client: mockClient({ events: [] }),
}),
"",
);
});
test("markSubmissionResolved is a no-op without an acting pubkey or event id", async () => {
assert.equal(
await markSubmissionResolved({ submission: { eventId: "x" }, actingHex: "", client: mockClient({}) }),