diff --git a/README.md b/README.md index f95ebe1a..696954c3 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ To set up your own instance of `MiroTalk P2P` on a dedicated cloud server, pleas - ianramzy (html [template](https://cruip.com/demos/neon/)) - vasanthv (webrtc-logic) - fabric.js (whiteboard) +- [Robohash.org](https://robohash.org) (random avatars) +- [Image by ddraw on Freepik](https://www.freepik.com/free-vector/collection-female-male-avatars_1105371.htm) (avatar illustrations) diff --git a/app/src/server.js b/app/src/server.js index b4e71abc..bfd6a41a 100755 --- a/app/src/server.js +++ b/app/src/server.js @@ -1616,17 +1616,19 @@ io.sockets.on('connect', async (socket) => { let peer_id_to_update = null; for (let peer_id in peers[room_id]) { - if (peers[room_id][peer_id]['peer_name'] == peer_name_old && peer_id == socket.id) { + if (peer_id == socket.id) { peers[room_id][peer_id]['peer_name'] = peer_name_new; + peers[room_id][peer_id]['peer_avatar'] = peer_avatar; // presenter if (presenters && presenters[room_id] && presenters[room_id][peer_id]) { presenters[room_id][peer_id]['peer_name'] = peer_name_new; } peer_id_to_update = peer_id; - log.debug('[' + socket.id + '] Peer name changed', { + log.debug('[' + socket.id + '] Peer profile changed', { peer_name_old: peer_name_old, peer_name_new: peer_name_new, }); + break; } } diff --git a/public/css/client.css b/public/css/client.css index 82ba618b..f692e9cd 100755 --- a/public/css/client.css +++ b/public/css/client.css @@ -3161,6 +3161,8 @@ button { #activeRoomsBtn, #myPeerNameSetBtn, +#myProfileAvatarUploadBtn, +#myProfileAvatarResetBtn, #captionEveryoneBtn, #muteEveryoneBtn, #hideEveryoneBtn, @@ -3177,6 +3179,8 @@ button { } #myPeerNameSetBtn:hover, +#myProfileAvatarUploadBtn:hover, +#myProfileAvatarResetBtn:hover, #roomSendEmailBtn:hover, #lockRoomBtn:hover, #unlockRoomBtn:hover { @@ -3220,6 +3224,11 @@ button { transition: all 0.3s ease-in-out; } +#myProfileAvatarUploadBtn, +#myProfileAvatarResetBtn { + margin-top: 6px; +} + /*-------------------------------------------------------------- # Settings Table --------------------------------------------------------------*/ diff --git a/public/images/avatars/avatar_01.png b/public/images/avatars/avatar_01.png new file mode 100644 index 00000000..acdcc3c8 Binary files /dev/null and b/public/images/avatars/avatar_01.png differ diff --git a/public/images/avatars/avatar_02.png b/public/images/avatars/avatar_02.png new file mode 100644 index 00000000..15d2864d Binary files /dev/null and b/public/images/avatars/avatar_02.png differ diff --git a/public/images/avatars/avatar_03.png b/public/images/avatars/avatar_03.png new file mode 100644 index 00000000..9a82eaf1 Binary files /dev/null and b/public/images/avatars/avatar_03.png differ diff --git a/public/images/avatars/avatar_04.png b/public/images/avatars/avatar_04.png new file mode 100644 index 00000000..3a1299d5 Binary files /dev/null and b/public/images/avatars/avatar_04.png differ diff --git a/public/images/avatars/avatar_05.png b/public/images/avatars/avatar_05.png new file mode 100644 index 00000000..138e5052 Binary files /dev/null and b/public/images/avatars/avatar_05.png differ diff --git a/public/images/avatars/avatar_06.png b/public/images/avatars/avatar_06.png new file mode 100644 index 00000000..13254f80 Binary files /dev/null and b/public/images/avatars/avatar_06.png differ diff --git a/public/images/avatars/avatar_07.png b/public/images/avatars/avatar_07.png new file mode 100644 index 00000000..689874b0 Binary files /dev/null and b/public/images/avatars/avatar_07.png differ diff --git a/public/images/avatars/avatar_08.png b/public/images/avatars/avatar_08.png new file mode 100644 index 00000000..01804174 Binary files /dev/null and b/public/images/avatars/avatar_08.png differ diff --git a/public/images/avatars/avatar_09.png b/public/images/avatars/avatar_09.png new file mode 100644 index 00000000..c8e1f825 Binary files /dev/null and b/public/images/avatars/avatar_09.png differ diff --git a/public/images/avatars/avatar_10.png b/public/images/avatars/avatar_10.png new file mode 100644 index 00000000..620270a2 Binary files /dev/null and b/public/images/avatars/avatar_10.png differ diff --git a/public/images/avatars/avatar_11.png b/public/images/avatars/avatar_11.png new file mode 100644 index 00000000..c15b50c4 Binary files /dev/null and b/public/images/avatars/avatar_11.png differ diff --git a/public/images/avatars/avatar_12.png b/public/images/avatars/avatar_12.png new file mode 100644 index 00000000..b94b0786 Binary files /dev/null and b/public/images/avatars/avatar_12.png differ diff --git a/public/images/avatars/avatar_13.png b/public/images/avatars/avatar_13.png new file mode 100644 index 00000000..d0a532b6 Binary files /dev/null and b/public/images/avatars/avatar_13.png differ diff --git a/public/images/avatars/avatar_14.png b/public/images/avatars/avatar_14.png new file mode 100644 index 00000000..582897e5 Binary files /dev/null and b/public/images/avatars/avatar_14.png differ diff --git a/public/images/avatars/avatar_15.png b/public/images/avatars/avatar_15.png new file mode 100644 index 00000000..f1de3936 Binary files /dev/null and b/public/images/avatars/avatar_15.png differ diff --git a/public/images/avatars/avatar_16.png b/public/images/avatars/avatar_16.png new file mode 100644 index 00000000..fe473c3d Binary files /dev/null and b/public/images/avatars/avatar_16.png differ diff --git a/public/images/avatars/avatar_17.png b/public/images/avatars/avatar_17.png new file mode 100644 index 00000000..40a73a69 Binary files /dev/null and b/public/images/avatars/avatar_17.png differ diff --git a/public/images/avatars/avatar_18.png b/public/images/avatars/avatar_18.png new file mode 100644 index 00000000..3c1d8229 Binary files /dev/null and b/public/images/avatars/avatar_18.png differ diff --git a/public/images/avatars/avatar_19.png b/public/images/avatars/avatar_19.png new file mode 100644 index 00000000..882cbd46 Binary files /dev/null and b/public/images/avatars/avatar_19.png differ diff --git a/public/images/avatars/avatar_20.png b/public/images/avatars/avatar_20.png new file mode 100644 index 00000000..24246574 Binary files /dev/null and b/public/images/avatars/avatar_20.png differ diff --git a/public/images/avatars/avatar_21.png b/public/images/avatars/avatar_21.png new file mode 100644 index 00000000..eda8b473 Binary files /dev/null and b/public/images/avatars/avatar_21.png differ diff --git a/public/images/avatars/avatar_22.png b/public/images/avatars/avatar_22.png new file mode 100644 index 00000000..5c263774 Binary files /dev/null and b/public/images/avatars/avatar_22.png differ diff --git a/public/images/avatars/avatar_23.png b/public/images/avatars/avatar_23.png new file mode 100644 index 00000000..10175dbc Binary files /dev/null and b/public/images/avatars/avatar_23.png differ diff --git a/public/images/avatars/avatar_24.png b/public/images/avatars/avatar_24.png new file mode 100644 index 00000000..21094df7 Binary files /dev/null and b/public/images/avatars/avatar_24.png differ diff --git a/public/images/avatars/avatar_25.png b/public/images/avatars/avatar_25.png new file mode 100644 index 00000000..7d1efc58 Binary files /dev/null and b/public/images/avatars/avatar_25.png differ diff --git a/public/js/client.js b/public/js/client.js index 5770aee6..3e897263 100644 --- a/public/js/client.js +++ b/public/js/client.js @@ -360,6 +360,8 @@ const tabLanguagesBtn = getId('tabLanguagesBtn'); const mySettingsCloseBtn = getId('mySettingsCloseBtn'); const myPeerNameSet = getId('myPeerNameSet'); const myPeerNameSetBtn = getId('myPeerNameSetBtn'); +const myProfileAvatarUploadBtn = getId('myProfileAvatarUploadBtn'); +const myProfileAvatarResetBtn = getId('myProfileAvatarResetBtn'); const switchSounds = getId('switchSounds'); const switchShare = getId('switchShare'); const switchKeepButtonsVisible = getId('switchKeepButtonsVisible'); @@ -563,6 +565,7 @@ let thisMaxRoomParticipants = 8; let swBg = 'rgba(0, 0, 0, 0.7)'; // swAlert background color let isDocumentOnFullScreen = false; let isToggleExtraBtnClicked = false; +let hasTemporaryAvatar = false; // peer let myPeerId; // This socket.id @@ -847,6 +850,8 @@ function setButtonsToolTip() { // Settings setTippy(mySettingsCloseBtn, 'Close', 'bottom'); setTippy(myPeerNameSetBtn, 'Change name', 'top'); + setTippy(myProfileAvatarUploadBtn, 'Set temporary avatar URL', 'top'); + setTippy(myProfileAvatarResetBtn, 'Reset temporary avatar', 'top'); setTippy(myRoomId, 'Room name (click to copy/share)', 'right'); setTippy(mySessionTime, 'Session time', 'right'); setTippy( @@ -1237,10 +1242,11 @@ function generateRandomName() { function getPeerAvatar() { const avatar = getQueryParam('avatar'); const avatarDisabled = avatar === '0' || avatar === 'false'; + const isBase64Avatar = typeof avatar === 'string' && avatar.startsWith('data:image/'); console.log('Direct join', { avatar: avatar }); - if (avatarDisabled || !isImageURL(avatar)) { + if (avatarDisabled || isBase64Avatar || !isValidAvatarURL(avatar)) { return false; } return avatar; @@ -2633,6 +2639,10 @@ async function handleAddPeer(config) { return; } + // Re-broadcast current profile to ensure late joiners receive latest avatar/name. + // This uses the existing peerName signaling path. + emitMyPeerProfile(); + console.log('iceServers', iceServers[0]); // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection @@ -2695,6 +2705,18 @@ async function handleAddPeer(config) { screenReaderAccessibility.announceMessage(`${peer_name} joined the room`); } +/** + * Broadcast my current profile (name + avatar) to room peers + */ +function emitMyPeerProfile() { + sendToServer('peerName', { + room_id: roomId, + peer_name_old: myPeerName, + peer_name_new: myPeerName, + peer_avatar: myPeerAvatar, + }); +} + /** * Handle peers connection state * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event @@ -5259,10 +5281,11 @@ function genAvatarSvg(peerName, avatarImgSize) { */ function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) { const videoAvatarImageElement = getId(videoAvatarImageId); + if (!videoAvatarImageElement) return; videoAvatarImageElement.style.pointerEvents = 'none'; // If a valid avatar image URL is provided - if (peerAvatar && isImageURL(peerAvatar)) { + if (peerAvatar && isValidAvatarURL(peerAvatar)) { videoAvatarImageElement.setAttribute('src', peerAvatar); } // If not, use SVG based on the email validity @@ -5285,7 +5308,7 @@ function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) { */ function setPeerChatAvatarImgName(avatar, peerName, peerAvatar) { const avatarImg = - peerAvatar && isImageURL(peerAvatar) + peerAvatar && isValidAvatarURL(peerAvatar) ? peerAvatar : isValidEmail(peerName) ? genGravatar(peerName) @@ -7121,6 +7144,13 @@ function setMySettingsBtn() { myPeerNameSetBtn.addEventListener('click', (e) => { updateMyPeerName(); }); + myProfileAvatarUploadBtn.addEventListener('click', async () => { + await updateMyPeerAvatarByUrl(); + }); + myProfileAvatarResetBtn.addEventListener('click', () => { + resetMyPeerAvatarInMemory(); + }); + updateMyAvatarResetButtonVisibility(); // Sounds switchSounds.addEventListener('change', (e) => { notifyBySound = e.currentTarget.checked; @@ -10569,7 +10599,7 @@ function handleSpeechTranscript(config) { const time_stamp = getFormatDate(new Date()); const avatar_image = - peer_avatar && isImageURL(peer_avatar) + peer_avatar && isValidAvatarURL(peer_avatar) ? peer_avatar : isValidEmail(peer_name) ? genGravatar(peer_name) @@ -10577,9 +10607,14 @@ function handleSpeechTranscript(config) { if (!isCaptionBoxVisible && transcriptShowOnMsg) showCaptionDraggable(); + // avatar_image is a user-controlled URL; do NOT interpolate it into + // insertAdjacentHTML — filterXSS encodes " to " which the HTML + // parser decodes back to " in attribute context (double-decode XSS). + // Use a temporary id and setAttribute instead. + const captionAvatarTmpId = `capt-av-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const msgHTML = `
- +
${peer_name} : ${time_stamp}
@@ -10589,6 +10624,11 @@ function handleSpeechTranscript(config) {
`; captionChat.insertAdjacentHTML('beforeend', msgHTML); + const captionAvatarEl = document.getElementById(captionAvatarTmpId); + if (captionAvatarEl) { + captionAvatarEl.setAttribute('src', avatar_image); + captionAvatarEl.removeAttribute('id'); + } captionChat.scrollTop += 500; transcripts.push({ time: time_stamp, @@ -10652,11 +10692,14 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '') // check if i receive a private message let msgBubble = getPrivateMsg ? 'private-msg-bubble' : 'msg-bubble'; + // getImg is a user-controlled URL; use a temporary id and setAttribute + // after insertion to avoid double-decode XSS via insertAdjacentHTML. + const msgAvatarTmpId = `msg-av-${chatMessagesId}`; let msgHTML = `
- +
${getFrom}
@@ -10707,6 +10750,11 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '') `; msgerChat.insertAdjacentHTML('beforeend', msgHTML); + const msgAvatarEl = document.getElementById(msgAvatarTmpId); + if (msgAvatarEl) { + msgAvatarEl.setAttribute('src', getImg); + msgAvatarEl.removeAttribute('id'); + } const message = getId(`message-${chatMessagesId}`); if (message) { @@ -11280,7 +11328,7 @@ async function msgerAddPeers(peers) { // if there isn't add it.... if (!exsistMsgerPrivateDiv) { const chatAvatar = - peer_avatar && isImageURL(peer_avatar) + peer_avatar && isValidAvatarURL(peer_avatar) ? peer_avatar : isValidEmail(peer_name) ? genGravatar(peer_name) @@ -11634,6 +11682,8 @@ function isValidHttpURL(input) { */ function isImageURL(input) { if (!input || typeof input !== 'string') return false; + // Data URLs can still be valid images for generic content handling. + if (input.startsWith('data:image/')) return true; try { const url = new URL(input); return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg'].some((ext) => @@ -11644,6 +11694,24 @@ function isImageURL(input) { } } +/** + * Check if a URL is a valid HTTP/HTTPS avatar URL. + * Unlike isImageURL, this does NOT require a file extension, + * so it accepts dynamic avatar endpoints (e.g. GitHub, Gravatar, Robohash). + * @param {string} input + * @returns {boolean} + */ +function isValidAvatarURL(input) { + if (!input || typeof input !== 'string') return false; + if (input.startsWith('data:')) return false; + try { + const url = new URL(input); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + /** * Check if Image File * @return boolean @@ -12075,12 +12143,171 @@ async function updateMyPeerName() { userLog('toast', 'My name changed to ' + myPeerName); } +/** + * Update my avatar from URL in-memory only (cleared on page refresh) + */ +async function updateMyPeerAvatarByUrl() { + const result = await Swal.fire({ + background: swBg, + title: 'Set avatar URL', + input: 'url', + inputLabel: 'Public image URL', + inputPlaceholder: 'https://example.com/avatar.jpg', + confirmButtonText: 'Apply', + showCancelButton: true, + showClass: { popup: 'animate__animated animate__fadeInDown' }, + hideClass: { popup: 'animate__animated animate__fadeOutUp' }, + inputValidator: (value) => { + if (!value) return 'Please enter an image URL'; + if (value.startsWith('data:')) return 'Base64 avatars are not supported'; + if (!isValidAvatarURL(value)) return 'Only http/https URLs are supported'; + return null; + }, + preConfirm: (url) => + new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(url); + img.onerror = () => { + Swal.showValidationMessage( + 'Could not load the image — the URL may be invalid, restricted, or not an image' + ); + resolve(false); // keep dialog open + }; + img.src = url; + }), + didOpen: () => { + const input = document.querySelector('.swal2-input'); + if (!input) return; + + const preview = document.createElement('img'); + preview.style.cssText = + 'display:none;width:72px;height:72px;border-radius:50%;object-fit:cover;border:2px solid #4caf50;margin:8px auto 4px;'; + input.parentNode.insertBefore(preview, input); + + function updatePreview(url) { + if (!url) { + preview.style.display = 'none'; + return; + } + preview.src = url; + preview.style.display = 'block'; + } + + input.addEventListener('input', (e) => updatePreview(e.target.value.trim())); + + function makeAvatarImg(url) { + const img = document.createElement('img'); + img.src = url; + img.title = 'Click to use this avatar'; + img.style.cssText = + 'width:48px;height:48px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s;object-fit:cover;background:#222;flex-shrink:0;'; + img.addEventListener('mouseover', () => (img.style.borderColor = '#4caf50')); + img.addEventListener('mouseout', () => (img.style.borderColor = 'transparent')); + img.addEventListener('click', () => { + input.value = url; + input.dispatchEvent(new Event('input')); + updatePreview(url); + }); + return img; + } + + // Self-hosted avatars + const localLabel = document.createElement('p'); + localLabel.textContent = 'Pick an avatar:'; + localLabel.style.cssText = 'color:#aaa;font-size:12px;margin:10px 0 6px;text-align:center;'; + + const localGrid = document.createElement('div'); + localGrid.style.cssText = + 'display:flex;flex-wrap:wrap;justify-content:center;gap:8px;max-height:120px;overflow-y:scroll;-webkit-overflow-scrolling:touch;touch-action:pan-y;padding:4px 2px;margin-bottom:4px;'; + localGrid.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: true }); + + for (let i = 1; i <= 25; i++) { + const url = `${window.location.origin}/images/avatars/avatar_${String(i).padStart(2, '0')}.png`; + localGrid.appendChild(makeAvatarImg(url)); + } + + // Robohash random avatars + const roboLabel = document.createElement('p'); + roboLabel.textContent = 'Or pick a random avatar:'; + roboLabel.style.cssText = 'color:#aaa;font-size:12px;margin:10px 0 6px;text-align:center;'; + + const roboGrid = document.createElement('div'); + roboGrid.style.cssText = 'display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-bottom:4px;'; + + for (let i = 0; i < 6; i++) { + const seed = Math.random().toString(36).substring(2, 10); + const url = `https://robohash.org/${seed}.png`; + roboGrid.appendChild(makeAvatarImg(url)); + } + + let insertAfter = input; + for (const el of [localLabel, localGrid, roboLabel, roboGrid]) { + insertAfter.parentNode.insertBefore(el, insertAfter.nextSibling); + insertAfter = el; + } + }, + }); + + if (!result.isConfirmed || !result.value) return; + + try { + myPeerAvatar = result.value; + hasTemporaryAvatar = true; + + setPeerAvatarImgName('myVideoAvatarImage', myPeerName, myPeerAvatar); + setPeerAvatarImgName('myProfileAvatar', myPeerName, myPeerAvatar); + setPeerChatAvatarImgName('right', myPeerName, myPeerAvatar); + updateMyAvatarResetButtonVisibility(); + + emitMyPeerProfile(); + + userLog('toast', 'Temporary avatar applied (will reset on refresh)'); + } catch (err) { + console.error('Failed to set avatar URL', err); + userLog('error', 'Unable to apply avatar URL'); + } +} + +/** + * Reset in-memory avatar to default generated/fallback avatar + */ +function resetMyPeerAvatarInMemory() { + myPeerAvatar = false; + hasTemporaryAvatar = false; + setPeerAvatarImgName('myVideoAvatarImage', myPeerName, myPeerAvatar); + setPeerAvatarImgName('myProfileAvatar', myPeerName, myPeerAvatar); + setPeerChatAvatarImgName('right', myPeerName, myPeerAvatar); + updateMyAvatarResetButtonVisibility(); + + emitMyPeerProfile(); + + userLog('toast', 'Temporary avatar reset'); +} + +/** + * Show reset avatar button only for uploaded temporary avatars + */ +function updateMyAvatarResetButtonVisibility() { + if (!myProfileAvatarResetBtn) return; + myProfileAvatarResetBtn.classList.toggle('hidden', !hasTemporaryAvatar); + if (myProfileAvatarUploadBtn) myProfileAvatarUploadBtn.classList.toggle('hidden', hasTemporaryAvatar); +} + /** * Append updated peer name to video player * @param {object} config data */ function handlePeerName(config) { - const { peer_id, peer_name, peer_avatar } = config; + const peer_id = config.peer_id; + const peer_name = filterXSS(config.peer_name); + const peer_avatar = filterXSS(config.peer_avatar); + + // Keep the latest profile in memory so late DOM creation still uses updated data. + if (allPeers && allPeers[peer_id]) { + allPeers[peer_id]['peer_name'] = peer_name; + allPeers[peer_id]['peer_avatar'] = peer_avatar; + } + const videoName = getId(peer_id + '_name'); const screenName = getId(peer_id + '_screen_name'); if (videoName) videoName.innerText = peer_name; @@ -12092,7 +12319,7 @@ function handlePeerName(config) { if (msgerPeerAvatar) { msgerPeerAvatar.src = - peer_avatar && isImageURL(peer_avatar) + peer_avatar && isValidAvatarURL(peer_avatar) ? peer_avatar : isValidEmail(peer_name) ? genGravatar(peer_name) diff --git a/public/views/client.html b/public/views/client.html index e7159873..11f13f29 100755 --- a/public/views/client.html +++ b/public/views/client.html @@ -1048,6 +1048,14 @@ access to use this app.
+
+ + +