mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-08 23:18:43 +00:00
685 lines
19 KiB
JavaScript
685 lines
19 KiB
JavaScript
// 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",
|
|
];
|
|
|
|
// Rate limiting for error logs
|
|
let errorLogCount = 0;
|
|
const MAX_ERROR_LOGS = 100; // Adjust as needed
|
|
|
|
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."
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A very naive "encryption" function that just reverses the string.
|
|
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
|
*/
|
|
function fakeEncrypt(magnet) {
|
|
return magnet.split("").reverse().join("");
|
|
}
|
|
|
|
function fakeDecrypt(encrypted) {
|
|
return encrypted.split("").reverse().join("");
|
|
}
|
|
|
|
/**
|
|
* Convert a raw Nostr event into your "video" object.
|
|
*/
|
|
function convertEventToVideo(event) {
|
|
const content = JSON.parse(event.content || "{}");
|
|
return {
|
|
id: event.id,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a combined key for (pubkey, dTagValue).
|
|
* If there's no `d` tag, we fallback to a special key so
|
|
* those older events still appear in the grid.
|
|
*/
|
|
function getPubkeyDKey(evt) {
|
|
const dTag = evt.tags.find((t) => t[0] === "d");
|
|
if (dTag) {
|
|
return `${evt.pubkey}:${dTag[1]}`;
|
|
} else {
|
|
// NEW: older events didn't have a d-tag, so use an alternative key
|
|
// Example: "npubXYZ:no-d:id-of-event"
|
|
return `${evt.pubkey}:no-d:${evt.id}`;
|
|
}
|
|
}
|
|
|
|
class NostrClient {
|
|
constructor() {
|
|
this.pool = null;
|
|
this.pubkey = null;
|
|
this.relays = RELAY_URLS;
|
|
|
|
// All events, old or new, keyed by event.id
|
|
this.allEvents = new Map();
|
|
|
|
// Only the "active" (non-deleted) newest version per (pubkey + dTag OR fallback)
|
|
this.activeMap = new Map();
|
|
}
|
|
|
|
/**
|
|
* Initializes the Nostr client by connecting to 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;
|
|
}
|
|
}
|
|
|
|
// Connects to each relay, ensuring they're alive
|
|
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);
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Logs in the user using a Nostr extension or by entering an NSEC key.
|
|
*/
|
|
async login() {
|
|
try {
|
|
if (!window.nostr) {
|
|
console.log("No Nostr extension found");
|
|
throw new Error(
|
|
"Please install a Nostr extension (like Alby or nos2x)."
|
|
);
|
|
}
|
|
|
|
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());
|
|
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
|
|
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
|
|
}
|
|
|
|
// Check access control
|
|
if (!accessControl.canAccess(npub)) {
|
|
if (accessControl.isBlacklisted(npub)) {
|
|
throw new Error(
|
|
"Your account has been blocked from accessing this platform."
|
|
);
|
|
} else {
|
|
throw new Error(
|
|
"Access is currently restricted to whitelisted users only."
|
|
);
|
|
}
|
|
}
|
|
|
|
this.pubkey = pubkey;
|
|
if (isDevMode) {
|
|
console.log(
|
|
"Successfully logged in with extension. Public key:",
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publishes a new video event to all relays (creates a brand-new note).
|
|
*/
|
|
async publishVideo(videoData, pubkey) {
|
|
if (!pubkey) {
|
|
throw new Error("User is not logged in.");
|
|
}
|
|
|
|
if (isDevMode) {
|
|
console.log("Publishing video with data:", videoData);
|
|
}
|
|
|
|
// If user sets "isPrivate = true", encrypt the magnet
|
|
let finalMagnet = videoData.magnet;
|
|
if (videoData.isPrivate === true) {
|
|
finalMagnet = fakeEncrypt(finalMagnet);
|
|
}
|
|
|
|
const version = videoData.version ?? 1;
|
|
const uniqueD = `${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.substring(2, 10)}`;
|
|
const contentObject = {
|
|
version,
|
|
deleted: false,
|
|
isPrivate: videoData.isPrivate || false,
|
|
title: videoData.title,
|
|
magnet: finalMagnet,
|
|
thumbnail: videoData.thumbnail,
|
|
description: videoData.description,
|
|
mode: videoData.mode,
|
|
};
|
|
|
|
const event = {
|
|
kind: 30078,
|
|
pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["t", "video"],
|
|
["d", uniqueD],
|
|
],
|
|
content: JSON.stringify(contentObject),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("Event content after stringify:", event.content);
|
|
console.log("Using d tag:", uniqueD);
|
|
}
|
|
|
|
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(`Event published to ${url}`);
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error(`Failed to publish to ${url}:`, err.message);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
return signedEvent;
|
|
} catch (error) {
|
|
if (isDevMode) {
|
|
console.error("Failed to sign event:", error.message);
|
|
}
|
|
throw new Error("Failed to sign event.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Edits an existing video event by reusing the same "d" tag.
|
|
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
|
*/
|
|
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
|
if (!pubkey) {
|
|
throw new Error("User is not logged in.");
|
|
}
|
|
if (originalEvent.pubkey !== pubkey) {
|
|
throw new Error("You do not own this event (different pubkey).");
|
|
}
|
|
|
|
if (isDevMode) {
|
|
console.log("Editing video event:", originalEvent);
|
|
console.log("New video data:", updatedVideoData);
|
|
}
|
|
|
|
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
|
if (!dTag) {
|
|
throw new Error('No "d" tag => cannot edit as addressable kind=30078.');
|
|
}
|
|
const existingD = dTag[1];
|
|
|
|
const oldContent = JSON.parse(originalEvent.content || "{}");
|
|
const oldVersion = oldContent.version ?? 1;
|
|
const oldDeleted = oldContent.deleted === true;
|
|
const newVersion = updatedVideoData.version ?? oldVersion;
|
|
|
|
const oldWasPrivate = oldContent.isPrivate === true;
|
|
let oldPlainMagnet = oldContent.magnet || "";
|
|
if (oldWasPrivate && oldPlainMagnet) {
|
|
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
|
}
|
|
|
|
const newIsPrivate =
|
|
typeof updatedVideoData.isPrivate === "boolean"
|
|
? updatedVideoData.isPrivate
|
|
: oldContent.isPrivate ?? false;
|
|
|
|
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
|
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
|
|
|
let finalMagnet = finalPlainMagnet;
|
|
if (newIsPrivate) {
|
|
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
|
}
|
|
|
|
const contentObject = {
|
|
version: newVersion,
|
|
deleted: oldDeleted,
|
|
isPrivate: newIsPrivate,
|
|
title: updatedVideoData.title,
|
|
magnet: finalMagnet,
|
|
thumbnail: updatedVideoData.thumbnail,
|
|
description: updatedVideoData.description,
|
|
mode: updatedVideoData.mode,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("Building updated content object:", contentObject);
|
|
}
|
|
|
|
const event = {
|
|
kind: 30078,
|
|
pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["t", "video"],
|
|
["d", existingD],
|
|
],
|
|
content: JSON.stringify(contentObject),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("Reusing d tag:", existingD);
|
|
console.log("Updated event content:", event.content);
|
|
}
|
|
|
|
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 event published to ${url} (d="${existingD}")`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error(
|
|
`Failed to publish edited event to ${url}:`,
|
|
err.message
|
|
);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
return signedEvent;
|
|
} catch (error) {
|
|
if (isDevMode) {
|
|
console.error("Failed to sign edited event:", error.message);
|
|
}
|
|
throw new Error("Failed to sign edited event.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
|
*/
|
|
async deleteVideo(originalEvent, pubkey) {
|
|
if (!pubkey) {
|
|
throw new Error("User is not logged in.");
|
|
}
|
|
if (originalEvent.pubkey !== pubkey) {
|
|
throw new Error("You do not own this event (different pubkey).");
|
|
}
|
|
|
|
if (isDevMode) {
|
|
console.log("Deleting video event:", originalEvent);
|
|
}
|
|
|
|
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
|
if (!dTag) {
|
|
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
|
|
}
|
|
const existingD = dTag[1];
|
|
|
|
const oldContent = JSON.parse(originalEvent.content || "{}");
|
|
const oldVersion = oldContent.version ?? 1;
|
|
|
|
const contentObject = {
|
|
version: oldVersion,
|
|
deleted: true,
|
|
title: oldContent.title || "",
|
|
magnet: "",
|
|
thumbnail: "",
|
|
description: "This video has been deleted.",
|
|
mode: oldContent.mode || "live",
|
|
isPrivate: oldContent.isPrivate || false,
|
|
};
|
|
|
|
const event = {
|
|
kind: 30078,
|
|
pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["t", "video"],
|
|
["d", existingD],
|
|
],
|
|
content: JSON.stringify(contentObject),
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("Reusing d tag for delete:", existingD);
|
|
console.log("Deleted event content:", event.content);
|
|
}
|
|
|
|
try {
|
|
const signedEvent = await window.nostr.signEvent(event);
|
|
if (isDevMode) {
|
|
console.log("Signed deleted event:", signedEvent);
|
|
}
|
|
|
|
await Promise.all(
|
|
this.relays.map(async (url) => {
|
|
try {
|
|
await this.pool.publish([url], signedEvent);
|
|
if (isDevMode) {
|
|
console.log(
|
|
`Deleted event published to ${url} (d="${existingD}")`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error(`Failed to publish deleted event to ${url}:`, err);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
return signedEvent;
|
|
} catch (error) {
|
|
if (isDevMode) {
|
|
console.error("Failed to sign deleted event:", error);
|
|
}
|
|
throw new Error("Failed to sign deleted event.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribes to all video events from all relays.
|
|
* We store them in `allEvents` (so old IDs are still available),
|
|
* and we also maintain `activeMap` for the newest versions of each pubkey-dKey.
|
|
*
|
|
* @param {Function} onVideo - Callback for each newly recognized "active" video
|
|
*/
|
|
subscribeVideos(onVideo) {
|
|
const filter = {
|
|
kinds: [30078],
|
|
"#t": ["video"],
|
|
limit: 500,
|
|
since: 0, // we want from the beginning
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
|
}
|
|
|
|
const sub = this.pool.sub(this.relays, [filter]);
|
|
|
|
sub.on("event", (event) => {
|
|
try {
|
|
// Convert event => video object
|
|
const video = convertEventToVideo(event);
|
|
|
|
// Always store it in allEvents
|
|
this.allEvents.set(event.id, video);
|
|
|
|
// If deleted, remove from activeMap if it's the active version
|
|
if (video.deleted) {
|
|
const key = getPubkeyDKey(event); // might be no-d if no 'd' tag
|
|
const activeVid = this.activeMap.get(key);
|
|
if (activeVid && activeVid.id === event.id) {
|
|
this.activeMap.delete(key);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// It's not deleted => see if we should set it as active
|
|
const key = getPubkeyDKey(event); // might be "npubXYZ:no-d:ID"
|
|
const existingActive = this.activeMap.get(key);
|
|
if (!existingActive) {
|
|
// brand new => store it
|
|
this.activeMap.set(key, video);
|
|
onVideo(video);
|
|
} else {
|
|
// We have an active version; check timestamps
|
|
if (video.created_at > existingActive.created_at) {
|
|
// It's newer => overwrite
|
|
this.activeMap.set(key, video);
|
|
onVideo(video);
|
|
} else {
|
|
// It's an older event => ignore from "active" perspective
|
|
// but still in allEvents for old links
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error("[subscribeVideos] Error parsing event:", err);
|
|
}
|
|
}
|
|
});
|
|
|
|
sub.on("eose", () => {
|
|
if (isDevMode) {
|
|
console.log("[subscribeVideos] Reached EOSE for all relays");
|
|
}
|
|
// Optionally notify that the initial load is done
|
|
});
|
|
|
|
return sub;
|
|
}
|
|
|
|
/**
|
|
* Bulk fetch of videos from all relays. Then we build the `activeMap`
|
|
* so your grid can show all old & new events (even if no 'd' tag).
|
|
*/
|
|
async fetchVideos() {
|
|
const filter = {
|
|
kinds: [30078],
|
|
"#t": ["video"],
|
|
// Increase limit if you want more than 300
|
|
limit: 300,
|
|
since: 0,
|
|
};
|
|
|
|
const localAll = new Map();
|
|
|
|
try {
|
|
await Promise.all(
|
|
this.relays.map(async (url) => {
|
|
const events = await this.pool.list([url], [filter]);
|
|
for (const evt of events) {
|
|
const video = convertEventToVideo(evt);
|
|
localAll.set(evt.id, video);
|
|
}
|
|
})
|
|
);
|
|
|
|
// Merge localAll into our global allEvents
|
|
for (const [id, vid] of localAll.entries()) {
|
|
this.allEvents.set(id, vid);
|
|
}
|
|
|
|
// Re-build activeMap
|
|
this.activeMap.clear();
|
|
for (const [id, video] of this.allEvents.entries()) {
|
|
if (video.deleted) continue; // skip
|
|
const key = getPubkeyDKey({
|
|
id,
|
|
tags: video.tags,
|
|
pubkey: video.pubkey,
|
|
});
|
|
const existing = this.activeMap.get(key);
|
|
if (!existing || video.created_at > existing.created_at) {
|
|
this.activeMap.set(key, video);
|
|
}
|
|
}
|
|
|
|
// Return sorted "active" array for your grid
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an event by ID from our local cache (allEvents) if present.
|
|
* If missing, do a direct pool.get() for that ID. This ensures older
|
|
* "archived" events might still be loaded from the relays.
|
|
*/
|
|
async getEventById(eventId) {
|
|
const local = this.allEvents.get(eventId);
|
|
if (local) {
|
|
return local;
|
|
}
|
|
|
|
// NEW: do a direct fetch if not found
|
|
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);
|
|
// store in allEvents
|
|
this.allEvents.set(eventId, video);
|
|
return video;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (isDevMode) {
|
|
console.error("getEventById direct fetch error:", err);
|
|
}
|
|
}
|
|
|
|
// not found
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Return the "active" videos, i.e. latest for each (pubkey+d or fallback).
|
|
*/
|
|
getActiveVideos() {
|
|
return Array.from(this.activeMap.values()).sort(
|
|
(a, b) => b.created_at - a.created_at
|
|
);
|
|
}
|
|
|
|
isValidVideo(content) {
|
|
try {
|
|
const isValid =
|
|
content &&
|
|
typeof content === "object" &&
|
|
typeof content.title === "string" &&
|
|
content.title.length > 0 &&
|
|
typeof content.magnet === "string" &&
|
|
content.magnet.length > 0 &&
|
|
typeof content.mode === "string" &&
|
|
["dev", "live"].includes(content.mode) &&
|
|
(typeof content.thumbnail === "string" ||
|
|
typeof content.thumbnail === "undefined") &&
|
|
(typeof content.description === "string" ||
|
|
typeof content.description === "undefined");
|
|
|
|
if (isDevMode && !isValid) {
|
|
console.log("Invalid video content:", content);
|
|
}
|
|
return isValid;
|
|
} catch (error) {
|
|
if (isDevMode) {
|
|
console.error("Error validating video:", error);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const nostrClient = new NostrClient();
|