Files
bitvid/src/js/nostr.js

696 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.nostr.band",
];
// Just a helper to keep error spam in check
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100;
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
if (eventContent) {
console.log(`Event Content: ${eventContent}`);
}
errorLogCount++;
}
if (errorLogCount === MAX_ERROR_LOGS) {
console.error(
"Maximum error log limit reached. Further errors will be suppressed."
);
}
}
/**
* Example "encryption" that just reverses strings.
* In real usage, swap with actual crypto.
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
}
function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
/**
* Convert a raw Nostr event => your "video" object.
*/
function convertEventToVideo(event) {
const content = JSON.parse(event.content || "{}");
return {
id: event.id,
// We store a 'videoRootId' in content so we can group multiple edits
videoRootId: content.videoRootId || null,
version: content.version ?? 1,
isPrivate: content.isPrivate ?? false,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
mode: content.mode || "live",
deleted: content.deleted === true,
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
};
}
/**
* Key each "active" video by its root ID => so you only store
* the newest version for each root. But for older events w/o videoRootId,
* or w/o 'd' tag, we handle fallback logic below.
*/
function getActiveKey(video) {
// If it has a videoRootId, we use that
if (video.videoRootId) {
return `ROOT:${video.videoRootId}`;
}
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
// This is a fallback approach so older events appear in the "active map".
const dTag = video.tags?.find((t) => t[0] === "d");
if (dTag) {
return `${video.pubkey}:${dTag[1]}`;
}
return `LEGACY:${video.id}`;
}
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
// All events—old or new—so older share links still work
this.allEvents = new Map();
// "activeMap" holds only the newest version for each root ID (or fallback).
this.activeMap = new Map();
}
/**
* Connect to all configured relays
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected");
if (isDevMode) {
console.log(`Connected to ${successfulRelays.length} relay(s)`);
}
} catch (err) {
console.error("Nostr init failed:", err);
throw err;
}
}
async connectToRelays() {
return Promise.all(
this.relays.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, 5000);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
}
/**
* Attempt Nostr extension login or abort
*/
async login() {
try {
if (!window.nostr) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
}
// Access control check
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error("Your account has been blocked on this platform.");
} else {
throw new Error("Access restricted to whitelisted users only.");
}
}
this.pubkey = pubkey;
if (isDevMode) {
console.log("Logged in with extension. Pubkey:", this.pubkey);
}
return this.pubkey;
} catch (e) {
console.error("Login error:", e);
throw e;
}
}
logout() {
this.pubkey = null;
if (isDevMode) console.log("User logged out.");
}
decodeNsec(nsec) {
try {
const { data } = window.NostrTools.nip19.decode(nsec);
return data;
} catch (error) {
throw new Error("Invalid NSEC key.");
}
}
/**
* Publish a *new* video with a brand-new d tag & brand-new videoRootId
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
if (isDevMode) {
console.log("Publishing new video with data:", videoData);
}
let finalMagnet = videoData.magnet;
if (videoData.isPrivate) {
finalMagnet = fakeEncrypt(finalMagnet);
}
// new "videoRootId" ensures all future edits know they're from the same root
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const contentObject = {
videoRootId,
version: videoData.version ?? 1,
deleted: false,
isPrivate: videoData.isPrivate ?? false,
title: videoData.title || "",
magnet: finalMagnet,
thumbnail: videoData.thumbnail || "",
description: videoData.description || "",
mode: videoData.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", dTagValue],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Publish event with brand-new root:", videoRootId);
console.log("Event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) console.log("Signed event:", signedEvent);
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) console.log(`Video published to ${url}`);
} catch (err) {
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
}
})
);
return signedEvent;
} catch (err) {
if (isDevMode) console.error("Failed to sign/publish:", err);
throw err;
}
}
/**
* Edits a video by creating a *new event* with a brand-new d tag,
* but reuses the same videoRootId as the original.
*
* => old link remains pinned to the old event, new link is a fresh ID.
* => older version is overshadowed if your dedupe logic only shows newest.
*/
async editVideo(originalEventStub, updatedData, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to edit.");
}
if (!originalEventStub.pubkey || originalEventStub.pubkey !== pubkey) {
throw new Error("You do not own this video (pubkey mismatch).");
}
// 1) Attempt to get the FULL old event details (especially videoRootId)
let baseEvent = originalEventStub;
// If the caller didn't pass .videoRootId, fetch from local or relay:
if (!baseEvent.videoRootId) {
const fetched = await this.getEventById(originalEventStub.id);
if (!fetched) {
throw new Error("Could not retrieve the original event to edit.");
}
baseEvent = fetched;
}
// 2) We now have baseEvent.videoRootId if it existed
let oldRootId = baseEvent.videoRootId || null;
// Decrypt the old magnet if it was private
let oldPlainMagnet = baseEvent.magnet || "";
if (baseEvent.isPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
// 3) Decide new privacy
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
// 4) Fallback to old magnet if none was provided
let finalPlainMagnet = (updatedData.magnet || "").trim();
if (!finalPlainMagnet) {
finalPlainMagnet = oldPlainMagnet;
}
// 5) Re-encrypt if user wants private
let finalMagnet = finalPlainMagnet;
if (wantPrivate) {
finalMagnet = fakeEncrypt(finalPlainMagnet);
}
// 6) If there's no root yet (legacy), use the old event's own ID.
// Otherwise keep the existing rootId.
if (!oldRootId) {
oldRootId = baseEvent.id;
if (isDevMode) {
console.log(
"No existing root => using baseEvent.id as root:",
oldRootId
);
}
}
// Generate a brand-new d-tag so it doesn't overshadow the old share link
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
// 7) Build updated content
const contentObject = {
videoRootId: oldRootId,
version: updatedData.version ?? baseEvent.version ?? 1,
deleted: false,
isPrivate: wantPrivate,
title: updatedData.title ?? baseEvent.title,
magnet: finalMagnet,
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
description: updatedData.description ?? baseEvent.description,
mode: updatedData.mode ?? baseEvent.mode ?? "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", newD], // new share link
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Creating edited event with root ID:", oldRootId);
console.log("Event content:", event.content);
}
// 8) Sign and publish the new event
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(`Edited video published to ${url}`);
}
} catch (err) {
if (isDevMode) {
console.error(`Publish failed to ${url}`, err);
}
}
})
);
return signedEvent;
} catch (err) {
console.error("Edit failed:", err);
throw err;
}
}
/**
* "Reverting" => we just mark the most recent content as {deleted:true} and blank out magnet/desc
*/
async revertVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to revert.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("Not your event (pubkey mismatch).");
}
// If front-end didn't pass the tags array, load the full event:
let baseEvent = originalEvent;
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
const fetched = await this.getEventById(originalEvent.id);
if (!fetched) {
throw new Error("Could not fetch the original event for reverting.");
}
baseEvent = {
id: fetched.id,
pubkey: fetched.pubkey,
content: JSON.stringify({
version: fetched.version,
deleted: fetched.deleted,
isPrivate: fetched.isPrivate,
title: fetched.title,
magnet: fetched.magnet,
thumbnail: fetched.thumbnail,
description: fetched.description,
mode: fetched.mode,
}),
tags: fetched.tags,
};
}
// Check d-tag
const dTag = baseEvent.tags.find((t) => t[0] === "d");
if (!dTag) {
throw new Error(
'No "d" tag => cannot revert addressable kind=30078 event.'
);
}
const existingD = dTag[1];
const oldContent = JSON.parse(baseEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
// If no root, fallback
let finalRootId = oldContent.videoRootId || null;
if (!finalRootId) {
finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
}
// Build “deleted: true” overshadow event => revert current version
const contentObject = {
videoRootId: finalRootId,
version: oldVersion,
deleted: true, // mark *this version* as deleted
isPrivate: oldContent.isPrivate ?? false,
title: oldContent.title || "",
magnet: "",
thumbnail: "",
description: "This version was reverted by the creator.",
mode: oldContent.mode || "live",
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD], // re-use same d => overshadow
],
content: JSON.stringify(contentObject),
};
const signedEvent = await window.nostr.signEvent(event);
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
} catch (err) {
if (isDevMode) {
console.error(`Failed to revert on ${url}`, err);
}
}
})
);
return signedEvent;
}
/**
* "Deleting" => we just mark all content with the same videoRootId as {deleted:true} and blank out magnet/desc
*/
async deleteAllVersions(videoRootId, pubkey) {
if (!pubkey) {
throw new Error("Not logged in to delete all versions.");
}
// 1) Find all events in our local allEvents that share the same root.
const matchingEvents = [];
for (const [id, vid] of this.allEvents.entries()) {
if (
vid.videoRootId === videoRootId &&
vid.pubkey === pubkey &&
!vid.deleted
) {
matchingEvents.push(vid);
}
}
// If you want to re-check the relay for older versions too,
// you can do a fallback query, but typically your local cache is enough.
if (!matchingEvents.length) {
throw new Error("No existing events found for that root.");
}
// 2) For each event, create a "deleted: true" overshadow
// by re-using the same d-tag so it cannot appear again.
for (const vid of matchingEvents) {
await this.revertVideo(
{
// re-using revertVideo logic
id: vid.id,
pubkey: vid.pubkey,
content: JSON.stringify({
version: vid.version,
deleted: vid.deleted,
isPrivate: vid.isPrivate,
title: vid.title,
magnet: vid.magnet,
thumbnail: vid.thumbnail,
description: vid.description,
mode: vid.mode,
}),
tags: vid.tags,
},
pubkey
);
}
// Optionally return some status
return true;
}
/**
* Subscribes to *all* video events. We store them in this.allEvents so older
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
* version of each root (or fallback).
*/
subscribeVideos(onVideo) {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 500,
since: 0,
};
if (isDevMode) {
console.log("[subscribeVideos] Subscribing with filter:", filter);
}
const sub = this.pool.sub(this.relays, [filter]);
sub.on("event", (event) => {
try {
const video = convertEventToVideo(event);
this.allEvents.set(event.id, video);
// If its marked deleted, remove from active map if its the active version
// NEW CODE
if (video.deleted) {
const activeKey = getActiveKey(video);
// Don't compare IDs—just remove that key from the active map
this.activeMap.delete(activeKey);
// (Optional) If you want a debug log:
// console.log(`[DELETE] Removed activeKey=${activeKey}`);
return;
}
// Not deleted => see if its the newest
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive) {
// brand new => set it
this.activeMap.set(activeKey, video);
onVideo(video);
} else {
// compare timestamps
if (video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
onVideo(video);
}
}
} catch (err) {
if (isDevMode) {
console.error("[subscribeVideos] Error processing event:", err);
}
}
});
sub.on("eose", () => {
if (isDevMode) {
console.log("[subscribeVideos] Reached EOSE for all relays");
}
});
return sub;
}
/**
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 300,
since: 0,
};
const localAll = new Map();
try {
// 1) Fetch all events from each relay
await Promise.all(
this.relays.map(async (url) => {
const events = await this.pool.list([url], [filter]);
for (const evt of events) {
const vid = convertEventToVideo(evt);
localAll.set(evt.id, vid);
}
})
);
// 2) Merge into this.allEvents
for (const [id, vid] of localAll.entries()) {
this.allEvents.set(id, vid);
}
// 3) Rebuild activeMap
this.activeMap.clear();
for (const [id, video] of this.allEvents.entries()) {
// Skip if the video is marked deleted
if (video.deleted) continue;
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
// If there's no existing entry or this is newer, set/replace
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
}
}
// 4) Return newest version for each root in descending order
const activeVideos = Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
return activeVideos;
} catch (err) {
console.error("fetchVideos error:", err);
return [];
}
}
/**
* Attempt to fetch an event by ID from local cache, then from the relays
*/
async getEventById(eventId) {
const local = this.allEvents.get(eventId);
if (local) {
return local;
}
// direct fetch if missing
try {
for (const url of this.relays) {
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
if (maybeEvt && maybeEvt.id === eventId) {
const video = convertEventToVideo(maybeEvt);
this.allEvents.set(eventId, video);
return video;
}
}
} catch (err) {
if (isDevMode) {
console.error("getEventById direct fetch error:", err);
}
}
return null; // not found
}
/**
* Return newest versions from activeMap if you want to skip older events
*/
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
);
}
}
export const nostrClient = new NostrClient();