mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
9ca726bc99
commit
60157eb743
3 files changed
+150
-22
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({}) }),
|
||||
|
||||
Reference in new issue
Block a user