mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
// Phase 1 of the NIP-71 interop plan (docs/nip71-migration-plan.md): publish the
|
|
// opt-in addressable NIP-71 mirror (kind 34235/34236) for a bitvid video to the
|
|
// user's write/outbox relays.
|
|
//
|
|
// Builds on the Phase 0 pure builder (buildNip71MirrorEvent) which already enforces
|
|
// the "private never mirrored" and "HTTPS url required" rules. This service adds:
|
|
// - the site-wide NSFW gate: when ALLOW_NSFW_CONTENT is false, an instance that
|
|
// won't surface NSFW must not publish it outward either (moderation audit item).
|
|
// - signing with the active signer + publishing to the write (outbox) relay set.
|
|
//
|
|
// Dependency-injected so it unit-tests without a live signer/relay pool.
|
|
|
|
import {
|
|
buildNip71MirrorEvent,
|
|
NIP71_NORMAL_VIDEO_KIND,
|
|
NIP71_SHORT_VIDEO_KIND,
|
|
} from "../nostr/nip71Mirror.js";
|
|
import { nostrClient } from "../nostrClientFacade.js";
|
|
import { getActiveSigner } from "../nostr/index.js";
|
|
import {
|
|
publishEventToRelays as defaultPublishEventToRelays,
|
|
summarizePublishResults as defaultSummarize,
|
|
} from "../nostrPublish.js";
|
|
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
|
|
function str(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
export function createNip71MirrorService({
|
|
buildMirrorEvent = buildNip71MirrorEvent,
|
|
getActivePubkey = () => str(nostrClient?.pubkey),
|
|
getSigner = () => getActiveSigner(),
|
|
// Outbox/write set (mirrors getDeletePublishRelays from the delete work).
|
|
getWriteRelays = () =>
|
|
typeof nostrClient?.getDeletePublishRelays === "function"
|
|
? nostrClient.getDeletePublishRelays()
|
|
: [],
|
|
getPool = () => nostrClient?.pool || null,
|
|
// L1 chokepoint (no direct pool.list — see scripts/check-direct-pool-access.mjs).
|
|
getSubscriptionManager = () =>
|
|
typeof nostrClient?.getSubscriptionManager === "function"
|
|
? nostrClient.getSubscriptionManager()
|
|
: null,
|
|
// Look up the author's existing mirror events (both kinds) for a video root, on
|
|
// the write relays where mirrors are published. Returns [{ kind, created_at }].
|
|
// Injected so the idempotency logic unit-tests without a live relay manager.
|
|
fetchExistingMirrors = async ({ pubkey, root }) => {
|
|
const sm = getSubscriptionManager();
|
|
const relays = getWriteRelays() || [];
|
|
if (!sm || typeof sm.list !== "function" || !relays.length) {
|
|
return [];
|
|
}
|
|
try {
|
|
const events = await sm.list({
|
|
filters: [
|
|
{
|
|
kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND],
|
|
authors: [pubkey],
|
|
"#d": [root],
|
|
},
|
|
],
|
|
relays,
|
|
});
|
|
return (Array.isArray(events) ? events : []).map((e) => ({
|
|
kind: e?.kind,
|
|
created_at: Number(e?.created_at) || 0,
|
|
}));
|
|
} catch (error) {
|
|
userLogger.warn("[nip71Mirror] existing-mirror lookup failed:", error);
|
|
return [];
|
|
}
|
|
},
|
|
// One relay query for all roots in the My Videos view. Kept separate from the
|
|
// single-root lookup so publish/remove can remain narrowly scoped.
|
|
fetchMirrorsByRoots = async ({ pubkey, roots }) => {
|
|
const sm = getSubscriptionManager();
|
|
const relays = getWriteRelays() || [];
|
|
const uniqueRoots = [...new Set((Array.isArray(roots) ? roots : []).map(str).filter(Boolean))];
|
|
if (!sm || typeof sm.list !== "function" || !relays.length || !uniqueRoots.length) {
|
|
return [];
|
|
}
|
|
try {
|
|
const events = await sm.list({
|
|
filters: [
|
|
{
|
|
kinds: [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND],
|
|
authors: [pubkey],
|
|
"#d": uniqueRoots,
|
|
},
|
|
],
|
|
relays,
|
|
});
|
|
return (Array.isArray(events) ? events : []).map((event) => ({
|
|
root: str(event?.tags?.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]),
|
|
kind: event?.kind,
|
|
created_at: Number(event?.created_at) || 0,
|
|
}));
|
|
} catch (error) {
|
|
userLogger.warn("[nip71Mirror] mirror batch lookup failed:", error);
|
|
return [];
|
|
}
|
|
},
|
|
publishEventToRelays = defaultPublishEventToRelays,
|
|
summarizePublishResults = defaultSummarize,
|
|
signEvent = async (template) => {
|
|
const signer = getActiveSigner();
|
|
if (!signer || typeof signer.signEvent !== "function") {
|
|
const err = new Error("Active signer missing signEvent support.");
|
|
err.code = "sign-event-missing";
|
|
throw err;
|
|
}
|
|
return signer.signEvent(template);
|
|
},
|
|
// Instance policy: only mirror NSFW outward when the site permits NSFW.
|
|
allowNsfw = () => ALLOW_NSFW_CONTENT === true,
|
|
now = () => Date.now(),
|
|
} = {}) {
|
|
function isAvailable() {
|
|
const pubkey = getActivePubkey();
|
|
const signer = getSigner();
|
|
return Boolean(pubkey) && Boolean(signer) && typeof signer.signEvent === "function";
|
|
}
|
|
|
|
// Whether a video is even eligible for the mirror (drives UI gating too).
|
|
function canMirror(video) {
|
|
if (!video || typeof video !== "object") {
|
|
return { ok: false, reason: "invalid" };
|
|
}
|
|
if (video.isPrivate === true) {
|
|
return { ok: false, reason: "private" };
|
|
}
|
|
if (video.isNsfw === true && !allowNsfw()) {
|
|
return { ok: false, reason: "nsfw-blocked" };
|
|
}
|
|
if (!/^https:\/\//i.test(str(video.url))) {
|
|
return { ok: false, reason: "no-url" };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
async function publish(video, options = {}) {
|
|
if (!isAvailable()) {
|
|
return { ok: false, error: "unavailable" };
|
|
}
|
|
// Site NSFW policy gate (defense in depth alongside the UI not offering it).
|
|
if (video?.isNsfw === true && !allowNsfw()) {
|
|
return { ok: false, reason: "nsfw-blocked" };
|
|
}
|
|
|
|
const pubkey = str(video?.pubkey) || getActivePubkey();
|
|
const root = str(video?.videoRootId);
|
|
|
|
// Idempotency (#34): the mirror kind (34235 normal / 34236 short) is inferred
|
|
// from the video's dimensions, which aren't always present — so re-mirroring the
|
|
// same video could publish the OTHER kind, and since (kind,pubkey,d) is the
|
|
// addressable identity that produced a DUPLICATE in NIP-71 clients. Reuse the kind
|
|
// of any existing mirror so a re-publish REPLACES the same coordinate, and remember
|
|
// a stray of the other kind to clean up (self-heals duplicates created earlier).
|
|
let forcedShort = options.short; // explicit caller override always wins
|
|
let staleKind = null;
|
|
let reusedExistingKind = false;
|
|
let newestExisting = null;
|
|
if (pubkey && root) {
|
|
const existing = await fetchExistingMirrors({ pubkey, root });
|
|
if (Array.isArray(existing) && existing.length) {
|
|
const newest = existing.reduce((a, b) =>
|
|
(b.created_at || 0) > (a.created_at || 0) ? b : a,
|
|
);
|
|
newestExisting = newest;
|
|
if (forcedShort === undefined) {
|
|
forcedShort = newest.kind === NIP71_SHORT_VIDEO_KIND;
|
|
reusedExistingKind = true;
|
|
const otherKind = forcedShort
|
|
? NIP71_NORMAL_VIDEO_KIND
|
|
: NIP71_SHORT_VIDEO_KIND;
|
|
if (existing.some((e) => e.kind === otherKind)) {
|
|
staleKind = otherKind;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Addressable events only replace an older coordinate. Match the canonical
|
|
// edit path: a fast second edit or a clock behind an existing relay event
|
|
// must still advance the mirror.
|
|
const createdAt = Math.max(
|
|
Math.floor(now() / 1000),
|
|
(Number(newestExisting?.created_at) || 0) + 1,
|
|
);
|
|
|
|
const built = buildMirrorEvent(
|
|
{ ...video, pubkey },
|
|
{ ...options, short: forcedShort, createdAt },
|
|
);
|
|
if (!built.ok) {
|
|
return { ok: false, reason: built.reason };
|
|
}
|
|
|
|
let signed;
|
|
try {
|
|
signed = await signEvent(built.event);
|
|
} catch (error) {
|
|
userLogger.warn("[nip71Mirror] Failed to sign mirror event:", error);
|
|
return { ok: false, error: "sign-failed", cause: error };
|
|
}
|
|
|
|
const relays = getWriteRelays() || [];
|
|
const pool = getPool();
|
|
if (!pool || !relays.length) {
|
|
return { ok: false, error: "no-relays" };
|
|
}
|
|
|
|
const results = await publishEventToRelays(pool, relays, signed);
|
|
const { accepted, failed } = summarizePublishResults(results);
|
|
|
|
// Self-heal: if a duplicate of the OTHER kind already existed, tear it down via a
|
|
// NIP-09 delete so the video stops showing twice in NIP-71 clients.
|
|
let healedStaleKind = false;
|
|
if (staleKind != null && accepted.length > 0) {
|
|
try {
|
|
const del = await signEvent({
|
|
kind: 5,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
content: "Removed duplicate NIP-71 mirror",
|
|
tags: [
|
|
["a", `${staleKind}:${pubkey}:${root}`],
|
|
["k", String(staleKind)],
|
|
],
|
|
});
|
|
const delResults = await publishEventToRelays(pool, relays, del);
|
|
healedStaleKind = summarizePublishResults(delResults).accepted.length > 0;
|
|
} catch (error) {
|
|
userLogger.warn("[nip71Mirror] Failed to clean up duplicate mirror:", error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: accepted.length > 0,
|
|
accepted: accepted.length,
|
|
total: accepted.length + failed.length,
|
|
event: signed,
|
|
kind: built.event.kind,
|
|
reusedExistingKind,
|
|
healedStaleKind,
|
|
};
|
|
}
|
|
|
|
// Tear down the mirror (on delete, or when the user toggles sync off). Belt-and-
|
|
// suspenders per the plan: a NIP-09 delete referencing BOTH addressable kinds
|
|
// (robust to a since-changed orientation), AND an empty-replace tombstone so
|
|
// clients that cache addressable events but ignore NIP-09 still see it cleared.
|
|
async function remove(video, options = {}) {
|
|
if (!isAvailable()) {
|
|
return { ok: false, error: "unavailable" };
|
|
}
|
|
const pubkey = str(video?.pubkey) || getActivePubkey();
|
|
const root = str(video?.videoRootId);
|
|
if (!pubkey || !root) {
|
|
return { ok: false, error: "invalid" };
|
|
}
|
|
|
|
const existing = await fetchExistingMirrors({ pubkey, root });
|
|
const newestCreatedAt = Array.isArray(existing)
|
|
? existing.reduce((max, event) => Math.max(max, Number(event?.created_at) || 0), 0)
|
|
: 0;
|
|
// NIP-09 only deletes addressable versions at or before the delete event,
|
|
// and empty replacements must likewise win their coordinate.
|
|
const createdAt = Math.max(Math.floor(now() / 1000), newestCreatedAt + 1);
|
|
const reason = str(options.reason) || "Removed NIP-71 mirror";
|
|
|
|
const deleteEvent = {
|
|
kind: 5,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
content: reason,
|
|
tags: [
|
|
["a", `34235:${pubkey}:${root}`],
|
|
["a", `34236:${pubkey}:${root}`],
|
|
["k", "34235"],
|
|
["k", "34236"],
|
|
],
|
|
};
|
|
|
|
// Empty-replace both coordinates. The NIP-09 delete is the canonical
|
|
// teardown, but clients that ignore it must not retain an old mirror when
|
|
// a source replacement changed portrait/landscape orientation.
|
|
const tombstones = [NIP71_NORMAL_VIDEO_KIND, NIP71_SHORT_VIDEO_KIND].map((kind) => ({
|
|
kind,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
// No imeta → nothing plays; title kept so the entry reads as removed.
|
|
tags: [["d", root], ["title", str(video?.title) || "[removed]"], ["client", "bitvid"]],
|
|
content: "",
|
|
}));
|
|
|
|
const relays = getWriteRelays() || [];
|
|
const pool = getPool();
|
|
if (!pool || !relays.length) {
|
|
return { ok: false, error: "no-relays" };
|
|
}
|
|
|
|
let publishedOk = 0;
|
|
let total = 0;
|
|
for (const template of [deleteEvent, ...tombstones]) {
|
|
total += 1;
|
|
let signed;
|
|
try {
|
|
signed = await signEvent(template);
|
|
} catch (error) {
|
|
userLogger.warn("[nip71Mirror] Failed to sign teardown event:", error);
|
|
continue;
|
|
}
|
|
const results = await publishEventToRelays(pool, relays, signed);
|
|
const { accepted } = summarizePublishResults(results);
|
|
if (accepted.length > 0) {
|
|
publishedOk += 1;
|
|
}
|
|
}
|
|
|
|
return { ok: publishedOk > 0, published: publishedOk, total };
|
|
}
|
|
|
|
// Relay-truth detection (#34): derive "is this mirrored?" from the actual
|
|
// published events instead of the device-local flag. `kinds.length > 1` flags a
|
|
// pre-existing cross-kind duplicate. (UI wiring is a follow-up — batch these.)
|
|
async function findMirror(video) {
|
|
const pubkey = str(video?.pubkey) || getActivePubkey();
|
|
const root = str(video?.videoRootId);
|
|
if (!pubkey || !root) {
|
|
return { mirrored: false, kinds: [], duplicate: false };
|
|
}
|
|
const existing = await fetchExistingMirrors({ pubkey, root });
|
|
const kinds = [
|
|
...new Set((Array.isArray(existing) ? existing : []).map((e) => e.kind).filter(Boolean)),
|
|
];
|
|
return { mirrored: kinds.length > 0, kinds, duplicate: kinds.length > 1 };
|
|
}
|
|
|
|
// Batched relay-truth detection for a list of videos (e.g. the My Videos grid):
|
|
// ONE query for all video roots instead of N. Returns Map<videoRootId,
|
|
// { mirrored, kinds, duplicate }>. Roots with no mirror are simply absent.
|
|
async function findMirrors(videos) {
|
|
const list = Array.isArray(videos) ? videos : [];
|
|
const pubkey = getActivePubkey();
|
|
const roots = [
|
|
...new Set(list.map((v) => str(v?.videoRootId)).filter(Boolean)),
|
|
];
|
|
const out = new Map();
|
|
if (!pubkey || !roots.length) {
|
|
return out;
|
|
}
|
|
const events = await fetchMirrorsByRoots({ pubkey, roots });
|
|
const kindsByRoot = new Map(roots.map((root) => [root, new Set()]));
|
|
for (const event of Array.isArray(events) ? events : []) {
|
|
if (kindsByRoot.has(event?.root) && event?.kind) {
|
|
kindsByRoot.get(event.root).add(event.kind);
|
|
}
|
|
}
|
|
for (const [root, kindsSet] of kindsByRoot) {
|
|
const kinds = [...kindsSet];
|
|
out.set(root, {
|
|
mirrored: kinds.length > 0,
|
|
kinds,
|
|
duplicate: kinds.length > 1,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
return { isAvailable, canMirror, publish, remove, findMirror, findMirrors };
|
|
}
|
|
|
|
export const nip71MirrorService = createNip71MirrorService();
|
|
|
|
export default nip71MirrorService;
|