Dead code (from the abandoned thumb-zone/Explore-sheet nav experiment):
- OasisMobile.css: removed ~90 lines of orphaned CSS (.oasis-bottombar-fork/.bb-fab-*,
.hive-sheet*/.fork-sheet*/.hsq-*, .omt-spacer, .hive-sheet-seg/.hs-seg, dead
.oasis-bottombar-fixed) — no element emits any of them.
- main_views.js: removed renderHiveSheet() (never called; sole emitter of that CSS).
- karvan_view.js: dropped dead export karvanShortId + unused karvanView param.
- Added a style for .karvan-msg-live (client emitted it with no rule).
Karvan security (from the review):
- GET /karvan/:id only adopts a mirror room on a real navigation (sec-fetch-dest
document / Accept text/html), not on <img>/subresources → fixes a CSRF that could
spam/evict the user's ephemeral rooms.
- Cap room.members at 50 (was unbounded; each poll echoed it back).
- Reject signal payloads >16KB (SDP/ICE are tiny) — anti memory-DoS.
- karvan.js: guard malformed {kind:desc} signals so one bad signal can't abort a poll batch.
Tests: 15/15 (added members-cap + oversized-payload). Verified: CSRF fix (nav=200,
subresource=302), pages unchanged after CSS removal, boot clean.
299 lines
14 KiB
JavaScript
299 lines
14 KiB
JavaScript
/* KARVAN client — mensajes temporales + WebRTC (data-channel).
|
|
* Capa fiable: relay por servidor (RAM, autodestrucción) con polling.
|
|
* Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation".
|
|
* Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay.
|
|
* Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */
|
|
(function () {
|
|
"use strict";
|
|
var root = document.querySelector(".karvan-room");
|
|
if (!root) return;
|
|
var roomId = root.getAttribute("data-room");
|
|
var selfId = root.getAttribute("data-self") || "";
|
|
var peerId = "p" + Math.random().toString(16).slice(2, 12);
|
|
var fromLabel = selfId || peerId;
|
|
|
|
var listEl = document.getElementById("karvan-msgs");
|
|
var formEl = document.getElementById("karvan-form");
|
|
var textEl = document.getElementById("karvan-text");
|
|
var liveTxt = document.getElementById("karvan-live-txt");
|
|
var liveWrap = document.getElementById("karvan-live");
|
|
|
|
var seen = Object.create(null);
|
|
var lastSeq = 0;
|
|
var lastSig = 0;
|
|
var peers = Object.create(null);
|
|
|
|
// --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) ---
|
|
var callRoot = document.getElementById("karvan-call");
|
|
var localVid = document.getElementById("karvan-local");
|
|
var remoteEl = document.getElementById("karvan-remote");
|
|
var statusEl = document.getElementById("kc-status");
|
|
var btnStart = document.getElementById("kc-start");
|
|
var btnMic = document.getElementById("kc-mic");
|
|
var btnCam = document.getElementById("kc-cam");
|
|
var btnStop = document.getElementById("kc-stop");
|
|
var localStream = (window.MediaStream ? new MediaStream() : null);
|
|
if (localVid && localStream) localVid.srcObject = localStream;
|
|
var micActive = false, camActive = false;
|
|
|
|
function esc(s) { return String(s == null ? "" : s); }
|
|
function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); }
|
|
|
|
function showMsg(m, live) {
|
|
var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random());
|
|
if (seen[key]) return;
|
|
seen[key] = 1;
|
|
var li = document.createElement("li");
|
|
li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : "");
|
|
var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from);
|
|
var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text);
|
|
li.appendChild(f); li.appendChild(t);
|
|
listEl.appendChild(li);
|
|
listEl.scrollTop = listEl.scrollHeight;
|
|
}
|
|
|
|
// --- relay por servidor (siempre activo) ---
|
|
function pollMsgs() {
|
|
fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (d) {
|
|
if (!d || !d.messages) return;
|
|
d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); });
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function sendMessage(text) {
|
|
if (!text) return;
|
|
var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6);
|
|
var m = { mid: mid, from: fromLabel, text: text };
|
|
showMsg(m, false); // optimista
|
|
broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares
|
|
fetch("/karvan/" + roomId + "/msg", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ from: fromLabel, text: text, mid: mid })
|
|
}).catch(function () {});
|
|
}
|
|
|
|
if (formEl) {
|
|
formEl.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
var v = (textEl.value || "").trim();
|
|
if (!v) return;
|
|
textEl.value = "";
|
|
sendMessage(v);
|
|
});
|
|
}
|
|
|
|
// --- señalización (mailbox RAM del servidor) ---
|
|
function sendSig(to, payload) {
|
|
fetch("/karvan/" + roomId + "/signal", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ from: peerId, to: to || "", payload: payload })
|
|
}).catch(function () {});
|
|
}
|
|
function announce() { sendSig("", { kind: "hello" }); }
|
|
|
|
function pollSignals() {
|
|
fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (d) {
|
|
if (!d) return;
|
|
(d.signals || []).forEach(function (s) {
|
|
if (s.seq > lastSig) lastSig = s.seq;
|
|
onSignal(s.from, s.payload);
|
|
});
|
|
(d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); });
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
// --- WebRTC (data-channel, perfect negotiation) ---
|
|
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection;
|
|
var rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
|
|
|
|
function setLive(on) {
|
|
if (!liveTxt) return;
|
|
liveTxt.textContent = on ? "🔒 P2P (WebRTC)" : "server relay";
|
|
if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : "");
|
|
}
|
|
|
|
function broadcastDC(obj) {
|
|
var s = JSON.stringify(obj);
|
|
for (var k in peers) {
|
|
var dc = peers[k] && peers[k].dc;
|
|
if (dc && dc.readyState === "open") { try { dc.send(s); } catch (e) {} }
|
|
}
|
|
}
|
|
|
|
function attachDC(peer, dc) {
|
|
peer.dc = dc;
|
|
dc.onopen = function () { setLive(true); };
|
|
dc.onmessage = function (e) {
|
|
try { var m = JSON.parse(e.data); if (m && m.kind === "chat") showMsg(m, true); } catch (x) {}
|
|
};
|
|
dc.onclose = function () {
|
|
var any = false;
|
|
for (var k in peers) if (peers[k].dc && peers[k].dc.readyState === "open") any = true;
|
|
if (!any) setLive(false);
|
|
};
|
|
}
|
|
|
|
function ensurePeer(remoteId) {
|
|
if (peers[remoteId] || !RTC) return peers[remoteId];
|
|
var polite = peerId > remoteId; // el id mayor es "polite"
|
|
var pc = new RTC(rtcConfig);
|
|
var peer = { pc: pc, dc: null, polite: polite, makingOffer: false, ignoreOffer: false, vid: null, remoteStream: null };
|
|
peers[remoteId] = peer;
|
|
|
|
if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal
|
|
pc.ondatachannel = function (e) { attachDC(peer, e.channel); };
|
|
// media: publicar los tracks locales que ya tengamos (si la llamada está activa) + recibir los del par
|
|
if (localStream) { localStream.getTracks().forEach(function (t) { try { pc.addTrack(t, localStream); } catch (e) {} }); }
|
|
pc.ontrack = function (e) { attachRemote(peer, remoteId, e); };
|
|
pc.onicecandidate = function (e) { if (e.candidate) sendSig(remoteId, { kind: "ice", candidate: e.candidate }); };
|
|
pc.onnegotiationneeded = function () {
|
|
peer.makingOffer = true;
|
|
Promise.resolve()
|
|
.then(function () { return pc.setLocalDescription(); })
|
|
.then(function () { sendSig(remoteId, { kind: "desc", desc: pc.localDescription }); })
|
|
.catch(function () {})
|
|
.then(function () { peer.makingOffer = false; });
|
|
};
|
|
pc.onconnectionstatechange = function () {
|
|
var st = pc.connectionState;
|
|
if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} removeRemoteVideo(peer); delete peers[remoteId]; }
|
|
};
|
|
return peer;
|
|
}
|
|
|
|
// --- media / videollamada (Fase 1) ---
|
|
function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : ""); }
|
|
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
|
|
function statusText() { var p = []; if (micActive) p.push("mic ON"); if (camActive) p.push("cam ON"); return p.length ? p.join(" · ") : ""; }
|
|
function updateBtns() {
|
|
if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : "");
|
|
if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : "");
|
|
}
|
|
|
|
function attachRemote(peer, remoteId, e) {
|
|
if (!remoteEl) return;
|
|
if (!peer.vid) {
|
|
peer.vid = document.createElement("video");
|
|
peer.vid.autoplay = true; peer.vid.playsInline = true; peer.vid.className = "kc-video kc-remote-vid";
|
|
peer.vid.setAttribute("data-peer", remoteId);
|
|
remoteEl.appendChild(peer.vid);
|
|
}
|
|
if (e.streams && e.streams[0]) peer.vid.srcObject = e.streams[0];
|
|
else { if (!peer.remoteStream) peer.remoteStream = new MediaStream(); peer.remoteStream.addTrack(e.track); peer.vid.srcObject = peer.remoteStream; }
|
|
setCallActive(true);
|
|
}
|
|
function removeRemoteVideo(peer) {
|
|
if (peer && peer.vid && peer.vid.parentNode) { try { peer.vid.parentNode.removeChild(peer.vid); } catch (_) {} peer.vid = null; }
|
|
}
|
|
|
|
function addLocalTrack(track) {
|
|
if (!localStream) return;
|
|
localStream.addTrack(track);
|
|
for (var k in peers) {
|
|
var pc = peers[k].pc, senders = pc.getSenders(), existing = null;
|
|
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === track.kind) { existing = senders[s]; break; }
|
|
if (existing) { try { existing.replaceTrack(track); } catch (_) {} } // swap (cambio de cámara) — sin renegociar
|
|
else { try { pc.addTrack(track, localStream); } catch (_) {} } // kind nuevo — dispara onnegotiationneeded
|
|
}
|
|
}
|
|
function removeLocalKind(kind) {
|
|
if (!localStream) return;
|
|
localStream.getTracks().filter(function (t) { return t.kind === kind; }).forEach(function (t) { t.stop(); localStream.removeTrack(t); });
|
|
for (var k in peers) {
|
|
var senders = peers[k].pc.getSenders();
|
|
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === kind) { try { senders[s].replaceTrack(null); } catch (_) {} }
|
|
}
|
|
}
|
|
function onMediaErr(kind, x) {
|
|
var name = (x && x.name) || "", msg = (x && x.message) || String(x);
|
|
if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus("⛔ Cámara/micro denegados (en el móvil, pendiente del wrapper). El chat sigue.");
|
|
else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + ".");
|
|
else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado.");
|
|
else setStatus("⚠ " + (name || "error") + ": " + msg);
|
|
}
|
|
function getMedia(kind) {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
|
|
var c = (kind === "audio") ? { audio: true, video: false } : { audio: false, video: true };
|
|
setStatus((kind === "audio" ? "🎤" : "🎥") + " pidiendo permiso…");
|
|
navigator.mediaDevices.getUserMedia(c).then(function (s) {
|
|
s.getTracks().forEach(addLocalTrack);
|
|
if (kind === "audio") micActive = true; else camActive = true;
|
|
setCallActive(true); updateBtns(); setStatus(statusText());
|
|
}).catch(function (x) { onMediaErr(kind, x); });
|
|
}
|
|
function startCall() {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
|
|
setStatus("📞 pidiendo cámara y micro…");
|
|
navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (s) {
|
|
s.getTracks().forEach(addLocalTrack);
|
|
micActive = true; camActive = true; setCallActive(true); updateBtns(); setStatus(statusText());
|
|
}).catch(function (x) {
|
|
if (x && x.name === "NotFoundError") { getMedia("audio"); return; } // sin cámara → intenta solo audio
|
|
onMediaErr("call", x);
|
|
});
|
|
}
|
|
function hangUp() {
|
|
if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); });
|
|
for (var k in peers) { var sn = peers[k].pc.getSenders(); for (var s = 0; s < sn.length; s++) { try { sn[s].replaceTrack(null); } catch (_) {} } }
|
|
micActive = false; camActive = false; setCallActive(false); updateBtns(); setStatus("");
|
|
}
|
|
if (btnStart) btnStart.addEventListener("click", startCall);
|
|
if (btnMic) btnMic.addEventListener("click", function () { if (micActive) { removeLocalKind("audio"); micActive = false; updateBtns(); setStatus(statusText()); } else getMedia("audio"); });
|
|
if (btnCam) btnCam.addEventListener("click", function () { if (camActive) { removeLocalKind("video"); camActive = false; updateBtns(); setStatus(statusText()); } else getMedia("video"); });
|
|
if (btnStop) btnStop.addEventListener("click", hangUp);
|
|
|
|
function onSignal(fromRemote, payload) {
|
|
if (!RTC || !payload) return;
|
|
var peer = ensurePeer(fromRemote);
|
|
if (!peer) return;
|
|
var pc = peer.pc;
|
|
if (payload.kind === "desc") {
|
|
var desc = payload.desc;
|
|
if (!desc) return; // señal malformada (sin desc) — no reventar el lote de sondeo
|
|
var collision = desc.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
|
|
peer.ignoreOffer = !peer.polite && collision;
|
|
if (peer.ignoreOffer) return;
|
|
Promise.resolve()
|
|
.then(function () { return pc.setRemoteDescription(desc); })
|
|
.then(function () {
|
|
if (desc.type === "offer") {
|
|
return pc.setLocalDescription().then(function () {
|
|
sendSig(fromRemote, { kind: "desc", desc: pc.localDescription });
|
|
});
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
} else if (payload.kind === "ice") {
|
|
Promise.resolve().then(function () { return pc.addIceCandidate(payload.candidate); }).catch(function () {});
|
|
}
|
|
}
|
|
|
|
// --- arranque ---
|
|
showMsgInitialSeq();
|
|
function showMsgInitialSeq() {
|
|
// registrar los mensajes ya renderizados por el servidor para no duplicarlos
|
|
var items = listEl ? listEl.querySelectorAll(".karvan-msg") : [];
|
|
for (var i = 0; i < items.length; i++) {
|
|
var seq = parseInt(items[i].getAttribute("data-seq"), 10) || 0;
|
|
var mid = items[i].getAttribute("data-mid") || "";
|
|
if (seq > lastSeq) lastSeq = seq;
|
|
seen[mid ? "m" + mid : "s" + seq] = 1;
|
|
}
|
|
}
|
|
|
|
pollMsgs();
|
|
setInterval(pollMsgs, 2000);
|
|
if (RTC) {
|
|
setLive(false);
|
|
announce();
|
|
setInterval(announce, 8000);
|
|
pollSignals();
|
|
setInterval(pollSignals, 2000);
|
|
}
|
|
})();
|