// 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", ]; // 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; } /** * 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() / 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. */ // Minimal fix: ensures we only ever encrypt once per edit operation 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 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; 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.message ); } } }) ); return signedEvent; } catch (error) { if (isDevMode) { console.error("Failed to sign deleted event:", error.message); } throw new Error("Failed to sign deleted event."); } } /** * Fetches videos from all configured relays. */ async fetchVideos() { const filter = { kinds: [30078], "#t": ["video"], limit: 1000, since: 0, }; const videoEvents = new Map(); if (isDevMode) { console.log("[fetchVideos] Starting fetch from all relays..."); console.log("[fetchVideos] Filter:", filter); } try { await Promise.all( this.relays.map(async (url) => { if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`); try { const events = await this.pool.list([url], [filter]); if (isDevMode) { console.log(`Events from ${url}:`, events.length); if (events.length > 0) { events.forEach((evt, idx) => { console.log( `[fetchVideos] [${url}] Event[${idx}] ID: ${evt.id} | pubkey: ${evt.pubkey} | created_at: ${evt.created_at}` ); }); } } events.forEach((event) => { try { const content = JSON.parse(event.content); // If deleted == true, it overrides older notes if (content.deleted === true) { videoEvents.delete(event.id); return; } // If we haven't seen this event.id before, store it if (!videoEvents.has(event.id)) { videoEvents.set(event.id, { 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, }); } } catch (parseError) { if (isDevMode) { console.error( "[fetchVideos] Event parsing error:", parseError ); } } }); } catch (relayError) { if (isDevMode) { console.error( `[fetchVideos] Error fetching from ${url}:`, relayError ); } } }) ); const videos = Array.from(videoEvents.values()).sort( (a, b) => b.created_at - a.created_at ); // Apply access control filtering const filteredVideos = accessControl.filterVideos(videos); if (isDevMode) { console.log("[fetchVideos] All relays have responded."); console.log( `[fetchVideos] Total unique video events: ${videoEvents.size}` ); console.log( `[fetchVideos] Videos after filtering: ${filteredVideos.length}` ); } return filteredVideos; } catch (error) { if (isDevMode) { console.error("FETCH VIDEOS ERROR:", error); } 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();