mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 08:52:25 +00:00
Adds remove(video, options) to the mirror service for delete / toggle-off: - NIP-09 kind-5 delete referencing BOTH addressable kinds (34235:pub:root + 34236:pub:root, with k tags) so it's robust to a since-changed orientation. - empty-replace tombstone (same d-tag, title, NO imeta) so clients that cache addressable events but ignore NIP-09 still see the entry cleared; uses the short kind for portrait videos. Published to the write/outbox relay set; unavailable without a signer. tests/nip71-mirror-service.test.mjs: remove publishes delete (both kinds) + tombstone; portrait => 34236 tombstone; unavailable without signer. Mutation-verified (tombstone emission + both-kind a-tags load-bearing). Note: full unit suite green apart from the documented order-dependent flakes (uploadModal-reset; a transient login-modal clipboard global-state flake) — both pass in isolation and are unrelated to this change. Co-Authored-By: Claude Opus 4.8 <[email protected]>
195 lines
6.6 KiB
JavaScript
195 lines
6.6 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 } 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,
|
|
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 built = buildMirrorEvent(
|
|
{ ...video, pubkey },
|
|
{ ...options, createdAt: Math.floor(now() / 1000) },
|
|
);
|
|
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);
|
|
return {
|
|
ok: accepted.length > 0,
|
|
accepted: accepted.length,
|
|
total: accepted.length + failed.length,
|
|
event: signed,
|
|
};
|
|
}
|
|
|
|
// 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 createdAt = Math.floor(now() / 1000);
|
|
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"],
|
|
],
|
|
};
|
|
|
|
const width = Number(video?.width);
|
|
const height = Number(video?.height);
|
|
const portrait =
|
|
Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 && height > width;
|
|
const tombstoneKind =
|
|
options.short === true || (options.short !== false && portrait) ? 34236 : 34235;
|
|
const tombstone = {
|
|
kind: tombstoneKind,
|
|
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, tombstone]) {
|
|
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 };
|
|
}
|
|
|
|
return { isAvailable, canMirror, publish, remove };
|
|
}
|
|
|
|
export const nip71MirrorService = createNip71MirrorService();
|
|
|
|
export default nip71MirrorService;
|