diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1bbf7f2a --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +HIVETALK_URL= +SUPABASE_URL= +SUPABASE_ANON_KEY= \ No newline at end of file diff --git a/app/api/nip98/nip98_client.js b/app/api/nip98/nip98_client.js new file mode 100644 index 00000000..f8582544 --- /dev/null +++ b/app/api/nip98/nip98_client.js @@ -0,0 +1,165 @@ +// THIS IS SAMPLE CODE ONLY, DOES NOT WORK IN PRODUCTION + +// + +// const { signEvent } = NostrTools; + +// var loggedIn = false; +// let pubkey = ""; +// let username = ""; +// let avatarURL = "https://cdn-icons-png.flaticon.com/512/149/149071.png"; + +// function loadUser() { +// if (window.nostr) { +// window.nostr.getPublicKey().then(function (pubkey) { +// if (pubkey) { +// loggedIn = true +// console.log("fetched pubkey", pubkey) +// } +// }).catch((err) => { +// console.log("LoadUser Err", err); +// console.log("logoff section") +// loggedIn = false +// }); +// } +// } + + +// class NostrAuthClient { +// /** +// * Construct a new NostrAuthClient instance. +// * @param {string} pubkey - Nostr public key of the user. +// */ +// constructor(pubkey) { +// this.publicKey = pubkey; +// } + +// // Generate a Nostr event for HTTP authentication +// async createAuthEvent(url, method, payload = null) { +// const tags = [ +// ['u', url], +// ['method', method.toUpperCase()] +// ]; + +// // If payload exists, add its SHA256 hash +// if (payload) { +// const payloadHash = await this.sha256(payload); +// tags.push(['payload', payloadHash]); +// } + +// const event = { +// kind: 27235, +// created_at: Math.floor(Date.now() / 1000), +// tags: tags, +// content: '', +// pubkey: this.publicKey +// }; +// console.log('event: ', event) + +// // Calculate event ID +// event.id = await this.calculateId(event); + +// // Sign the event +// event.sig = await window.nostr.signEvent(event); +// return event; +// } + +// // Utility functions for cryptographic operations +// async sha256(message) { +// const msgBuffer = new TextEncoder().encode(message); +// const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); +// const hashArray = Array.from(new Uint8Array(hashBuffer)); +// return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +// } + +// async calculateId(event) { +// const eventData = JSON.stringify([ +// 0, +// event.pubkey, +// event.created_at, +// event.kind, +// event.tags, +// event.content +// ]); +// return await this.sha256(eventData); +// } +// } + +// // Make an authenticated request +// async function fetchWithNostrAuth(url, options = {}) { +// const method = options.method || 'GET'; +// const payload = options.body || null; + +// const client = new NostrAuthClient(pubkey); +// const authEvent = await client.createAuthEvent(url, method, payload); + +// // Convert event to base64 +// const authHeader = 'Nostr ' + btoa(JSON.stringify(authEvent)); + +// // Add auth header to request +// const headers = new Headers(options.headers || {}); +// headers.set('Authorization', authHeader); + +// // Make the request +// return fetch(url, { +// ...options, +// headers +// }); +// } + +// // Helper function to get base domain/host with port if needed +// function getBaseUrl() { +// // Get the full host (includes port if it exists) +// const host = window.location.host; +// // Get the protocol (http: or https:) +// const protocol = window.location.protocol; +// // Combine them +// return `${protocol}//${host}`; +// } + +// async function authNIP98() { + +// const roomName = "TestRoom"; +// const preferredRelays = ['wss://hivetalk.nostr1.com'] +// const isModerator = true; + +// try { +// const baseUrl = getBaseUrl(); +// fetchWithNostrAuth(`${baseUrl}/api/auth`, { +// method: 'POST', +// headers: { +// 'Content-Type': 'application/json', +// }, +// body: JSON.stringify({ +// room: roomName, +// username: username, +// avatarURL: avatarURL, +// relays: preferredRelays, +// isPresenter: isModerator, +// }), +// }).then(response => { +// console.log('response', response.status) +// if (response.status === 302) { +// console.log("response status is 302") // Get the redirect URL from the response +// const data = response.json(); +// window.location.href = data.redirectUrl; +// } else if (response.ok) { +// console.log("response.ok", response.ok) +// return response.json(); +// } else { +// console.error('Login failed'); +// } +// }).then(data => { +// console.log('auth success: ', data); +// document.getElementById('protected').innerHTML = data['message']; +// }) + +// } catch (error) { +// console.error('Error:', error); +// document.getElementById('protected').innerHTML = error; +// } +// } + + +// loadUser(); +// authNIP98(); \ No newline at end of file diff --git a/app/api/nip98/nip98_server.js b/app/api/nip98/nip98_server.js new file mode 100644 index 00000000..dc6f9a9e --- /dev/null +++ b/app/api/nip98/nip98_server.js @@ -0,0 +1,181 @@ +// THIS IS SAMPLE CODE ONLY, DOES NOT WORK IN PRODUCTION + + +// const express = require('express') +// const cors = require('cors') +// const { verifyEvent } = require('nostr-tools'); + +// class NostrAuthMiddleware { +// constructor(options = {}) { +// this.timeWindow = options.timeWindow || 60; // seconds +// } + +// // Middleware function for Express +// middleware() { +// return async (req, res, next) => { +// try { +// const isValid = await this.validateRequest(req); +// console.log("isValid : ", isValid) +// if (!isValid) { +// return res.status(401).json({ +// error: 'Invalid Nostr authentication' +// }); +// } +// next(); +// } catch (error) { +// console.error('Nostr auth error:', error); +// res.status(401).json({ +// error: 'Authentication failed' +// }); +// } +// }; +// } + +// async validateRequest(req) { +// // Extract the Nostr event from Authorization header +// const authHeader = req.headers.authorization; +// console.log("validate request auth header: ", authHeader) + +// if (!authHeader?.startsWith('Nostr ')) { +// return false; +// } + +// try { +// // Decode the base64 event +// const eventStr = Buffer.from(authHeader.slice(6), 'base64').toString(); +// const event = JSON.parse(eventStr); + +// console.log("eventStr: ", eventStr) +// console.log('event decoded: ', event) + +// // Validate the event +// return await this.validateEvent(event.sig, req); +// } catch (error) { +// console.error('Error parsing auth event:', error); +// return false; +// } +// } + +// async validateEvent(event, req) { +// // 1. Check kind +// if (event.kind !== 27235) { +// return false; +// } +// console.log("check kind") + +// // 2. Check timestamp +// const now = Math.floor(Date.now() / 1000); +// if (Math.abs(now - event.created_at) > this.timeWindow) { +// return false; +// } +// console.log("check timestamp") + +// // 3. Check URL +// const urlTag = event.tags.find(tag => tag[0] === 'u'); + +// console.log('urltag: ', urlTag[1]) +// console.log('full url: ', this.getFullUrl(req)) + +// if (!urlTag || urlTag[1] !== this.getFullUrl(req)) { +// return false; +// } +// console.log("check URL") + +// // 4. Check method +// const methodTag = event.tags.find(tag => tag[0] === 'method'); +// if (!methodTag || methodTag[1] !== req.method) { +// return false; +// } +// console.log("check method") + +// // 5. Check payload hash if present +// if (req.body && Object.keys(req.body).length > 0) { +// const payloadTag = event.tags.find(tag => tag[0] === 'payload'); +// if (payloadTag) { +// const bodyHash = await this.sha256(JSON.stringify(req.body)); +// if (bodyHash !== payloadTag[1]) { +// return false; +// } +// } +// } +// console.log("check payload hash if present") + +// // 6. Verify event signature +// return await this.verifySignature(event); +// } + +// // Utility functions +// getFullUrl(req) { +// // return `${req.protocol}://${req.get('host')}${req.originalUrl}`; +// return `https://${req.get('host')}${req.originalUrl}`; +// } + +// async sha256(message) { +// return crypto +// .createHash('sha256') +// .update(message) +// .digest('hex'); +// } + +// async calculateEventId(event) { +// // Serialize the event data according to NIP-01 +// const serialized = JSON.stringify([ +// 0, // Reserved for future use +// event.pubkey, +// event.created_at, +// event.kind, +// event.tags, +// event.content +// ]); + +// // Calculate SHA256 hash +// return await this.sha256(serialized); +// } + +// async verifySignature(event) { +// let isGood = verifyEvent(event) +// console.log("Verify Event", isGood) +// return isGood +// } + +// } + +// const app = express(); +// const nostrAuth = new NostrAuthMiddleware(); +// app.use(express.json({ limit: '50mb' })); // Increase the limit if necessary + +// app.use( +// cors({ +// origin: '*', +// }) +// ) + +// app.get('/api', (req, res) => { +// res.setHeader('Content-Type', 'text/html') +// res.setHeader('Cache-Control', 's-max-age=1, stale-while-revalidate') +// res.send('hello world') +// }) + +// app.post('/api/auth', +// nostrAuth.middleware(), +// (req, res) => { +// try { +// // Accessing the JSON body sent by the client +// const { room, username, avatarURL, relays, isPresenter } = req.body; +// console.log('Room:', room); +// console.log('Username:', username); +// console.log('Avatar URL:', avatarURL); +// console.log('Relays:', relays); +// console.log('isPresenter', isPresenter); + +// // TODO: Redirect to hivetalk room give above info, correctly +// res.status(200).json({ message: 'Authentication successful'}); + +// } catch (error) { +// console.log("authentication failed") +// res.status(401).json({ error: 'Authentication failed' }); +// } +// } +// ); + +// module.exports = app; \ No newline at end of file diff --git a/app/src/Server.js b/app/src/Server.js index 9c9887df..a2478401 100644 --- a/app/src/Server.js +++ b/app/src/Server.js @@ -91,6 +91,7 @@ const Sentry = require('@sentry/node'); // const Mattermost = require('./Mattermost.js'); const restrictAccessByIP = require('./middleware/IpWhitelist.js'); const packageJson = require('../../package.json'); +const { invokeCheckRoomOwner } = require('./checkRoomOwner'); // Incoming Stream to RTPM const { v4: uuidv4 } = require('uuid'); @@ -163,6 +164,8 @@ const hostCfg = { api_room_exists: config.host.api_room_exists, users: config.host.users, authenticated: !config.host.protected, + nostr_api_endpoint: config.host.nostr_api_endpoint, + nostr_api_secret_key: config.host.nostr_api_secret_key, }; const restApi = { @@ -386,7 +389,7 @@ function startServer() { // Middleware to handle both .html and .handlebars templates app.use((req, res, next) => { - // Extend res.render to try both .html and .handlebars + // Extend res.render to try both .handlebars first const originalRender = res.render; res.render = function(view, locals, callback) { // Try .handlebars first @@ -626,7 +629,8 @@ function startServer() { // http://localhost:3010/join?room=test&roomPassword=0&name=mirotalksfu&audio=1&video=1&screen=0&hide=0¬ify=1 // http://localhost:3010/join?room=test&roomPassword=0&name=mirotalksfu&audio=1&video=1&screen=0&hide=0¬ify=0&token=token - const { room, roomPassword, name, audio, video, screen, hide, notify, token, isPresenter } = checkXSS( + // https://localhost:3010/join?room=yourname&nip98=nip98token // 60 second expiration + const { room, roomPassword, name, audio, video, screen, hide, notify, token, nip98, isPresenter } = checkXSS( req.query, ); @@ -640,17 +644,27 @@ function startServer() { let peerPassword = ''; let isPeerValid = false; let isPeerPresenter = false; - + + // TODO: Finish nip98 auth here - dashboard login 60 sec expiration + // if (nip98) { + // validateNip98Token(nip98).then((result) => { + // if (result) { + // log.debug('Direct Join NIP98 Success'); + // } + // }).catch((error) => { + // log.error('Direct Join NIP98 error', { error: error.message }); + // }) + // } + + // TODO: disallow reserved rooms if not owner if (token) { try { const validToken = await isValidToken(token); - if (!validToken) { return res.status(401).json({ message: 'Invalid Token' }); } const { username, password, presenter } = checkXSS(decodeToken(token)); - peerUsername = username; peerPassword = password; isPeerValid = await isAuthPeer(username, password); @@ -682,7 +696,6 @@ function startServer() { //return res.status(401).json({ message: 'Direct Room Join Unauthorized' }); } } - const OIDCUserAuthenticated = OIDC.enabled && req.oidc.isAuthenticated(); if ( @@ -722,7 +735,11 @@ function startServer() { return res.redirect('/'); } - const allowRoomAccess = isAllowedRoomAccess('/join/:roomId', req, hostCfg, roomList, roomId); + // check does the room exist yet? if not + // check for user allowed to open room. + + // TODO: disallow reserved rooms if not owner or on acl list + const allowRoomAccess = isAllowedRoomAccess('/join/:roomId', req, hostCfg, authHost, roomList, roomId); if (allowRoomAccess) { // 1. Protect room access with database check @@ -811,10 +828,36 @@ function startServer() { hostCfg.authenticated = true; } else { hostCfg.authenticated = false; - res.redirect('/login'); + res.sendFile(views.login); } }); + // handle nostr protected login rooms + // check for nostr authentication to start room + // if open to the public let anyone auth into the room + // if not open to the public then check the database for allowed users + // nostr_api_endpoint : https://supabase-url/api/v1/nostrAuth + // nostr_api_secret_key: 'insert your secret key here' + + app.post(['/nauth'], (req, res) => { + // nip98 nostr auth api endpoint + // check if request is valid. + res.sendFile(views.newRoom); + }); + + // handle nostr protected login rooms + // check for nostr authentication to start room + // if open to the public let anyone auth into the room + // if not open to the public then check the database for allowed users + // nostr_api_endpoint : https://supabase-url/api/v1/nostrAuth + // nostr_api_secret_key: 'insert your secret key here' + + app.post(['/nauth'], (req, res) => { + // nip98 nostr auth api endpoint + // check if request is valid. + res.sendFile(views.newRoom); + }); + // #################################################### // AXIOS // #################################################### @@ -1228,6 +1271,27 @@ function startServer() { }); }); + // API endpoint for room ownership check + app.post('/api/check-room-owner', async (req, res) => { + try { + const { room_id } = req.body; + const data = await invokeCheckRoomOwner(room_id); + res.json(data); + } catch (error) { + console.error('Error checking room ownership:', error); + res.status(500).json({ error: 'Failed to check room ownership' }); + } + }); + + app.post('/api/check-room-peers', async (req, res) => { + const { room_id } = req.body; + const room = roomList.get(room_id); + const peerCount = room ? room.peers.size : 0; + console.log('check-room-peers --> ', peerCount); + + res.json({ peerCount }); + }); + // #################################################### // SLACK API // #################################################### @@ -1486,7 +1550,9 @@ function startServer() { }); socket.on('join', async (dataObject, cb) => { - if (!roomExists(socket)) { + // TODO: Check if is Room is Reserved on on database + + if (!roomList.has(socket.room_id)) { return cb({ error: 'Room does not exist', }); @@ -1512,9 +1578,10 @@ function startServer() { const room = getRoom(socket); - const { peer_name, peer_id, peer_uuid, peer_token, os_name, os_version, browser_name, browser_version } = + const { peer_name, peer_pubkey, peer_id, peer_uuid, peer_token, os_name, os_version, browser_name, browser_version } = data.peer_info; + console.log('>>>>> [Join] <<<< - socket.on Peer Info', {peer_name: peer_name, peer_pubkey: peer_pubkey, peer_id: peer_id}); let is_presenter = true; // User Auth required or detect token, we check if peer valid diff --git a/app/src/checkRoomOwner.js b/app/src/checkRoomOwner.js new file mode 100644 index 00000000..97a1c2c4 --- /dev/null +++ b/app/src/checkRoomOwner.js @@ -0,0 +1,33 @@ +'use strict'; + +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config(); + +// Initialize Supabase client +const supabase = createClient( + process.env.SUPABASE_URL || 'http://127.0.0.1:54321', + process.env.SUPABASE_ANON_KEY || '' +) + +async function invokeCheckRoomOwner(roomName) { + try { + const { data } = await supabase.functions.invoke('check-room-owner', { + body: { p_room_name: roomName } + }) + //console.log('Room Owner Data:', data) + return data + } catch (error) { + console.error('Error:', error.message) + throw error + } +} + +// Test the check-room-owner function +// console.log('Testing check-room-owner function with room: KarrotRoom') +// invokeCheckRoomOwner('KarrotRoom') +// .then(result => console.log('Check Room Owner Result:', result)) +// .catch(error => console.error('Check Room Owner Error:', error)) + +module.exports = { + invokeCheckRoomOwner +}; diff --git a/package.json b/package.json index df8c8f67..4b0f821f 100644 --- a/package.json +++ b/package.json @@ -59,16 +59,19 @@ "dependencies": { "@mattermost/client": "^10.0.0", "@sentry/node": "^8.37.1", + "@supabase/supabase-js": "^2.46.2", "axios": "^1.7.7", + "body-parser": "^1.20.3", "colors": "1.4.0", "compression": "1.7.5", "cors": "2.8.5", "crypto-js": "4.2.0", "dompurify": "^3.1.7", + "dotenv": "^16.4.7", "ejs": "^3.1.10", - "express": "4.21.1", + "express": "^4.21.1", "express-handlebars": "^7.1.3", - "express-openid-connect": "^2.17.1", + "express-openid-connect": "^0.5.0", "fluent-ffmpeg": "^2.1.3", "he": "^1.2.0", "httpolyglot": "0.1.2", @@ -79,10 +82,10 @@ "mediasoup-client": "3.7.17", "ngrok": "^5.0.0-beta.2", "nodemailer": "^6.9.16", - "nostr-tools": "^2.5.2", + "nostr-tools": "^2.10.1", "openai": "^4.71.1", "qs": "6.13.0", - "socket.io": "4.8.1", + "socket.io": "^4.8.1", "swagger-ui-express": "5.0.1", "uuid": "11.0.2" }, diff --git a/public/js/RoomClient.js b/public/js/RoomClient.js index 4cf87326..539ef3cf 100644 --- a/public/js/RoomClient.js +++ b/public/js/RoomClient.js @@ -462,14 +462,17 @@ class RoomClient { this.producerLabel = new Map(); this.eventListeners = new Map(); + this.openState = false; + this.debug = false; this.debug ? window.localStorage.setItem('debug', 'mediasoup*') : window.localStorage.removeItem('debug'); + this.successCallback = successCallback; // Store callback + // VideoMenuBar Behavior if (this.isMobileDevice) { this.addLowLatencySwipeListener(); } - console.log('06 ----> Load MediaSoup Client v', mediasoupClient.version); console.log('06.1 ----> PEER_ID', this.peer_id); @@ -494,16 +497,135 @@ class RoomClient { // CREATE ROOM AND JOIN // #################################################### - this.createRoom(this.room_id).then(async () => { - const data = { - room_id: this.room_id, - peer_info: this.peer_info, - }; - await this.join(data); - this.initSockets(); - this._isConnected = true; - successCallback(); + console.log('>>>>> peer_info.peer_name', this.peer_info.peer_name); + console.log('>>>>> peer_info.peer_pubkey', this.peer_info.peer_pubkey); + console.log('Room ID ', this.room_id); + console.log('precheck this.openState:', this.openState) + + this.checkRoomHasPeers(); + } + + async checkRoomHasPeers() { + const response = await fetch('/api/check-room-peers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ room_id: this.room_id }), }); + + const roomData = await response.json(); + if (roomData.peerCount > 0) { + // Room already has peers, allow joining directly + this.openState = true; + this.createRoom(this.room_id).then(async () => { + const data = { + room_id: this.room_id, + peer_info: this.peer_info, + }; + await this.join(data); + this.initSockets(); + this._isConnected = true; + this.successCallback(); + }); + } else { + this.checkRoomOwnership(); + } + } + + async checkRoomOwnership() { + try { + const response = await fetch('/api/check-room-owner', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ room_id: this.room_id }), + }); + + if (!response.ok) { + throw new Error('Failed to check room ownership'); + } + + const data = await response.json(); + console.log('checkRoomOwnership', data); + // console.log('json response state = ', data[0].success) + + if (data[0].success) { + // Convert peer_pubkey to npub format for comparison + const peer_npub = this.peer_info.peer_npub + // console.log('peer_pubkey', this.peer_info.peer_pubkey, + // 'vs nostr_pubkey: ', data[0].nostr_pubkey); + // console.log('compare against room npub', peer_npub, + // 'vs room_npub: ', data[0].room_npub); + + // Check if peer is the room owner, allow open + if (this.peer_info?.peer_pubkey && data[0]?.nostr_pubkey && + this.peer_info.peer_pubkey === data[0].nostr_pubkey) { + this.openState = true; + } + if (peer_npub === data[0].room_npub) { + // Allow bot to open room + this.openState = true; + } + } else if (data[0].success === false) { + // allow room creation, not reserved + console.log("Not a reserved room proceed with open") + this.openState = true; + } + + // console.log("inside checkRoomOwnership openState: ", this.openState) + if (this.openState) { + this.createRoom(this.room_id).then(async () => { + const data = { + room_id: this.room_id, + peer_info: this.peer_info, + }; + await this.join(data); + this.initSockets(); + this._isConnected = true; + this.successCallback(); + }); + } else { + console.log("open state is false") + Swal.fire({ + allowOutsideClick: false, + allowEscapeKey: false, + showDenyButton: true, + showConfirmButton: false, + background: swalBackground, + title: 'Oops, '+ this.room_id + ' Room is closed', + text: 'Sorry, this is a Reserved Room and can only be opened by the Room Owner!', + denyButtonText: `Leave room`, + showClass: { popup: 'animate__animated animate__fadeInDown' }, + hideClass: { popup: 'animate__animated animate__fadeOutUp' }, + }).then((result) => { + if (!result.isConfirmed) { + this.event(_EVENTS.exitRoom); + } + }); + } + + } catch (error) { + console.error('Error checking room ownership:', error); + // Handle error case can't connect to DB - show error message and exit + Swal.fire({ + allowOutsideClick: false, + allowEscapeKey: false, + showDenyButton: true, + showConfirmButton: false, + background: swalBackground, + title: 'Error', + text: 'Unable to verify room ownership. Please try again later.', + denyButtonText: `Leave room`, + showClass: { popup: 'animate__animated animate__fadeInDown' }, + hideClass: { popup: 'animate__animated animate__fadeOutUp' }, + }).then((result) => { + if (!result.isConfirmed) { + this.event(_EVENTS.exitRoom); + } + }); + } } // #################################################### @@ -511,6 +633,7 @@ class RoomClient { // #################################################### async createRoom(room_id) { + // console.log('######### CREATE NEW ROOM #########'); await this.socket .request('createRoom', { room_id, @@ -1257,6 +1380,7 @@ class RoomClient { } getReconnectDirectJoinURL() { + // TODO: Add Direct join URL with peer info, decode peer token for nostr specific login const { peer_audio, peer_video, peer_screen, peer_token } = this.peer_info; const baseUrl = `${window.location.origin}/join`; const queryParams = {