mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-08 23:18:43 +00:00
651 lines
18 KiB
JavaScript
651 lines
18 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("");
|
|
}
|
|
|
|
class NostrClient {
|
|
constructor() {
|
|
this.pool = null;
|
|
this.pubkey = null;
|
|
this.relays = RELAY_URLS;
|
|
|
|
// We keep a Map of subscribed videos for quick lookups by event.id
|
|
this.subscribedVideos = 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;
|
|
}
|
|
}
|
|
|
|
// Helper method to handle relay connections
|
|
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);
|
|
|
|
// Debug logs
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logs out the user.
|
|
*/
|
|
logout() {
|
|
this.pubkey = null;
|
|
if (isDevMode) console.log("User logged out.");
|
|
}
|
|
|
|
/**
|
|
* Decodes an NSEC key.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
// Default version is 1 if not specified
|
|
const version = videoData.version ?? 1;
|
|
|
|
const uniqueD = `${Date.now()}-${Math.random()
|
|
.toString(36)
|
|
.substring(2, 10)}`;
|
|
|
|
// Always mark "deleted" false for new posts
|
|
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() / 100),
|
|
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);
|
|
}
|
|
|
|
// Grab the d tag from the original event
|
|
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
|
if (!dTag) {
|
|
throw new Error(
|
|
'This event has no "d" tag, cannot edit as addressable kind=30078.'
|
|
);
|
|
}
|
|
const existingD = dTag[1];
|
|
|
|
// Parse old content
|
|
const oldContent = JSON.parse(originalEvent.content || "{}");
|
|
if (isDevMode) {
|
|
console.log("Old content:", oldContent);
|
|
}
|
|
|
|
// Keep old version & deleted status
|
|
const oldVersion = oldContent.version ?? 1;
|
|
const oldDeleted = oldContent.deleted === true;
|
|
const newVersion = updatedVideoData.version ?? oldVersion;
|
|
|
|
const oldWasPrivate = oldContent.isPrivate === true;
|
|
|
|
// 1) If old was private, decrypt the old magnet once => oldPlainMagnet
|
|
let oldPlainMagnet = oldContent.magnet || "";
|
|
if (oldWasPrivate && oldPlainMagnet) {
|
|
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
|
}
|
|
|
|
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
|
|
const newIsPrivate =
|
|
typeof updatedVideoData.isPrivate === "boolean"
|
|
? updatedVideoData.isPrivate
|
|
: oldContent.isPrivate ?? false;
|
|
|
|
// 3) The user might type a new magnet or keep oldPlainMagnet
|
|
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
|
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
|
|
|
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
|
|
let finalMagnet = finalPlainMagnet;
|
|
if (newIsPrivate) {
|
|
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
|
}
|
|
|
|
// Build updated content
|
|
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);
|
|
}
|
|
|
|
// Publish to all relays
|
|
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"
|
|
* and republishing with the same (kind=30078, pubkey, d) address.
|
|
*/
|
|
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(
|
|
'This event has no "d" tag, cannot delete as addressable kind=30078.'
|
|
);
|
|
}
|
|
const existingD = dTag[1];
|
|
|
|
const oldContent = JSON.parse(originalEvent.content || "{}");
|
|
const oldVersion = oldContent.version ?? 1;
|
|
|
|
// Mark it "deleted" and clear out magnet, thumbnail, etc.
|
|
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,
|
|
};
|
|
|
|
// Reuse the same d-tag for an addressable edit
|
|
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 video events from all configured relays, storing them in a Map.
|
|
*
|
|
* @param {Function} onVideo - Callback fired for each new/updated video
|
|
*/
|
|
subscribeVideos(onVideo) {
|
|
const filter = {
|
|
kinds: [30078],
|
|
"#t": ["video"],
|
|
limit: 500, // Adjust as needed
|
|
since: 0,
|
|
};
|
|
|
|
if (isDevMode) {
|
|
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
|
}
|
|
|
|
// Create subscription across all relays
|
|
const sub = this.pool.sub(this.relays, [filter]);
|
|
|
|
sub.on("event", (event) => {
|
|
try {
|
|
const content = JSON.parse(event.content);
|
|
|
|
// If marked deleted
|
|
if (content.deleted === true) {
|
|
// Remove it from our Map if we had it
|
|
if (this.subscribedVideos.has(event.id)) {
|
|
this.subscribedVideos.delete(event.id);
|
|
// Optionally notify the callback so UI can remove it
|
|
// onVideo(null, { deletedId: event.id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Construct a video object
|
|
const video = {
|
|
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",
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
tags: event.tags,
|
|
};
|
|
|
|
// Check if we already have it in our Map
|
|
if (!this.subscribedVideos.has(event.id)) {
|
|
// It's new, so store it
|
|
this.subscribedVideos.set(event.id, video);
|
|
// Then notify the callback that a new video arrived
|
|
onVideo(video);
|
|
} else {
|
|
// Optional: if you want to detect edits, compare the new vs. old and update
|
|
// this.subscribedVideos.set(event.id, video);
|
|
// onVideo(video) to re-render, etc.
|
|
}
|
|
} 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: onVideo(null, { eose: true }) to signal initial load done
|
|
});
|
|
|
|
return sub; // so you can unsub later if needed
|
|
}
|
|
|
|
/**
|
|
* A one-time, bulk fetch of videos from all configured relays.
|
|
* (Limit has been reduced to 300 for better performance.)
|
|
*/
|
|
async fetchVideos() {
|
|
const filter = {
|
|
kinds: [30078],
|
|
"#t": ["video"],
|
|
limit: 300, // Reduced from 1000 for quicker fetches
|
|
since: 0,
|
|
};
|
|
const videoEvents = new Map();
|
|
|
|
try {
|
|
// Query each relay in parallel
|
|
await Promise.all(
|
|
this.relays.map(async (url) => {
|
|
const events = await this.pool.list([url], [filter]);
|
|
for (const evt of events) {
|
|
try {
|
|
const content = JSON.parse(evt.content);
|
|
if (content.deleted) {
|
|
videoEvents.delete(evt.id);
|
|
} else {
|
|
videoEvents.set(evt.id, {
|
|
id: evt.id,
|
|
pubkey: evt.pubkey,
|
|
created_at: evt.created_at,
|
|
title: content.title || "",
|
|
magnet: content.magnet || "",
|
|
thumbnail: content.thumbnail || "",
|
|
description: content.description || "",
|
|
mode: content.mode || "live",
|
|
isPrivate: content.isPrivate || false,
|
|
tags: evt.tags,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Error parsing event content:", e);
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
// Turn the Map into a sorted array
|
|
const allVideos = Array.from(videoEvents.values()).sort(
|
|
(a, b) => b.created_at - a.created_at
|
|
);
|
|
return allVideos;
|
|
} catch (err) {
|
|
console.error("fetchVideos error:", err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates video content structure.
|
|
*/
|
|
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);
|
|
console.log("Validation details:", {
|
|
hasTitle: typeof content.title === "string",
|
|
hasMagnet: typeof content.magnet === "string",
|
|
hasMode: typeof content.mode === "string",
|
|
validThumbnail:
|
|
typeof content.thumbnail === "string" ||
|
|
typeof content.thumbnail === "undefined",
|
|
validDescription:
|
|
typeof content.description === "string" ||
|
|
typeof content.description === "undefined",
|
|
});
|
|
}
|
|
|
|
return isValid;
|
|
} catch (error) {
|
|
if (isDevMode) {
|
|
console.error("Error validating video:", error);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const nostrClient = new NostrClient();
|