Compare commits

..

No commits in common. "093498e168afdcd1ff4e4f9b8030e7b12eb88a59" and "cab6314bc7af670eb0bb140652a3a13571b21358" have entirely different histories.

20 changed files with 385 additions and 1175 deletions

View file

@ -7,8 +7,8 @@ android {
applicationId "olcxja.miarven" applicationId "olcxja.miarven"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 6 versionCode 5
versionName "0.2.0" versionName "0.1.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -233,7 +233,6 @@
"message.reaction.removed": "Purreaction remeowed", "message.reaction.removed": "Purreaction remeowed",
"no.invite": "No meowvite", "no.invite": "No meowvite",
"not.a.member": "Not a meowmber", "not.a.member": "Not a meowmber",
"removed.from.group": "You got hissed out of the clowder",
"not.in.group": "Not in purroup", "not.in.group": "Not in purroup",
"reaction.not.found": "Purreaction not meownd", "reaction.not.found": "Purreaction not meownd",
"rotation.not.required": "Purrotation not meowquired", "rotation.not.required": "Purrotation not meowquired",

View file

@ -233,7 +233,6 @@
"message.reaction.removed": "Reaction removed", "message.reaction.removed": "Reaction removed",
"no.invite": "No invite", "no.invite": "No invite",
"not.a.member": "Not a member", "not.a.member": "Not a member",
"removed.from.group": "You were removed from the group",
"not.in.group": "Not in group", "not.in.group": "Not in group",
"reaction.not.found": "Reaction not found", "reaction.not.found": "Reaction not found",
"rotation.not.required": "Rotation not required", "rotation.not.required": "Rotation not required",

View file

@ -231,7 +231,6 @@
"message.reaction.removed": "Reakcja usunięta", "message.reaction.removed": "Reakcja usunięta",
"no.invite": "Brak zaproszenia", "no.invite": "Brak zaproszenia",
"not.a.member": "Nie jesteś członkiem", "not.a.member": "Nie jesteś członkiem",
"removed.from.group": "Zostałeś usunięty z grupy",
"not.in.group": "Nie jesteś w grupie", "not.in.group": "Nie jesteś w grupie",
"reaction.not.found": "Nie znaleziono reakcji", "reaction.not.found": "Nie znaleziono reakcji",
"rotation.not.required": "Rotacja niewymagana", "rotation.not.required": "Rotacja niewymagana",

View file

@ -285,7 +285,7 @@
const registerHost = document.getElementById("reg-host"); const registerHost = document.getElementById("reg-host");
const registerUsername = document.getElementById("reg-username"); const registerUsername = document.getElementById("reg-username");
const registerPassword = document.getElementById("reg-password"); const registerPassword = document.getElementById("reg-password");
const registerPasswordConfirm = document.getElementById("reg-confirm"); const registerPasswordConfirm = document.getElementById("reg-password");
const loginHost = document.getElementById("login-host"); const loginHost = document.getElementById("login-host");
const loginUsername = document.getElementById("login-username"); const loginUsername = document.getElementById("login-username");
const loginPassword = document.getElementById("login-password"); const loginPassword = document.getElementById("login-password");
@ -382,13 +382,6 @@
var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]); var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]);
sharedpvkey = sharedkey[2]; sharedpvkey = sharedkey[2];
createId = dataarray[2]; createId = dataarray[2];
//sól z init jest wymagana - hasło zawsze przez PBKDF2
if (!dataarray[3]) {
showBlahNotification("error:something.wrong");
authContainer.className = 'auth-container show-register';
return;
}
let regCredHash = await deriveCredentialKdf(registerPassword.value, dataarray[3]);
const captchaimage = await fetch(`${url}/createaccount?step=register`, { const captchaimage = await fetch(`${url}/createaccount?step=register`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@ -396,7 +389,7 @@
ciphertextMlKem: sharedkey[1], ciphertextMlKem: sharedkey[1],
idKey: createId, idKey: createId,
username: await encrypt(registerUsername.value, sharedpvkey), username: await encrypt(registerUsername.value, sharedpvkey),
password: await encrypt(regCredHash, sharedpvkey) password: await encrypt(await hashSHA3_512(registerPassword.value), sharedpvkey)
}) })
}); });
const clone = captchaimage.clone(); const clone = captchaimage.clone();

View file

@ -83,9 +83,7 @@ async function packetEncPass(pass, key, accountId) {
async function getNonce(accountId, key) { async function getNonce(accountId, key) {
let nonce; let nonce;
let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text();
headers: {"X-Crypto": "g1"}
})).text();
try { try {
nonce = await decryptString(fetchRes, key); nonce = await decryptString(fetchRes, key);
@ -125,8 +123,7 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa
function keyDataFromServerJson(jsonFromServer) { function keyDataFromServerJson(jsonFromServer) {
const data = JSON.parse(jsonFromServer); const data = JSON.parse(jsonFromServer);
//0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) return [data.pubX25519, data.pubMlKem, data.idKey]
return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt]
} }
function base64ToUint8(base64) { function base64ToUint8(base64) {
@ -708,20 +705,25 @@ async function decryptBytes(combinedBytes, passphrase) {
async function encryptString(plainText, passphrase) { async function encryptString(plainText, passphrase) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(plainText);
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
); );
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = new Uint8Array(await crypto.subtle.encrypt( const encrypted = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: iv}, {name: 'AES-CBC', iv: iv},
key, key,
encoder.encode(plainText) data
)); );
return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return uint8ToBase64(combined);
} }
async function encryptBytesWithNonce(dataUint8, key, nonce) { async function encryptBytesWithNonce(dataUint8, key, nonce) {
@ -730,44 +732,37 @@ async function encryptBytesWithNonce(dataUint8, key, nonce) {
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const importedKey = await crypto.subtle.importKey( const importedKey = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
); );
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = new Uint8Array(await crypto.subtle.encrypt( const encrypted = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: iv}, {name: 'AES-CBC', iv: iv},
importedKey, importedKey,
dataUint8 dataUint8
)); );
const prefix = encoder.encode("G1:"); const combined = new Uint8Array(iv.length + encrypted.byteLength);
const combined = new Uint8Array(prefix.length + iv.length + encrypted.byteLength); combined.set(iv);
combined.set(prefix, 0); combined.set(new Uint8Array(encrypted), iv.length);
combined.set(iv, prefix.length);
combined.set(encrypted, prefix.length + iv.length);
return combined; return combined;
} }
async function decryptString(base64Text, passphrase) { async function decryptString(base64Text, passphrase) {
//tylko AES-GCM ("G1:") - pakiety bez prefiksu odrzucane
if (typeof base64Text !== "string" || !base64Text.startsWith("G1:")) {
throw new Error("error:invalid.cipher.format");
}
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const combined = base64ToUint8(base64Text.substring(3)); const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0)));
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt']
); );
const iv = combined.slice(0, 12); const iv = combined.slice(0, 16);
const data = combined.slice(12); const data = combined.slice(16);
const decrypted = await crypto.subtle.decrypt( const decrypted = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: iv}, {name: 'AES-CBC', iv: iv},
key, key,
data data
); );
@ -789,7 +784,6 @@ async function fetchPost(url, value) {
method: "POST", method: "POST",
body: value, body: value,
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash))
} }
}); });
@ -812,7 +806,6 @@ async function fetchPostEnc(url, value) {
method: "POST", method: "POST",
body: await encryptWithNonce(value, passwordHash, nonce), body: await encryptWithNonce(value, passwordHash, nonce),
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce) "secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
} }
}); });
@ -842,7 +835,6 @@ async function fetchAsyncWAuth(url) {
let response = await fetch(url, { let response = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash))
} }
}); });
@ -872,30 +864,17 @@ async function Auth(loginUsername, loginPassword) {
} }
let actualId = resolvedId.split(":")[0]; let actualId = resolvedId.split(":")[0];
//sól KDF jest wymagana - konta bez soli nie mogą się zalogować let passwordHash = await hashSHA3_512(loginPassword);
let saltRes = await fetchAsync(`${url}/kdfsalt?id=${actualId}`);
if (!saltRes || !saltRes.startsWith("P:")) {
return "error:invalid.username.or.password";
}
let credHash = await deriveCredentialKdf(loginPassword, saltRes.substring(2).trim());
let response = await fetch(`${url}/auth?id=${actualId}`, { let response = await fetch(`${url}/auth?id=${actualId}`, {
method: "GET", method: "GET",
headers: { headers: {
"X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash))
"secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash))
} }
}); });
let data = await response.text(); let data = await response.text();
if (data.startsWith("success:")) { if (data.startsWith("success:")) {
data += "|" + actualId; data += "|" + actualId;
//utrwal globale sesyjne - dalsze żądania w tej sesji mają od razu ważny hash
passwordHash = credHash;
if (!id) {
id = actualId;
try { localStorage.setItem("id", actualId); } catch (e) {}
}
} }
return data; return data;
} finally { } finally {
@ -923,7 +902,6 @@ async function fetchEncrypted(request, body = "") {
, passwordHash, nonce), , passwordHash, nonce),
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce) "secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
} }
}); });
@ -951,7 +929,6 @@ async function fetchEncryptedUpload(target, fileBytesUint8) {
method: "POST", method: "POST",
body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce), body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce),
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce), "secret": await encryptWithNonce(passwordHash, passwordHash, nonce),
"target": await encryptWithNonce(target, passwordHash, nonce) "target": await encryptWithNonce(target, passwordHash, nonce)
} }
@ -1069,35 +1046,13 @@ function clearAction(actionid) {
} }
//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) async function hashSHA3_512(input) {
async function deriveCredentialKdf(passwordPlain, saltB64) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey( const data = encoder.encode(input);
"raw", const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd
encoder.encode(passwordPlain), const hashArray = Array.from(new Uint8Array(hashBuffer));
{name: "PBKDF2"}, const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
false, return hashHex;
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{name: "PBKDF2", hash: "SHA-256", salt: base64ToUint8(saltB64), iterations: 600000},
keyMaterial,
512
);
return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
}
//zwraca null gdy brak konta/soli - celowo NIE rzuca, żeby nie wywalać bootu aplikacji
//(strona logowania i świeży użytkownik nie mają soli; start() sam zrobi Auth i przekieruje)
async function resolveCredentialHash(accountId) {
if (!accountId) return null;
try {
let res = await fetchAsync(`${url}/kdfsalt?id=${accountId}`);
if (res && res.startsWith("P:") && password) {
return await deriveCredentialKdf(password, res.substring(2).trim());
}
} catch (e) {}
return null;
} }
//placeholder pfp //placeholder pfp
@ -1447,10 +1402,9 @@ async function mainJS() {
} }
await initBlahs(); await initBlahs();
//emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML await loadEmotes();
if (!window.location.pathname.includes("/login")) {
await loadEmotes(); passwordHash = await hashSHA3_512(password);
}
if (host) { if (host) {
await updateProtocolAndUrl(host); await updateProtocolAndUrl(host);
@ -1466,12 +1420,6 @@ async function mainJS() {
} }
} }
//hash tylko dla istniejącej sesji; brak soli/konta nie może blokować startu -
//start() i tak wykonuje własny Auth i przekieruje niezalogowanych na login
if (id && password) {
try { passwordHash = await resolveCredentialHash(id); } catch (e) { passwordHash = null; }
}
await start(); await start();
} }
@ -1925,7 +1873,6 @@ async function openProfile(accountId) {
try { try {
if (!accountId) accountId = window.id; //var fallback if (!accountId) accountId = window.id; //var fallback
accountId = sanitizeId(accountId);
if (accountId.endsWith(';' + host)) { if (accountId.endsWith(';' + host)) {
accountId = accountId.substring(0, accountId.length - host.length - 1); accountId = accountId.substring(0, accountId.length - host.length - 1);
@ -1949,7 +1896,7 @@ async function openProfile(accountId) {
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`); let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`);
if (bio.startsWith("error:")) bio = ""; if (bio.startsWith("error:")) bio = "";
let bioHtml = bio && bio.trim() !== "" ? `<div class="profile-bio">${escapeHtml(bio)}</div>` : ""; let bioHtml = bio && bio.trim() !== "" ? `<div class="profile-bio">${bio.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>` : "";
let isOwnProfile = accountId === id; let isOwnProfile = accountId === id;
let actionBtnHtml = ""; let actionBtnHtml = "";
@ -2045,8 +1992,8 @@ async function openProfile(accountId) {
</div> </div>
<div class="profile-info"> <div class="profile-info">
<div> <div>
<div class="profile-name">${escapeHtml(profileName)}</div> <div class="profile-name">${profileName}</div>
<div class="profile-id-host">${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})</div> <div class="profile-id-host">${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})</div>
${roleHtml} ${roleHtml}
</div> </div>
${bioHtml} ${bioHtml}
@ -2182,7 +2129,7 @@ async function renderInvites(res, type) {
if (invite.type === "dm") { if (invite.type === "dm") {
let id = sanitizeId(invite.id); let id = invite.id;
if (!id.includes(":")) { if (!id.includes(":")) {
id += `:${host}`; id += `:${host}`;
@ -2197,7 +2144,7 @@ async function renderInvites(res, type) {
let actions = ""; let actions = "";
let desc = ""; let desc = "";
let displayName = escapeHtml(username || onlyId); let displayName = username || onlyId;
if (type === "received") { if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`; desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = ` actions = `
@ -2227,8 +2174,8 @@ async function renderInvites(res, type) {
html += entry; html += entry;
} else if (invite.type === "group") { } else if (invite.type === "group") {
let groupId = sanitizeId(invite.groupId || invite.id); let groupId = invite.groupId || invite.id;
let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver); let targetId = (type === "received") ? invite.sender : invite.receiver;
if (!targetId.includes(":")) { if (!targetId.includes(":")) {
targetId += `:${host}`; targetId += `:${host}`;
@ -2251,9 +2198,9 @@ async function renderInvites(res, type) {
let actions = ""; let actions = "";
let desc = ""; let desc = "";
let displayName = escapeHtml(username || onlyId); let displayName = username || onlyId;
let val1 = `<span onclick="openProfile('${sanitizeId(targetId)}')" style="cursor:pointer"><b>${displayName}</b></span>`; let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
let val2 = `<span onclick="openProfile('${sanitizeId(targetId)}')" style="cursor:pointer"><b>${escapeHtml(onlyId)}</b></span>`; let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
if (type === "received") { if (type === "received") {
desc = `:desc.invite.group.received:${val1};${val2}`; desc = `:desc.invite.group.received:${val1};${val2}`;
@ -2659,17 +2606,16 @@ async function renderDms(res) {
let html = ""; let html = "";
let count = 0; let count = 0;
for (let [dmIdRaw, dmData] of dmEntries) { for (let [dmId, dmData] of dmEntries) {
let dmId = sanitizeId(dmIdRaw);
let parts = dmId.split('_'); let parts = dmId.split('_');
let myId = `${id};${host}`; let myId = `${id};${host}`;
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); let targetId = parts[0] === myId ? parts[1] : parts[0];
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
let pfp = await getAvatarUrl(targetId, targetUsername); let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = escapeHtml(targetUsername.split(':')[0]); let displayName = targetUsername.split(':')[0];
let isRead = dmData.string3 === "true"; let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
@ -2683,7 +2629,7 @@ async function renderDms(res) {
<img src="${pfp}" class="room-pfp"> <img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name" style="${nameStyle}">${displayName}</span> <span class="room-name" style="${nameStyle}">${displayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
</div> </div>
${unreadBadge} ${unreadBadge}
</button> </button>
@ -2739,23 +2685,19 @@ async function renderGroups(res) {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName); let pfp = await getGroupAvatarUrl(groupId, groupName);
let safeGroupId = sanitizeId(groupId);
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`; let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += ` html += `
<button class="room-entry" id="group-btn-${safeGroupId}" onclick="openDm('${safeGroupId}', decodeURIComponent('${safeGroupName}'), '${safeGroupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${safeGroupId}')"> <button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
<img src="${pfp}" class="room-pfp"> <img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name" style="${nameStyle}">${escapeHtmlText(groupName)}</span> <span class="room-name" style="${nameStyle}">${groupName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${groupId.replace(';', ':')}</span>
</div> </div>
${unreadBadge} ${unreadBadge}
</button> </button>
`; `;
count++; count++;
} else if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
} }
} }
@ -2801,18 +2743,6 @@ function escapeHtml(unsafe) {
.replaceAll(/'/g, "&#039;"); .replaceAll(/'/g, "&#039;");
} }
//ID-y/identyfikatory wstawiane do atrybutów HTML i inline JS (onclick).
//Usuwa wszystko poza bezpiecznym zestawem znakow - nie da się wyjść z stringa JS ani atrybutu.
//Dozwolone: litery, cyfry oraz _ . ; : @ -
function sanitizeId(value) {
return String(value ?? "").replace(/[^A-Za-z0-9_.;:@-]/g, "");
}
//nazwy (grup itp.) wstawiane do treści HTML
function escapeHtmlText(value) {
return escapeHtml(String(value ?? ""));
}
async function generateDmMembersHtml(targetId, targetUsername) { async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host; let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
@ -2821,28 +2751,24 @@ async function generateDmMembersHtml(targetId, targetUsername) {
let targetPfp = await getAvatarUrl(targetId, targetUsername); let targetPfp = await getAvatarUrl(targetId, targetUsername);
let targetDisplayName = targetUsername.split(':')[0]; let targetDisplayName = targetUsername.split(':')[0];
let myDisplayName = myUsername.split(':')[0]; let myDisplayName = myUsername.split(':')[0];
let safeTargetId = sanitizeId(targetId);
let safeMyId = sanitizeId(myIdStr);
targetDisplayName = escapeHtmlText(targetDisplayName);
let myDisplaySafe = escapeHtmlText(myDisplayName);
return ` return `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;"> <div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;"> <div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span> <span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div> </div>
<button class="room-entry" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;"> <button class="room-entry" onclick="openProfile('${targetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" class="room-pfp"> <img src="${targetPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${targetDisplayName}</span> <span class="room-name">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername}</span>
</div> </div>
</button> </button>
<button class="room-entry" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;"> <button class="room-entry" onclick="openProfile('${myIdStr}')" style="margin-bottom: 0;">
<img src="${myPfp}" class="room-pfp"> <img src="${myPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${myDisplaySafe}</span> <span class="room-name">${myDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${myUsername}</span>
</div> </div>
</button> </button>
</div> </div>
@ -2959,7 +2885,7 @@ async function generateGroupMembersHtml(groupId, groupName) {
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;"> <div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span> <span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
<div class="flex-spacer"></div> <div class="flex-spacer"></div>
<button class="topbar-action-btn" onclick="inviteToGroupPrompt('${sanitizeId(groupId)}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;"> <button class="topbar-action-btn" onclick="inviteToGroupPrompt('${groupId}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;">
${ICONS.plus({ width: "1rem", height: "1rem" })} ${ICONS.plus({ width: "1rem", height: "1rem" })}
</button> </button>
</div>`; </div>`;
@ -2969,16 +2895,14 @@ async function generateGroupMembersHtml(groupId, groupName) {
if (!memberUsername || memberUsername.startsWith("error")) continue; if (!memberUsername || memberUsername.startsWith("error")) continue;
let memberPfp = await getAvatarUrl(memberId, memberUsername); let memberPfp = await getAvatarUrl(memberId, memberUsername);
let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); let memberDisplayName = memberUsername.split(':')[0];
let safeMemberId = sanitizeId(memberId);
let safeGroupId = sanitizeId(groupId);
html += ` html += `
<button class="room-entry" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;"> <button class="room-entry" onclick="openProfile('${memberId}')" oncontextmenu="handleMemberContextMenu(event, '${groupId}', '${memberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" class="room-pfp"> <img src="${memberPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${memberDisplayName}</span> <span class="room-name">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${memberUsername}</span>
</div> </div>
</button>`; </button>`;
} }
@ -3001,16 +2925,6 @@ async function openDm(dmId, username, targetId, type = 'dms') {
} }
if (type === 'dms') { if (type === 'dms') {
let keyCheck = await fetchEncrypted("dm/key/get", dmId);
if (keyCheck === "error:forbidden") {
//wpis w liscie do ktorego nie mamy dostepu - usun go i nie otwieraj rozmowy
showBlahNotification(keyCheck);
removeRoomFromCachedList(dmId, 'dms');
removeRoomButton(sanitizeId(dmId), 'dms');
refreshDms(0, false);
clearAction("dmopen");
return;
}
currentDmKey = await ensureDmRoomKey(dmId); currentDmKey = await ensureDmRoomKey(dmId);
if (!currentDmKey) { if (!currentDmKey) {
throw new Error("Missing DM room key"); throw new Error("Missing DM room key");
@ -3022,16 +2936,8 @@ async function openDm(dmId, username, targetId, type = 'dms') {
window.currentGroupKeyIndex = info.string4 || "0"; window.currentGroupKeyIndex = info.string4 || "0";
window.currentGroupOwner = info.string7; window.currentGroupOwner = info.string7;
} else { } else {
showBlahNotification(infoPayload && infoPayload.startsWith("error:") ? infoPayload : "error:group.not.found"); window.currentGroupKeyIndex = "0";
//usuń wpis z listy tylko przy deterministycznym błędzie window.currentGroupOwner = "";
//(network.error itp. nie może czyścić poprawnych wpisów)
if (infoPayload === "error:group.not.found") {
removeRoomFromCachedList(dmId, 'groups');
removeRoomButton(sanitizeId(dmId), 'groups');
refreshGroups(0, false);
}
clearAction("dmopen");
return;
} }
} }
@ -3109,9 +3015,13 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
if (res && res.startsWith("error:")) { if (res && res.startsWith("error:")) {
if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") { if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") {
//brak dostępu do pokoju - serwer usunął też nieaktualny wpis z listy, if (dmMessagePollInterval) clearInterval(dmMessagePollInterval);
//my dodatkowo czyścimy cache/UI i wychodzimy z ekranu rozmowy currentDmId = null;
await handleLostRoomAccess(dmId, null, res); showBlahNotification(res);
if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details');
}
switchDetailsContent("title.details", `<div style="text-align: center; opacity: 0.5; padding: 1rem; margin-top: 2rem;"><blah>desc.no.chat.selected</blah></div>`);
return; return;
} }
showBlahNotification(res); showBlahNotification(res);
@ -3233,8 +3143,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
} }
} }
let safeAttId = sanitizeId(att.id); let attContainerId = `att-container-${msgId}-${att.id}`;
let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`;
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/")); let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
let sizeStr = att.size ? formatBytes(att.size) : ""; let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25); let displayName = truncateFilename(att.name, 25);
@ -3245,7 +3154,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27"); let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27"); let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 7.5rem; max-width: 18.75rem; min-height: 6.25rem; max-height: 18.75rem; background: rgba(0,0,0,0.2); border: 0.0625rem solid var(--border-color); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${sanitizeId(String(msgId))}', '${safeAttId}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${sanitizeId(msg.key) || "0"}')"> attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 7.5rem; max-width: 18.75rem; min-height: 6.25rem; max-height: 18.75rem; background: rgba(0,0,0,0.2); border: 0.0625rem solid var(--border-color); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${msg.key || "0"}')">
<div style="padding: 1rem; text-align: center; color: var(--text-color);"> <div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg} ${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div> <div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
@ -3306,11 +3215,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by"; else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by";
else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by"; else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by";
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`; let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr); let textStr = processBlah(actionStr);
if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) { if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) {
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${sanitizeId(msg.pervious)}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`; let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg); content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
} else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") { } else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") {
let escapedContent = escapeHtml(content); let escapedContent = escapeHtml(content);
@ -3320,7 +3229,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let targetId = ""; let targetId = "";
let parts = content.split(':'); let parts = content.split(':');
if (parts.length >= 2 && parts[0].includes(';')) { if (parts.length >= 2 && parts[0].includes(';')) {
targetId = sanitizeId(parts[0]); targetId = parts[0];
targetUsername = escapeHtml(parts.slice(1).join(':')); targetUsername = escapeHtml(parts.slice(1).join(':'));
} else { } else {
targetUsername = escapeHtml(parts[0]); targetUsername = escapeHtml(parts[0]);
@ -3366,7 +3275,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]);
} }
if (dmUsernameCache[msg.author]) { if (dmUsernameCache[msg.author]) {
authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]); authorName = dmUsernameCache[msg.author].split(':')[0];
pfp = dmPfpCache[msg.author]; pfp = dmPfpCache[msg.author];
} }
} }
@ -3446,7 +3355,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
window.replyAuthorCache[respondedId] = sName; window.replyAuthorCache[respondedId] = sName;
let el = document.getElementById(randId); let el = document.getElementById(randId);
if (el) { if (el) {
el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(sName)}`; el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${sName}`;
} }
} }
} }
@ -3456,7 +3365,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
} }
let divIdStr = randId !== "" ? `id="${randId}" ` : ""; let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${sanitizeId(respondedId)}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(replyAuthor)}</div>`; repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
} }
let reactionsHtml = ""; let reactionsHtml = "";
@ -3496,7 +3405,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27");
let escapedRxVal = escapeHtml(rxVal); let escapedRxVal = escapeHtml(rxVal);
let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`; let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`;
reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${sanitizeId(String(msgId))}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`; reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${msgId}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`;
} }
reactionsHtml += `</div>`; reactionsHtml += `</div>`;
} }
@ -3506,11 +3415,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
} }
html += ` html += `
<div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${sanitizeId(String(msgId))}" id="${isPinnedContext ? `pinned-` : ``}msg-${sanitizeId(String(msgId))}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${sanitizeId(String(msgId))}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}"> <div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${msgId}" id="${isPinnedContext ? `pinned-` : ``}msg-${msgId}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${msgId}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`} ${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${sanitizeId(String(msgId))}')"> <div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${msgId}')">
${showAvatar ? `<div class="chat-message-header"> ${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer;">${authorName}</span> <span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span> <span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""} </div>` : ""}
${repliedHtml} ${repliedHtml}
@ -3589,21 +3498,7 @@ async function renderMessages(messages, isPrepend = false) {
let newAttachments = newChild.querySelector('.chat-message-attachments'); let newAttachments = newChild.querySelector('.chat-message-attachments');
if (newAttachments) { if (newAttachments) {
if (oldAttachments) { if (oldAttachments) {
if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) { if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) oldAttachments.innerHTML = newAttachments.innerHTML;
//keep already downloaded/rendered attachment boxes alive across the update,
//and always sync data-raw - otherwise every poll would see a stale attribute
//and re-render (unloading) the attachments forever
let loadedBoxes = {};
oldAttachments.querySelectorAll('.attachment-box[data-loaded="true"]').forEach(box => {
loadedBoxes[box.id] = box;
});
oldAttachments.innerHTML = newAttachments.innerHTML;
oldAttachments.setAttribute('data-raw', newAttachments.getAttribute('data-raw'));
for (let boxId in loadedBoxes) {
let freshBox = oldAttachments.querySelector(`#${boxId}`);
if (freshBox) freshBox.replaceWith(loadedBoxes[boxId]);
}
}
} else { } else {
let contentNode = existingChild.querySelector('.chat-message-content'); let contentNode = existingChild.querySelector('.chat-message-content');
if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions); if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions);
@ -3861,7 +3756,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
<div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;"> <div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;">
<div style="width: 2.5rem; flex-shrink: 0;"></div> <div style="width: 2.5rem; flex-shrink: 0;"></div>
<div class="chat-message-content"> <div class="chat-message-content">
<div class="chat-message-text">${escapeHtml(content)} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div> <div class="chat-message-text">${content} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div>
</div> </div>
</div> </div>
`; `;
@ -3893,7 +3788,6 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
res = await fetch(`${url}/dm/attachment/staging?id=${id}`, { res = await fetch(`${url}/dm/attachment/staging?id=${id}`, {
method: "POST", method: "POST",
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce) "secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}, },
body: encryptedFileBytes body: encryptedFileBytes
@ -4097,10 +3991,6 @@ function setupWebSocket() {
}, 300); }, 300);
} }
} }
else if (data.startsWith("group_removed:")) {
let removedGroupId = data.substring("group_removed:".length);
handleGroupRemoved(removedGroupId);
}
else if (data.startsWith("call_update:")) { else if (data.startsWith("call_update:")) {
if (typeof handleCallUpdate === "function") { if (typeof handleCallUpdate === "function") {
handleCallUpdate(data); handleCallUpdate(data);
@ -4153,7 +4043,7 @@ function handleMessageContextMenu(e, msgId) {
if (pinContainer) { if (pinContainer) {
let isPinned = msg && msg.pinned === "true"; let isPinned = msg && msg.pinned === "true";
pinContainer.innerHTML = ` pinContainer.innerHTML = `
<button onclick="togglePinMessage('${sanitizeId(String(msgId))}')"> <button onclick="togglePinMessage('${msgId}')">
${ICONS.pin()} ${ICONS.pin()}
<span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span> <span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span>
</button>`; </button>`;
@ -4569,14 +4459,7 @@ async function changePassword() {
if (!newPass) return; if (!newPass) return;
showAction("action.account.saving", "accountsave"); showAction("action.account.saving", "accountsave");
//sól KDF konta (wymagana; nie zmienia się przy zmianie hasła) let newPassHash = await hashSHA3_512(newPass);
let saltRes = await fetchAsync(`${url}/kdfsalt?id=${window.id}`);
if (!saltRes || !saltRes.startsWith("P:")) {
clearAction("accountsave");
showBlahNotification("error:kdf.salt.missing");
return;
}
let newPassHash = await deriveCredentialKdf(newPass, saltRes.substring(2).trim());
let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass);
let payload = JSON.stringify({ let payload = JSON.stringify({
@ -5087,91 +4970,6 @@ if (cropperContainer) {
}).observe(cropperContainer); }).observe(cropperContainer);
} }
//Usuwa pokój z cache listy (dms/groups). Zwraca true jeśli coś usunięto.
function removeRoomFromCachedList(roomId, type) {
try {
roomId = String(roomId);
if (type === 'groups') {
if (!window.cachedGroups) return false;
let parsed = JSON.parse(window.cachedGroups);
let removed = false;
if (parsed.groups && parsed.groups[roomId]) {
delete parsed.groups[roomId];
removed = true;
}
//fallback: wpis może być zapisany z domena albo bez
if (!removed && parsed.groups && !roomId.includes(';')) {
for (let key in parsed.groups) {
if (key.split(';')[0] === roomId) {
delete parsed.groups[key];
removed = true;
break;
}
}
}
if (removed) window.cachedGroups = JSON.stringify(parsed);
return removed;
} else {
if (!window.cachedDms) return false;
let parsed = JSON.parse(window.cachedDms);
if (parsed.dms && parsed.dms[roomId]) {
delete parsed.dms[roomId];
window.cachedDms = JSON.stringify(parsed);
return true;
}
}
} catch (e) {
console.error(e);
}
return false;
}
function removeRoomButton(roomId, type) {
let btn = document.getElementById(`${type === 'groups' ? 'group' : 'dm'}-btn-${sanitizeId(roomId)}`);
if (btn) btn.remove();
}
//Serwer informuje, że użytkownik został usunięty z grupy (kick) -
//usuń grupę z listy, a jeśli jest otwarta, wyjdź z ekranu rozmowy.
async function handleGroupRemoved(groupId) {
try {
removeRoomFromCachedList(groupId, 'groups');
removeRoomButton(sanitizeId(groupId), 'groups');
if (currentDmId === groupId || (currentDmId && currentDmId.split(';')[0] === groupId.split(';')[0] && window.currentRoomType === 'groups')) {
await handleLostRoomAccess(groupId, 'groups', "info:removed.from.group");
}
if (typeof refreshGroups === 'function') refreshGroups(0, false);
} catch (e) {
console.error(e);
}
}
//Pokój do którego straciliśmy dostęp: usuń go z listy i wyjdź z ekranu rozmowy.
async function handleLostRoomAccess(roomId, type = null, notifyKey = null) {
if (!type) type = window.currentRoomType === 'groups' ? 'groups' : 'dms';
let wasOpen = currentDmId === roomId;
removeRoomFromCachedList(roomId, type);
removeRoomButton(sanitizeId(roomId), type);
if (typeof refreshDms === 'function') refreshDms(0, false);
if (typeof refreshGroups === 'function') refreshGroups(0, false);
if (wasOpen) {
showBlahNotification(notifyKey || (type === 'groups' ? "error:not.a.member" : "error:forbidden"));
if (dmMessagePollInterval) {
clearInterval(dmMessagePollInterval);
dmMessagePollInterval = null;
}
currentDmId = null;
window.currentRoomType = null;
if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details');
}
gotoHome();
}
}
function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) { function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
try { try {
if (type === 'groups') { if (type === 'groups') {
@ -5428,9 +5226,6 @@ function handleMemberContextMenu(e, groupId, memberId) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
groupId = sanitizeId(groupId);
memberId = sanitizeId(memberId);
if (e && e.type === "contextmenu") { if (e && e.type === "contextmenu") {
touchContextMenuFired = true; touchContextMenuFired = true;
setTimeout(() => { touchContextMenuFired = false; }, 500); setTimeout(() => { touchContextMenuFired = false; }, 500);

View file

@ -774,7 +774,7 @@ space{
border-radius: 0.8rem; border-radius: 0.8rem;
transform: translateY(calc( ( var(--icon-button-height) + (var(--button-margin) * 2) ) / 2 - 0.6rem)); transform: translateY(calc( ( var(--icon-button-height) + (var(--button-margin) * 2) ) / 2 - 0.6rem));
} }
button:hover, input:not([type="range"]):hover, textarea:hover { button:hover, input:hover, textarea:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
.submit-button:hover, .profile-action-btn:hover { .submit-button:hover, .profile-action-btn:hover {
@ -791,11 +791,7 @@ space{
filter: brightness(0.93); filter: brightness(0.93);
} }
} }
/* Suwaki ([type=range]) są wyłączone z efektu klikania całego pola - button:active, button.is-pressed, input:active, input.is-pressed, textarea:active, textarea.is-pressed {
przytrzymanie skaluje tylko uchwyt (patrz ::-webkit-slider-thumb:active). */
button:active, button.is-pressed,
input:not([type="range"]):active, input:not([type="range"]).is-pressed,
textarea:active, textarea.is-pressed {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
transform: var(--press-scale); transform: var(--press-scale);
} }
@ -1025,18 +1021,15 @@ button.profile-action-btn.popup-btn-danger:hover, button.submit-button.popup-btn
height: auto; height: auto;
} }
/* Custom Zoom Slider Styling */
input[type="range"] { #cropper-zoom,
.voice-setting-row input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
display: block;
margin: 0;
padding: 0.4rem 0.05rem;
border: none;
width: 100%; width: 100%;
height: 0.375rem; height: 0.375rem;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border-radius: 0.34rem; border-radius: 0.1875rem;
outline: none; outline: none;
} }
@ -1044,7 +1037,8 @@ input[type="range"] {
margin: 0.625rem 0; margin: 0.625rem 0;
} }
input[type="range"]::-webkit-slider-thumb { #cropper-zoom::-webkit-slider-thumb,
.voice-setting-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 1rem; width: 1rem;
@ -1055,16 +1049,14 @@ input[type="range"]::-webkit-slider-thumb {
transition: transform 0.2s, background 0.2s; transition: transform 0.2s, background 0.2s;
} }
input[type="range"]::-webkit-slider-thumb:hover { #cropper-zoom::-webkit-slider-thumb:hover,
transform: scale(1.08); .voice-setting-row input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: #fff; background: #fff;
} }
input[type="range"]::-webkit-slider-thumb:active { #cropper-zoom::-moz-range-thumb,
transform: scale(0.87); .voice-setting-row input[type="range"]::-moz-range-thumb {
}
input[type="range"]::-moz-range-thumb {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
border-radius: 50%; border-radius: 50%;
@ -1074,15 +1066,12 @@ input[type="range"]::-moz-range-thumb {
border: none; border: none;
} }
input[type="range"]::-moz-range-thumb:hover { #cropper-zoom::-moz-range-thumb:hover,
transform: scale(1.08); .voice-setting-row input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.2);
background: #fff; background: #fff;
} }
input[type="range"]::-moz-range-thumb:active {
transform: scale(0.87);
}
roomcontent roomtopbar .room-action-pin, roomcontent roomtopbar .room-action-pin,
roomcontent roomtopbar .room-action-call { roomcontent roomtopbar .room-action-call {
display: flex; display: flex;
@ -1429,16 +1418,10 @@ input:checked + .toggle-slider:before {
.voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; } .voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; }
.voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; } .voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; }
/*
* Kafelki rozmowy - wszystkie mają TEN SAM rozmiar (kwadrat, --voice-tile-size
* ustawiany przez relayoutVoiceTiles() w voice.js). Kafelki płyną obok siebie,
* a gdy się nie mieszczą w rzędzie, flex-wrap przenosi je do następnego.
*/
.voice-tile { .voice-tile {
position: relative; position: relative;
width: var(--voice-tile-size, 9rem); width: var(--voice-tile-w, 9rem);
height: var(--voice-tile-size, 9rem); height: var(--voice-tile-h, 8rem);
flex: 0 0 auto;
background: var(--light-border-color); background: var(--light-border-color);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@ -1724,6 +1707,13 @@ input:checked + .toggle-slider:before {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.voice-setting-row input[type="range"] {
margin: 0;
width: 100%;
appearance: none;
-webkit-appearance: none;
outline: none;
}
/* In-call Voice Device Settings Popup */ /* In-call Voice Device Settings Popup */
#voice-device-settings { #voice-device-settings {
@ -1892,10 +1882,10 @@ input:checked + .toggle-slider:before {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 0.125rem; width: 2px;
margin-left: -0.0625rem; margin-left: -1px;
background: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.65);
border-radius: 0.0625rem; border-radius: 1px;
pointer-events: none; pointer-events: none;
} }
/* Settings button keeps its hover look while the settings popup is open */ /* Settings button keeps its hover look while the settings popup is open */

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.2.0"; const CLIENT_VERSION = "0.1.4";

View file

@ -3,99 +3,10 @@ let currentVoiceCallId = null;
let currentVoiceCallType = null; let currentVoiceCallType = null;
let voiceWsHeartbeat = null; let voiceWsHeartbeat = null;
let voiceActiveUsers = []; let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceAesKeyBytes = null;
let voiceAesCryptoKey = null;
let voiceUiContainer = null; let voiceUiContainer = null;
// === Gwarantowana unikalność IV (AES-GCM): ===
// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId)
// -> kolizja między nadawcami niemożliwa z konstrukcji,
// 2) IV(12B) = epoka(4B = SHA-256(ticketPublicId)[0..4]) || licznik(8B BE, ściśle rosnący)
// -> kolizja w ramach jednego nadawcy niemożliwa w obrębie epoki; każde dołączenie
// do calla ma unikalny ticketPublicId (single-use po stronie serwera), a licznik
// może się resetować TYLKO razem ze zmianą klucza root.
let voiceMySenderCryptoKey = null; // mój podklucz nadawcy (CryptoKey)
const voicePeerSenderKeys = new Map(); // clientIdStr -> Promise<CryptoKey> odbiorczy
let voiceSendEpoch = null; // Uint8Array(4)
let voiceSendCounter = 0n; // BigInt, monotoniczny w ramach epoki
//Promise rozstrzygany gdy operacja na kluczu root (ustawienie/wyczyszczenie)
//jest gotowa - pozwala innym kodom czekać zamiast wchodzić w wyścig z derivacją.
let voiceRootKeyReady = Promise.resolve();
const VOICE_HKDF_INFO_PREFIX = "larpix:voice:v1:";
function voiceHkdfInfoBytes(senderIdStr) {
const prefix = new TextEncoder().encode(VOICE_HKDF_INFO_PREFIX);
const idBytes = new TextEncoder().encode(String(senderIdStr));
const out = new Uint8Array(prefix.length + idBytes.length);
out.set(prefix, 0);
out.set(idBytes, prefix.length);
return out;
}
async function deriveVoiceSenderKey(rootBytes, senderIdStr) {
const ikm = await crypto.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: voiceHkdfInfoBytes(senderIdStr)},
ikm, 256
);
return await crypto.subtle.importKey("raw", new Uint8Array(bits), {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
}
//Jedyny punkt zmiany klucza root. Reset licznika jest poprawny wyłącznie tutaj -
//nowy root = świeża przestrzeń IV. Null czyści cały stan.
function setVoiceRootKey(bytesOrNull) {
const done = (async () => {
if (!bytesOrNull) {
voiceAesKeyBytes = null;
voiceMySenderCryptoKey = null;
voicePeerSenderKeys.clear();
return;
}
voicePeerSenderKeys.clear();
const k = await deriveVoiceSenderKey(bytesOrNull, String(window.id));
voiceAesKeyBytes = bytesOrNull;
voiceMySenderCryptoKey = k;
})();
voiceRootKeyReady = done;
return done;
}
//Epoka z unikalnego per-dołączenie ticketPublicId - deterministycznie, bez dodatkowego losowania.
function setVoiceEpochFromTicket(publicIdB64u) {
crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(publicIdB64u))).then(d => {
voiceSendEpoch = new Uint8Array(d).slice(0, 4);
voiceSendCounter = 0n;
}).catch(() => { voiceSendEpoch = null; voiceSendCounter = 0n; });
}
//Jedynne źródło IV do wysyłki. Inkrementacja synchroniczna (single-thread JS = atomiczne).
function nextVoiceIv() {
if (!voiceSendEpoch) throw new Error("voice epoch not ready");
voiceSendCounter += 1n;
if (voiceSendCounter >= (1n << 63n)) throw new Error("voice iv counter exhausted"); //praktycznie nieosiągalne
const iv = new Uint8Array(12);
iv.set(voiceSendEpoch, 0);
let c = voiceSendCounter;
for (let i = 0; i < 8; i++) {
iv[11 - i] = Number(c & 0xffn);
c >>= 8n;
}
return iv;
}
//Podklucz odbiorczy danego nadawcy (cache). clientId to dziesiętny string ulonga -
//dokładnie ta sama forma, której nadawca użył przy derivacji własnego klucza.
function getVoicePeerSenderKey(clientIdStr) {
let p = voicePeerSenderKeys.get(clientIdStr);
if (!p && voiceAesKeyBytes) {
p = deriveVoiceSenderKey(voiceAesKeyBytes, clientIdStr);
voicePeerSenderKeys.set(clientIdStr, p);
}
return p || null;
}
let micStream = null, audioContext = null, isAudioWorkletLoaded = false; let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
@ -128,8 +39,6 @@ let peerLastSeen = {};
let deadPeers = new Set(); let deadPeers = new Set();
let lastVoiceServerPacketAt = 0; let lastVoiceServerPacketAt = 0;
let voicePingTimer = null; let voicePingTimer = null;
let voiceRosterRetryTimer = null;
let lastVoiceKeyRequestAt = 0;
let voiceReconnectAttempts = 0; let voiceReconnectAttempts = 0;
let voiceSocketOpenedAt = 0; let voiceSocketOpenedAt = 0;
@ -152,9 +61,9 @@ function cleanupPeerMedia(userId) {
} }
function sendVoicePing() { function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
@ -621,16 +530,6 @@ setInterval(() => {
} else if (audioContext && audioContext.state === 'suspended') { } else if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().catch(() => {}); audioContext.resume().catch(() => {});
} }
// Safety net for the call-key handshake: if others are in the call but
// we never received the root key (raced/lost signal), ask for it
// instead of staying deaf until someone refreshes the page.
if (others > 0 && !voiceAesKeyBytes && now - lastVoiceKeyRequestAt > 4000) {
lastVoiceKeyRequestAt = now;
console.debug("Voice: requesting call key from peers");
for (const u of voiceActiveUsers) {
if (u !== String(window.id)) sendVoiceSignalToUser(u, currentVoiceCallId, true);
}
}
} }
if (changed) updateVoiceUIUsers(); if (changed) updateVoiceUIUsers();
}, 2000); }, 2000);
@ -783,8 +682,6 @@ async function requestVoiceJoin() {
let serverUrl = parts[0]; let serverUrl = parts[0];
const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
//epoka IV z unikalnego (single-use) publicId - każde dołączenie = rozłączna przestrzeń IV
setVoiceEpochFromTicket(publicId);
if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
} }
@ -883,21 +780,7 @@ async function startVoiceSession(serverUrl, ticket) {
} catch (e) {} } catch (e) {}
}; };
fetchUsers(); fetchUsers();
//The voice-server roster is joined only AFTER ticket validation (an voiceWsHeartbeat = setInterval(fetchUsers, 5000);
//HTTP roundtrip to the main server), so the immediate fetch can still
//miss us - retry quickly until our own tile shows up instead of
//staring at an empty call for the first heartbeat.
if (voiceRosterRetryTimer) clearInterval(voiceRosterRetryTimer);
let rosterTries = 0;
voiceRosterRetryTimer = setInterval(() => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN ||
voiceActiveUsers.includes(String(window.id)) || ++rosterTries > 12) {
clearInterval(voiceRosterRetryTimer);
voiceRosterRetryTimer = null;
return;
}
fetchUsers();
}, 400);
voiceReconnectAttempts = 0; voiceReconnectAttempts = 0;
lastVoiceServerPacketAt = Date.now(); lastVoiceServerPacketAt = Date.now();
@ -964,9 +847,9 @@ async function startVoiceSession(serverUrl, ticket) {
if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)';
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceMySenderCryptoKey) return; if (!voiceAesCryptoKey) return;
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
@ -1003,10 +886,7 @@ async function startVoiceSession(serverUrl, ticket) {
const iv = new Uint8Array(arrayBuffer, 10, 12); const iv = new Uint8Array(arrayBuffer, 10, 12);
const ciphertext = new Uint8Array(arrayBuffer, 22); const ciphertext = new Uint8Array(arrayBuffer, 22);
if (!voiceAesKeyBytes) return; if (!voiceAesCryptoKey) return;
//podklucz nadawcy - deterministyczny z (root, clientId)
const senderKeyPromise = getVoicePeerSenderKey(clientId);
if (type === 0 || type === 4) { if (type === 0 || type === 4) {
const isScreenAudio = (type === 4); const isScreenAudio = (type === 4);
@ -1019,9 +899,7 @@ async function startVoiceSession(serverUrl, ticket) {
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode; const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
audioDecryptChains[audioId].enqueue(async () => { audioDecryptChains[audioId].enqueue(async () => {
try { try {
const senderKey = await senderKeyPromise; const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if (!senderKey) return;
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
@ -1033,9 +911,7 @@ async function startVoiceSession(serverUrl, ticket) {
videoDecryptChains[decoderId].enqueue(async () => { videoDecryptChains[decoderId].enqueue(async () => {
try { try {
const senderKey = await senderKeyPromise; const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if (!senderKey) return;
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if (!remoteVideos[decoderId]) { if (!remoteVideos[decoderId]) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -1095,12 +971,8 @@ async function startVoiceSession(serverUrl, ticket) {
} catch (err) {} } catch (err) {}
}); });
} else if (type === 3) { } else if (type === 3) {
(async () => { crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => {
try { const stoppedTypeId = new Uint8Array(decrypted)[0];
const senderKey = await senderKeyPromise;
if (!senderKey) return;
const decrypted = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
const stoppedTypeId = new Uint8Array(decrypted)[0];
if (stoppedTypeId === 4) { if (stoppedTypeId === 4) {
const audioId = `${clientId}_screen`; const audioId = `${clientId}_screen`;
if (remoteAudio[audioId]) { if (remoteAudio[audioId]) {
@ -1117,8 +989,7 @@ async function startVoiceSession(serverUrl, ticket) {
updateVoiceUIUsers(); updateVoiceUIUsers();
} }
} }
} catch (err) {} }).catch(() => {});
})();
} }
}; };
@ -1150,12 +1021,9 @@ async function startVoiceSession(serverUrl, ticket) {
} }
} }
async function handleCallUpdate(data) { function handleCallUpdate(data) {
let parts = data.split(":"); let parts = data.split(":");
let roomId = parts[1]; let roomId = parts[1];
//Ignore updates for rooms we have nothing to do with - they would otherwise
//overwrite the participant list of an ongoing call and wipe its tiles.
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
let state = []; let state = [];
if(parts.slice(2).join(":") !== "") { if(parts.slice(2).join(":") !== "") {
let stateRaw = JSON.parse(parts.slice(2).join(":")); let stateRaw = JSON.parse(parts.slice(2).join(":"));
@ -1166,13 +1034,10 @@ async function handleCallUpdate(data) {
if (currentVoiceCallId === roomId) { if (currentVoiceCallId === roomId) {
let myIdStr = String(window.id); let myIdStr = String(window.id);
if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) { if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) {
setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32))); voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"])
.then(k => voiceAesCryptoKey = k);
} }
//The root key is set asynchronously - wait for it before distributing.
//Otherwise a fast second join produces [me,peer] while the derivation
//is still running, no signal is sent and the peer never gets the key
//(audio stayed broken for everyone until someone refreshed the page).
await voiceRootKeyReady;
if (voiceAesKeyBytes) { if (voiceAesKeyBytes) {
for (let u of state) { for (let u of state) {
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
@ -1255,7 +1120,7 @@ async function resolveCallerInfo(roomId) {
} }
function showIncomingCallUI(roomId) { function showIncomingCallUI(roomId) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
@ -1309,24 +1174,18 @@ function joinIncomingCall(roomId) {
initiateVoiceCall(roomId); initiateVoiceCall(roomId);
} }
async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) { async function sendVoiceSignalToUser(targetUserId, roomId) {
try { try {
let setupPayloadJson; let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`);
if (!requestData && voiceAesKeyBytes) { let publicKeys = JSON.parse(publicKeysRaw);
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`); let hybrid = await deriveHybridKeyToUser(publicKeys);
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes); let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes);
setupPayloadJson = JSON.stringify({ let setupPayloadJson = JSON.stringify({
v: 1, v: 1,
ctMlKem: hybrid.ciphertextMlKemBase64, ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember enc: encForMember
}); });
} else {
//Prośba o klucz rozmowy (bez sekretów) - adresat z kluczem odpowie pełnym sygnałem.
setupPayloadJson = JSON.stringify({ v: 1, needKey: true });
}
let payload = JSON.stringify({ let payload = JSON.stringify({
string1: currentVoiceCallType, string1: currentVoiceCallType,
@ -1345,22 +1204,15 @@ async function handleRoomSignal(data) {
let signalStr = parts.slice(2).join(":"); let signalStr = parts.slice(2).join(":");
let signalObj = JSON.parse(signalStr); let signalObj = JSON.parse(signalStr);
if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) { if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) {
try { try {
let setupData = JSON.parse(signalObj.data); let setupData = JSON.parse(signalObj.data);
if (setupData.needKey) { let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
//Peer nie dostał klucza rozmowy (wygryziony/zagubiony sygnał) - let otherPublicKeysObj = JSON.parse(publicKeysRaw);
//odeślij mu go, ale tylko jeśli sami już go mamy. let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
if (voiceAesKeyBytes) await sendVoiceSignalToUser(signalObj.from, roomId); let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
return; voiceAesKeyBytes = base64ToUint8(decRoomKeyB64);
} voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
if (!voiceAesKeyBytes) {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
let otherPublicKeysObj = JSON.parse(publicKeysRaw);
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
}
} catch(e) { } catch(e) {
console.error("Signal decryption failed", e); console.error("Signal decryption failed", e);
} }
@ -2186,12 +2038,6 @@ function ensureVoiceGridObserver() {
voiceGridRO.observe(container); voiceGridRO.observe(container);
} }
/*
* Wszystkie kafelki to kwadraty o wspólnym rozmiarze --voice-tile-size.
* Wybieramy największy rozmiar kwadratu, przy którym n kafelków mieści się
* w oknie rozmowy - układanie i zawijanie do następnych rzędów robi sam
* flex-wrap w CSS (justify-content: center).
*/
function relayoutVoiceTiles() { function relayoutVoiceTiles() {
let container = document.getElementById("voice-users"); let container = document.getElementById("voice-users");
if (!container || !container.isConnected) return; if (!container || !container.isConnected) return;
@ -2206,34 +2052,28 @@ function relayoutVoiceTiles() {
const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) { if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) {
container.style.removeProperty("--voice-tile-size"); container.style.removeProperty("--voice-tile-w");
container.style.removeProperty("--voice-tile-h");
delete container.dataset.layoutSig; delete container.dataset.layoutSig;
return; return;
} }
const minCol = 6.25 * rootFs;
const minRow = 7 * rootFs;
const gap = 0.75 * rootFs; const gap = 0.75 * rootFs;
const maxSize = 18 * rootFs; // jedna osoba nie dostanie gigantycznego kafelka
const minSize = 4.5 * rootFs; // poniżej tego wolimy scrollować niż spłaszczać
//Największy kwadrat, przy którym n kafelków mieści się w dostępnej powierzchni: const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap)));
//próbujemy liczby kolumn 1..n, dla każdej wyliczamy ograniczony rozmiar const cols = Math.min(n, maxCols);
//(szerokość kolumny vs wysokość rzędu) i bierzemy najlepszy wariant. const rows = Math.ceil(n / cols);
let best = 0; const w = Math.floor((innerW - gap * (cols - 1)) / cols);
for (let cols = 1; cols <= n; cols++) { const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows));
const rows = Math.ceil(n / cols);
const s = Math.min(
(innerW - gap * (cols - 1)) / cols,
(innerH - gap * (rows - 1)) / rows
);
if (s > best) best = s;
}
const size = Math.min(maxSize, Math.max(minSize, Math.floor(best))); const sig = `${n}:${cols}:${w}x${h}`;
const sig = `${n}:${size}`;
if (container.dataset.layoutSig === sig) return; if (container.dataset.layoutSig === sig) return;
container.dataset.layoutSig = sig; container.dataset.layoutSig = sig;
container.style.setProperty("--voice-tile-size", size + "px"); container.style.setProperty("--voice-tile-w", w + "px");
container.style.setProperty("--voice-tile-h", h + "px");
} }
let voiceSettingsState = null; let voiceSettingsState = null;
@ -2339,16 +2179,10 @@ function teardownVoiceTransport() {
audioDecryptChains = {}; audioDecryptChains = {};
peerLastSeen = {}; peerLastSeen = {};
deadPeers.clear(); deadPeers.clear();
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
lastVoiceKeyRequestAt = 0;
//Stale participants from the previous attempt must not linger in the UI
voiceActiveUsers = [];
updateVoiceUIUsers();
if (receiverNode) receiverNode.port.postMessage({action: 'clear'}); if (receiverNode) receiverNode.port.postMessage({action: 'clear'});
if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'}); if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'});
setVoiceRootKey(null); voiceAesKeyBytes = null;
voiceSendEpoch = null; voiceAesCryptoKey = null;
voiceSendCounter = 0n;
lastVoiceServerPacketAt = Date.now(); lastVoiceServerPacketAt = Date.now();
} }
@ -2409,11 +2243,8 @@ function endVoiceSession() {
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
peerLastSeen = {}; peerLastSeen = {};
deadPeers.clear(); deadPeers.clear();
lastVoiceKeyRequestAt = 0;
voiceActiveUsers = [];
for (let id in audioDecoders) { for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){} try { audioDecoders[id].close(); } catch(e){}
@ -2444,9 +2275,8 @@ function endVoiceSession() {
remoteVideos = {}; remoteVideos = {};
videoDecryptChains = {}; videoDecryptChains = {};
setVoiceRootKey(null); voiceAesKeyBytes = null;
voiceSendEpoch = null; voiceAesCryptoKey = null;
voiceSendCounter = 0n;
currentVoiceWs = null; currentVoiceWs = null;
currentVoiceCallType = null; currentVoiceCallType = null;
currentVoiceCallId = null; currentVoiceCallId = null;
@ -2492,11 +2322,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
screenAudioSourceNode = audioContext.createMediaStreamSource(stream); screenAudioSourceNode = audioContext.createMediaStreamSource(stream);
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
screenSenderNode.port.onmessage = async (e) => { screenSenderNode.port.onmessage = async (e) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
// Same backpressure valve as the mic sender. // Same backpressure valve as the mic sender.
if (currentVoiceWs.bufferedAmount > 256 * 1024) return; if (currentVoiceWs.bufferedAmount > 256 * 1024) return;
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
@ -2527,10 +2357,10 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
const encoder = new VideoEncoder({ const encoder = new VideoEncoder({
output: async (chunk) => { output: async (chunk) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer); const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer);
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
@ -2582,9 +2412,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
updateVoiceUIUsers(); updateVoiceUIUsers();
btnEl.classList.remove('voice-btn-active'); btnEl.classList.remove('voice-btn-active');
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
const buffer = new Uint8Array([typeId]); const iv = nextVoiceIv(); const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer);
} }
@ -2593,9 +2423,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; } if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; }
if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; } if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; }
if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; } if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; }
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv(); const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12));
const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo); const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo);
const payloadA = new Uint8Array(14 + cipherA.byteLength); const payloadA = new Uint8Array(14 + cipherA.byteLength);
payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer); payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer);
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "Miarven", "name": "Miarven",
"version": "0.2.0", "version": "0.1.4",
"description": "An Amazing Capacitor App", "description": "An Amazing Capacitor App",
"author": { "author": {
"name": "", "name": "",

View file

@ -1,6 +1,6 @@
{ {
"name": "olcxja.miarven", "name": "olcxja.miarven",
"version": "0.2.0", "version": "0.1.4",
"description": "Larpix client", "description": "Larpix client",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": "olcxja's softworks", "author": "olcxja's softworks",

View file

@ -1,5 +1,5 @@
{ {
"versionCode": 6, "versionCode": 5,
"versionName": "0.2.0", "versionName": "0.1.4",
"npmVersion": "0.2.0" "npmVersion": "0.1.4"
} }

View file

@ -233,7 +233,6 @@
"message.reaction.removed": "Purreaction remeowed", "message.reaction.removed": "Purreaction remeowed",
"no.invite": "No meowvite", "no.invite": "No meowvite",
"not.a.member": "Not a meowmber", "not.a.member": "Not a meowmber",
"removed.from.group": "You got hissed out of the clowder",
"not.in.group": "Not in purroup", "not.in.group": "Not in purroup",
"reaction.not.found": "Purreaction not meownd", "reaction.not.found": "Purreaction not meownd",
"rotation.not.required": "Purrotation not meowquired", "rotation.not.required": "Purrotation not meowquired",

View file

@ -233,7 +233,6 @@
"message.reaction.removed": "Reaction removed", "message.reaction.removed": "Reaction removed",
"no.invite": "No invite", "no.invite": "No invite",
"not.a.member": "Not a member", "not.a.member": "Not a member",
"removed.from.group": "You were removed from the group",
"not.in.group": "Not in group", "not.in.group": "Not in group",
"reaction.not.found": "Reaction not found", "reaction.not.found": "Reaction not found",
"rotation.not.required": "Rotation not required", "rotation.not.required": "Rotation not required",

View file

@ -231,7 +231,6 @@
"message.reaction.removed": "Reakcja usunięta", "message.reaction.removed": "Reakcja usunięta",
"no.invite": "Brak zaproszenia", "no.invite": "Brak zaproszenia",
"not.a.member": "Nie jesteś członkiem", "not.a.member": "Nie jesteś członkiem",
"removed.from.group": "Zostałeś usunięty z grupy",
"not.in.group": "Nie jesteś w grupie", "not.in.group": "Nie jesteś w grupie",
"reaction.not.found": "Nie znaleziono reakcji", "reaction.not.found": "Nie znaleziono reakcji",
"rotation.not.required": "Rotacja niewymagana", "rotation.not.required": "Rotacja niewymagana",

View file

@ -285,7 +285,7 @@
const registerHost = document.getElementById("reg-host"); const registerHost = document.getElementById("reg-host");
const registerUsername = document.getElementById("reg-username"); const registerUsername = document.getElementById("reg-username");
const registerPassword = document.getElementById("reg-password"); const registerPassword = document.getElementById("reg-password");
const registerPasswordConfirm = document.getElementById("reg-confirm"); const registerPasswordConfirm = document.getElementById("reg-password");
const loginHost = document.getElementById("login-host"); const loginHost = document.getElementById("login-host");
const loginUsername = document.getElementById("login-username"); const loginUsername = document.getElementById("login-username");
const loginPassword = document.getElementById("login-password"); const loginPassword = document.getElementById("login-password");
@ -382,13 +382,6 @@
var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]); var sharedkey = await calcHybridSharedKeyClient(dataarray[0], dataarray[1]);
sharedpvkey = sharedkey[2]; sharedpvkey = sharedkey[2];
createId = dataarray[2]; createId = dataarray[2];
//sól z init jest wymagana - hasło zawsze przez PBKDF2
if (!dataarray[3]) {
showBlahNotification("error:something.wrong");
authContainer.className = 'auth-container show-register';
return;
}
let regCredHash = await deriveCredentialKdf(registerPassword.value, dataarray[3]);
const captchaimage = await fetch(`${url}/createaccount?step=register`, { const captchaimage = await fetch(`${url}/createaccount?step=register`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@ -396,7 +389,7 @@
ciphertextMlKem: sharedkey[1], ciphertextMlKem: sharedkey[1],
idKey: createId, idKey: createId,
username: await encrypt(registerUsername.value, sharedpvkey), username: await encrypt(registerUsername.value, sharedpvkey),
password: await encrypt(regCredHash, sharedpvkey) password: await encrypt(await hashSHA3_512(registerPassword.value), sharedpvkey)
}) })
}); });
const clone = captchaimage.clone(); const clone = captchaimage.clone();

View file

@ -83,9 +83,7 @@ async function packetEncPass(pass, key, accountId) {
async function getNonce(accountId, key) { async function getNonce(accountId, key) {
let nonce; let nonce;
let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`, { let fetchRes = await (await fetch(`${url}/nextnonce?id=${accountId}`)).text();
headers: {"X-Crypto": "g1"}
})).text();
try { try {
nonce = await decryptString(fetchRes, key); nonce = await decryptString(fetchRes, key);
@ -125,8 +123,7 @@ async function calcHybridSharedKeyClient(pubX25519ServerBase64, pubMlKemServerBa
function keyDataFromServerJson(jsonFromServer) { function keyDataFromServerJson(jsonFromServer) {
const data = JSON.parse(jsonFromServer); const data = JSON.parse(jsonFromServer);
//0=pubX25519, 1=pubMlKem, 2=idKey, 3=kdfSalt (wymagany do PBKDF2 przy rejestracji) return [data.pubX25519, data.pubMlKem, data.idKey]
return [data.pubX25519, data.pubMlKem, data.idKey, data.kdfSalt]
} }
function base64ToUint8(base64) { function base64ToUint8(base64) {
@ -708,20 +705,25 @@ async function decryptBytes(combinedBytes, passphrase) {
async function encryptString(plainText, passphrase) { async function encryptString(plainText, passphrase) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(plainText);
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
); );
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = new Uint8Array(await crypto.subtle.encrypt( const encrypted = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: iv}, {name: 'AES-CBC', iv: iv},
key, key,
encoder.encode(plainText) data
)); );
return "G1:" + uint8ToBase64(concatUint8(iv, encrypted)); const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return uint8ToBase64(combined);
} }
async function encryptBytesWithNonce(dataUint8, key, nonce) { async function encryptBytesWithNonce(dataUint8, key, nonce) {
@ -730,44 +732,37 @@ async function encryptBytesWithNonce(dataUint8, key, nonce) {
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const importedKey = await crypto.subtle.importKey( const importedKey = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['encrypt'] 'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
); );
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = new Uint8Array(await crypto.subtle.encrypt( const encrypted = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv: iv}, {name: 'AES-CBC', iv: iv},
importedKey, importedKey,
dataUint8 dataUint8
)); );
const prefix = encoder.encode("G1:"); const combined = new Uint8Array(iv.length + encrypted.byteLength);
const combined = new Uint8Array(prefix.length + iv.length + encrypted.byteLength); combined.set(iv);
combined.set(prefix, 0); combined.set(new Uint8Array(encrypted), iv.length);
combined.set(iv, prefix.length);
combined.set(encrypted, prefix.length + iv.length);
return combined; return combined;
} }
async function decryptString(base64Text, passphrase) { async function decryptString(base64Text, passphrase) {
//tylko AES-GCM ("G1:") - pakiety bez prefiksu odrzucane
if (typeof base64Text !== "string" || !base64Text.startsWith("G1:")) {
throw new Error("error:invalid.cipher.format");
}
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const combined = base64ToUint8(base64Text.substring(3)); const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0)));
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase)); const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-GCM'}, false, ['decrypt'] 'raw', pwHash, {name: 'AES-CBC'}, false, ['decrypt']
); );
const iv = combined.slice(0, 12); const iv = combined.slice(0, 16);
const data = combined.slice(12); const data = combined.slice(16);
const decrypted = await crypto.subtle.decrypt( const decrypted = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: iv}, {name: 'AES-CBC', iv: iv},
key, key,
data data
); );
@ -789,7 +784,6 @@ async function fetchPost(url, value) {
method: "POST", method: "POST",
body: value, body: value,
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash))
} }
}); });
@ -812,7 +806,6 @@ async function fetchPostEnc(url, value) {
method: "POST", method: "POST",
body: await encryptWithNonce(value, passwordHash, nonce), body: await encryptWithNonce(value, passwordHash, nonce),
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce) "secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
} }
}); });
@ -842,7 +835,6 @@ async function fetchAsyncWAuth(url) {
let response = await fetch(url, { let response = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash)) "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(id, passwordHash))
} }
}); });
@ -872,30 +864,17 @@ async function Auth(loginUsername, loginPassword) {
} }
let actualId = resolvedId.split(":")[0]; let actualId = resolvedId.split(":")[0];
//sól KDF jest wymagana - konta bez soli nie mogą się zalogować let passwordHash = await hashSHA3_512(loginPassword);
let saltRes = await fetchAsync(`${url}/kdfsalt?id=${actualId}`);
if (!saltRes || !saltRes.startsWith("P:")) {
return "error:invalid.username.or.password";
}
let credHash = await deriveCredentialKdf(loginPassword, saltRes.substring(2).trim());
let response = await fetch(`${url}/auth?id=${actualId}`, { let response = await fetch(`${url}/auth?id=${actualId}`, {
method: "GET", method: "GET",
headers: { headers: {
"X-Crypto": "g1", "secret": await encryptWithNonce(passwordHash, passwordHash, await getNonce(actualId, passwordHash))
"secret": await encryptWithNonce(credHash, credHash, await getNonce(actualId, credHash))
} }
}); });
let data = await response.text(); let data = await response.text();
if (data.startsWith("success:")) { if (data.startsWith("success:")) {
data += "|" + actualId; data += "|" + actualId;
//utrwal globale sesyjne - dalsze żądania w tej sesji mają od razu ważny hash
passwordHash = credHash;
if (!id) {
id = actualId;
try { localStorage.setItem("id", actualId); } catch (e) {}
}
} }
return data; return data;
} finally { } finally {
@ -923,7 +902,6 @@ async function fetchEncrypted(request, body = "") {
, passwordHash, nonce), , passwordHash, nonce),
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce) "secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
} }
}); });
@ -951,7 +929,6 @@ async function fetchEncryptedUpload(target, fileBytesUint8) {
method: "POST", method: "POST",
body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce), body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce),
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce), "secret": await encryptWithNonce(passwordHash, passwordHash, nonce),
"target": await encryptWithNonce(target, passwordHash, nonce) "target": await encryptWithNonce(target, passwordHash, nonce)
} }
@ -1069,35 +1046,13 @@ function clearAction(actionid) {
} }
//solony, wolny KDF - 600k iteracji PBKDF2-SHA256, wyjscie 512 bitow (128 hex) async function hashSHA3_512(input) {
async function deriveCredentialKdf(passwordPlain, saltB64) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey( const data = encoder.encode(input);
"raw", const hashBuffer = await crypto.subtle.digest('SHA-512', data); //-3 kiedys xddddddddddddddddd
encoder.encode(passwordPlain), const hashArray = Array.from(new Uint8Array(hashBuffer));
{name: "PBKDF2"}, const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
false, return hashHex;
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{name: "PBKDF2", hash: "SHA-256", salt: base64ToUint8(saltB64), iterations: 600000},
keyMaterial,
512
);
return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
}
//zwraca null gdy brak konta/soli - celowo NIE rzuca, żeby nie wywalać bootu aplikacji
//(strona logowania i świeży użytkownik nie mają soli; start() sam zrobi Auth i przekieruje)
async function resolveCredentialHash(accountId) {
if (!accountId) return null;
try {
let res = await fetchAsync(`${url}/kdfsalt?id=${accountId}`);
if (res && res.startsWith("P:") && password) {
return await deriveCredentialKdf(password, res.substring(2).trim());
}
} catch (e) {}
return null;
} }
//placeholder pfp //placeholder pfp
@ -1447,10 +1402,9 @@ async function mainJS() {
} }
await initBlahs(); await initBlahs();
//emotes.json leży w katalogu głównym - na stronie /login fetch by trafił w HTML await loadEmotes();
if (!window.location.pathname.includes("/login")) {
await loadEmotes(); passwordHash = await hashSHA3_512(password);
}
if (host) { if (host) {
await updateProtocolAndUrl(host); await updateProtocolAndUrl(host);
@ -1466,12 +1420,6 @@ async function mainJS() {
} }
} }
//hash tylko dla istniejącej sesji; brak soli/konta nie może blokować startu -
//start() i tak wykonuje własny Auth i przekieruje niezalogowanych na login
if (id && password) {
try { passwordHash = await resolveCredentialHash(id); } catch (e) { passwordHash = null; }
}
await start(); await start();
} }
@ -1925,7 +1873,6 @@ async function openProfile(accountId) {
try { try {
if (!accountId) accountId = window.id; //var fallback if (!accountId) accountId = window.id; //var fallback
accountId = sanitizeId(accountId);
if (accountId.endsWith(';' + host)) { if (accountId.endsWith(';' + host)) {
accountId = accountId.substring(0, accountId.length - host.length - 1); accountId = accountId.substring(0, accountId.length - host.length - 1);
@ -1949,7 +1896,7 @@ async function openProfile(accountId) {
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`); let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`);
if (bio.startsWith("error:")) bio = ""; if (bio.startsWith("error:")) bio = "";
let bioHtml = bio && bio.trim() !== "" ? `<div class="profile-bio">${escapeHtml(bio)}</div>` : ""; let bioHtml = bio && bio.trim() !== "" ? `<div class="profile-bio">${bio.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>` : "";
let isOwnProfile = accountId === id; let isOwnProfile = accountId === id;
let actionBtnHtml = ""; let actionBtnHtml = "";
@ -2045,8 +1992,8 @@ async function openProfile(accountId) {
</div> </div>
<div class="profile-info"> <div class="profile-info">
<div> <div>
<div class="profile-name">${escapeHtml(profileName)}</div> <div class="profile-name">${profileName}</div>
<div class="profile-id-host">${escapeHtml(profileName)}:${escapeHtml(profileHost)} (${escapeHtml(accountId.split(':')[0].split(';')[0])})</div> <div class="profile-id-host">${profileName}:${profileHost} (${accountId.split(':')[0].split(';')[0]})</div>
${roleHtml} ${roleHtml}
</div> </div>
${bioHtml} ${bioHtml}
@ -2182,7 +2129,7 @@ async function renderInvites(res, type) {
if (invite.type === "dm") { if (invite.type === "dm") {
let id = sanitizeId(invite.id); let id = invite.id;
if (!id.includes(":")) { if (!id.includes(":")) {
id += `:${host}`; id += `:${host}`;
@ -2197,7 +2144,7 @@ async function renderInvites(res, type) {
let actions = ""; let actions = "";
let desc = ""; let desc = "";
let displayName = escapeHtml(username || onlyId); let displayName = username || onlyId;
if (type === "received") { if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`; desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = ` actions = `
@ -2227,8 +2174,8 @@ async function renderInvites(res, type) {
html += entry; html += entry;
} else if (invite.type === "group") { } else if (invite.type === "group") {
let groupId = sanitizeId(invite.groupId || invite.id); let groupId = invite.groupId || invite.id;
let targetId = sanitizeId((type === "received") ? invite.sender : invite.receiver); let targetId = (type === "received") ? invite.sender : invite.receiver;
if (!targetId.includes(":")) { if (!targetId.includes(":")) {
targetId += `:${host}`; targetId += `:${host}`;
@ -2251,9 +2198,9 @@ async function renderInvites(res, type) {
let actions = ""; let actions = "";
let desc = ""; let desc = "";
let displayName = escapeHtml(username || onlyId); let displayName = username || onlyId;
let val1 = `<span onclick="openProfile('${sanitizeId(targetId)}')" style="cursor:pointer"><b>${displayName}</b></span>`; let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
let val2 = `<span onclick="openProfile('${sanitizeId(targetId)}')" style="cursor:pointer"><b>${escapeHtml(onlyId)}</b></span>`; let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
if (type === "received") { if (type === "received") {
desc = `:desc.invite.group.received:${val1};${val2}`; desc = `:desc.invite.group.received:${val1};${val2}`;
@ -2659,17 +2606,16 @@ async function renderDms(res) {
let html = ""; let html = "";
let count = 0; let count = 0;
for (let [dmIdRaw, dmData] of dmEntries) { for (let [dmId, dmData] of dmEntries) {
let dmId = sanitizeId(dmIdRaw);
let parts = dmId.split('_'); let parts = dmId.split('_');
let myId = `${id};${host}`; let myId = `${id};${host}`;
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); let targetId = parts[0] === myId ? parts[1] : parts[0];
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
let pfp = await getAvatarUrl(targetId, targetUsername); let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = escapeHtml(targetUsername.split(':')[0]); let displayName = targetUsername.split(':')[0];
let isRead = dmData.string3 === "true"; let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
@ -2683,7 +2629,7 @@ async function renderDms(res) {
<img src="${pfp}" class="room-pfp"> <img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name" style="${nameStyle}">${displayName}</span> <span class="room-name" style="${nameStyle}">${displayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
</div> </div>
${unreadBadge} ${unreadBadge}
</button> </button>
@ -2739,23 +2685,19 @@ async function renderGroups(res) {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName); let pfp = await getGroupAvatarUrl(groupId, groupName);
let safeGroupId = sanitizeId(groupId);
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`; let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += ` html += `
<button class="room-entry" id="group-btn-${safeGroupId}" onclick="openDm('${safeGroupId}', decodeURIComponent('${safeGroupName}'), '${safeGroupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${safeGroupId}')"> <button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
<img src="${pfp}" class="room-pfp"> <img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name" style="${nameStyle}">${escapeHtmlText(groupName)}</span> <span class="room-name" style="${nameStyle}">${groupName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${groupId.replace(';', ':')}</span>
</div> </div>
${unreadBadge} ${unreadBadge}
</button> </button>
`; `;
count++; count++;
} else if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
} }
} }
@ -2801,18 +2743,6 @@ function escapeHtml(unsafe) {
.replaceAll(/'/g, "&#039;"); .replaceAll(/'/g, "&#039;");
} }
//ID-y/identyfikatory wstawiane do atrybutów HTML i inline JS (onclick).
//Usuwa wszystko poza bezpiecznym zestawem znakow - nie da się wyjść z stringa JS ani atrybutu.
//Dozwolone: litery, cyfry oraz _ . ; : @ -
function sanitizeId(value) {
return String(value ?? "").replace(/[^A-Za-z0-9_.;:@-]/g, "");
}
//nazwy (grup itp.) wstawiane do treści HTML
function escapeHtmlText(value) {
return escapeHtml(String(value ?? ""));
}
async function generateDmMembersHtml(targetId, targetUsername) { async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host; let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
@ -2821,28 +2751,24 @@ async function generateDmMembersHtml(targetId, targetUsername) {
let targetPfp = await getAvatarUrl(targetId, targetUsername); let targetPfp = await getAvatarUrl(targetId, targetUsername);
let targetDisplayName = targetUsername.split(':')[0]; let targetDisplayName = targetUsername.split(':')[0];
let myDisplayName = myUsername.split(':')[0]; let myDisplayName = myUsername.split(':')[0];
let safeTargetId = sanitizeId(targetId);
let safeMyId = sanitizeId(myIdStr);
targetDisplayName = escapeHtmlText(targetDisplayName);
let myDisplaySafe = escapeHtmlText(myDisplayName);
return ` return `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;"> <div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;"> <div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span> <span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div> </div>
<button class="room-entry" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;"> <button class="room-entry" onclick="openProfile('${targetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" class="room-pfp"> <img src="${targetPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${targetDisplayName}</span> <span class="room-name">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername}</span>
</div> </div>
</button> </button>
<button class="room-entry" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;"> <button class="room-entry" onclick="openProfile('${myIdStr}')" style="margin-bottom: 0;">
<img src="${myPfp}" class="room-pfp"> <img src="${myPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${myDisplaySafe}</span> <span class="room-name">${myDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${myUsername}</span>
</div> </div>
</button> </button>
</div> </div>
@ -2959,7 +2885,7 @@ async function generateGroupMembersHtml(groupId, groupName) {
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;"> <div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span> <span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
<div class="flex-spacer"></div> <div class="flex-spacer"></div>
<button class="topbar-action-btn" onclick="inviteToGroupPrompt('${sanitizeId(groupId)}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;"> <button class="topbar-action-btn" onclick="inviteToGroupPrompt('${groupId}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;">
${ICONS.plus({ width: "1rem", height: "1rem" })} ${ICONS.plus({ width: "1rem", height: "1rem" })}
</button> </button>
</div>`; </div>`;
@ -2969,16 +2895,14 @@ async function generateGroupMembersHtml(groupId, groupName) {
if (!memberUsername || memberUsername.startsWith("error")) continue; if (!memberUsername || memberUsername.startsWith("error")) continue;
let memberPfp = await getAvatarUrl(memberId, memberUsername); let memberPfp = await getAvatarUrl(memberId, memberUsername);
let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); let memberDisplayName = memberUsername.split(':')[0];
let safeMemberId = sanitizeId(memberId);
let safeGroupId = sanitizeId(groupId);
html += ` html += `
<button class="room-entry" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;"> <button class="room-entry" onclick="openProfile('${memberId}')" oncontextmenu="handleMemberContextMenu(event, '${groupId}', '${memberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" class="room-pfp"> <img src="${memberPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${memberDisplayName}</span> <span class="room-name">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${memberUsername}</span>
</div> </div>
</button>`; </button>`;
} }
@ -3001,16 +2925,6 @@ async function openDm(dmId, username, targetId, type = 'dms') {
} }
if (type === 'dms') { if (type === 'dms') {
let keyCheck = await fetchEncrypted("dm/key/get", dmId);
if (keyCheck === "error:forbidden") {
//wpis w liscie do ktorego nie mamy dostepu - usun go i nie otwieraj rozmowy
showBlahNotification(keyCheck);
removeRoomFromCachedList(dmId, 'dms');
removeRoomButton(sanitizeId(dmId), 'dms');
refreshDms(0, false);
clearAction("dmopen");
return;
}
currentDmKey = await ensureDmRoomKey(dmId); currentDmKey = await ensureDmRoomKey(dmId);
if (!currentDmKey) { if (!currentDmKey) {
throw new Error("Missing DM room key"); throw new Error("Missing DM room key");
@ -3022,16 +2936,8 @@ async function openDm(dmId, username, targetId, type = 'dms') {
window.currentGroupKeyIndex = info.string4 || "0"; window.currentGroupKeyIndex = info.string4 || "0";
window.currentGroupOwner = info.string7; window.currentGroupOwner = info.string7;
} else { } else {
showBlahNotification(infoPayload && infoPayload.startsWith("error:") ? infoPayload : "error:group.not.found"); window.currentGroupKeyIndex = "0";
//usuń wpis z listy tylko przy deterministycznym błędzie window.currentGroupOwner = "";
//(network.error itp. nie może czyścić poprawnych wpisów)
if (infoPayload === "error:group.not.found") {
removeRoomFromCachedList(dmId, 'groups');
removeRoomButton(sanitizeId(dmId), 'groups');
refreshGroups(0, false);
}
clearAction("dmopen");
return;
} }
} }
@ -3109,9 +3015,13 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
if (res && res.startsWith("error:")) { if (res && res.startsWith("error:")) {
if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") { if (res === "error:forbidden" || res === "error:not.a.member" || res === "error:group.not.found") {
//brak dostępu do pokoju - serwer usunął też nieaktualny wpis z listy, if (dmMessagePollInterval) clearInterval(dmMessagePollInterval);
//my dodatkowo czyścimy cache/UI i wychodzimy z ekranu rozmowy currentDmId = null;
await handleLostRoomAccess(dmId, null, res); showBlahNotification(res);
if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details');
}
switchDetailsContent("title.details", `<div style="text-align: center; opacity: 0.5; padding: 1rem; margin-top: 2rem;"><blah>desc.no.chat.selected</blah></div>`);
return; return;
} }
showBlahNotification(res); showBlahNotification(res);
@ -3233,8 +3143,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
} }
} }
let safeAttId = sanitizeId(att.id); let attContainerId = `att-container-${msgId}-${att.id}`;
let attContainerId = `att-container-${sanitizeId(String(msgId))}-${safeAttId}`;
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/")); let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
let sizeStr = att.size ? formatBytes(att.size) : ""; let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25); let displayName = truncateFilename(att.name, 25);
@ -3245,7 +3154,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27"); let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27"); let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 7.5rem; max-width: 18.75rem; min-height: 6.25rem; max-height: 18.75rem; background: rgba(0,0,0,0.2); border: 0.0625rem solid var(--border-color); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${sanitizeId(String(msgId))}', '${safeAttId}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${sanitizeId(msg.key) || "0"}')"> attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 7.5rem; max-width: 18.75rem; min-height: 6.25rem; max-height: 18.75rem; background: rgba(0,0,0,0.2); border: 0.0625rem solid var(--border-color); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${msg.key || "0"}')">
<div style="padding: 1rem; text-align: center; color: var(--text-color);"> <div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg} ${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div> <div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
@ -3306,11 +3215,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by"; else if (msg.type === "larp.info.pfp") actionStr = "group.pfp.changed.by";
else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by"; else if (msg.type === "larp.info.pfp_removed") actionStr = "group.pfp.removed.by";
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`; let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr); let textStr = processBlah(actionStr);
if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) { if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) {
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${sanitizeId(msg.pervious)}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`; let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg); content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
} else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") { } else if (msg.type === "larp.info.history" || msg.type === "larp.info.name") {
let escapedContent = escapeHtml(content); let escapedContent = escapeHtml(content);
@ -3320,7 +3229,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let targetId = ""; let targetId = "";
let parts = content.split(':'); let parts = content.split(':');
if (parts.length >= 2 && parts[0].includes(';')) { if (parts.length >= 2 && parts[0].includes(';')) {
targetId = sanitizeId(parts[0]); targetId = parts[0];
targetUsername = escapeHtml(parts.slice(1).join(':')); targetUsername = escapeHtml(parts.slice(1).join(':'));
} else { } else {
targetUsername = escapeHtml(parts[0]); targetUsername = escapeHtml(parts[0]);
@ -3366,7 +3275,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]); dmPfpCache[msg.author] = await getAvatarUrl(msg.author, dmUsernameCache[msg.author]);
} }
if (dmUsernameCache[msg.author]) { if (dmUsernameCache[msg.author]) {
authorName = escapeHtml(dmUsernameCache[msg.author].split(':')[0]); authorName = dmUsernameCache[msg.author].split(':')[0];
pfp = dmPfpCache[msg.author]; pfp = dmPfpCache[msg.author];
} }
} }
@ -3446,7 +3355,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
window.replyAuthorCache[respondedId] = sName; window.replyAuthorCache[respondedId] = sName;
let el = document.getElementById(randId); let el = document.getElementById(randId);
if (el) { if (el) {
el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(sName)}`; el.innerHTML = `${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${sName}`;
} }
} }
} }
@ -3456,7 +3365,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
} }
let divIdStr = randId !== "" ? `id="${randId}" ` : ""; let divIdStr = randId !== "" ? `id="${randId}" ` : "";
repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${sanitizeId(respondedId)}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${escapeHtml(replyAuthor)}</div>`; repliedHtml = `<div ${divIdStr}class="chat-message-replied" style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.2rem; cursor: pointer;" onclick="event.stopPropagation(); scrollToMessage('${respondedId}')">${ICONS.cornerUpLeft({ width: "0.8rem", height: "0.8rem", stroke: "currentColor", style: "vertical-align: middle;" })} ${processBlah('title.replied.to')} ${replyAuthor}</div>`;
} }
let reactionsHtml = ""; let reactionsHtml = "";
@ -3496,7 +3405,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27"); let safeRx = encodeURIComponent(rxVal).replace(/'/g, "%27");
let escapedRxVal = escapeHtml(rxVal); let escapedRxVal = escapeHtml(rxVal);
let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`; let pillStyle = `display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 1rem; font-size: 0.85rem; border: var(--border-width) solid var(--light-border-color); cursor: pointer; ${style}`;
reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${sanitizeId(String(msgId))}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`; reactionsHtml += `<span class="reaction-pill" style="${pillStyle}" onclick="event.stopPropagation(); reactMessagePrompt('${msgId}', '${safeRx}')"><span>${processBlah(escapedRxVal)}</span>${rxCounts[rxVal].count > 1 ? `<span style="opacity: 0.5;">${rxCounts[rxVal].count}</span>` : ""}</span>`;
} }
reactionsHtml += `</div>`; reactionsHtml += `</div>`;
} }
@ -3506,11 +3415,11 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
} }
html += ` html += `
<div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${sanitizeId(String(msgId))}" id="${isPinnedContext ? `pinned-` : ``}msg-${sanitizeId(String(msgId))}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${sanitizeId(String(msgId))}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}"> <div class="chat-message ${extraClass}${isPinnedContext ? ` chat-message-pinned-item` : ``}" data-msg-id="${msgId}" id="${isPinnedContext ? `pinned-` : ``}msg-${msgId}"${isPinnedContext ? ` onclick="scrollToPinnedMessage(event, '${msgId}')"` : ``} style="${isPinnedContext ? `cursor: pointer;` : ``}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`} ${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
<div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${sanitizeId(String(msgId))}')"> <div class="chat-message-content" oncontextmenu="handleMessageContextMenu(arguments[0], '${msgId}')">
${showAvatar ? `<div class="chat-message-header"> ${showAvatar ? `<div class="chat-message-header">
<span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${sanitizeId(msg.author)}')" style="cursor: pointer;">${authorName}</span> <span class="chat-message-author" onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer;">${authorName}</span>
<span class="chat-message-timestamp">${timeStr}</span> <span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""} </div>` : ""}
${repliedHtml} ${repliedHtml}
@ -3589,21 +3498,7 @@ async function renderMessages(messages, isPrepend = false) {
let newAttachments = newChild.querySelector('.chat-message-attachments'); let newAttachments = newChild.querySelector('.chat-message-attachments');
if (newAttachments) { if (newAttachments) {
if (oldAttachments) { if (oldAttachments) {
if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) { if (oldAttachments.getAttribute('data-raw') !== newAttachments.getAttribute('data-raw')) oldAttachments.innerHTML = newAttachments.innerHTML;
//keep already downloaded/rendered attachment boxes alive across the update,
//and always sync data-raw - otherwise every poll would see a stale attribute
//and re-render (unloading) the attachments forever
let loadedBoxes = {};
oldAttachments.querySelectorAll('.attachment-box[data-loaded="true"]').forEach(box => {
loadedBoxes[box.id] = box;
});
oldAttachments.innerHTML = newAttachments.innerHTML;
oldAttachments.setAttribute('data-raw', newAttachments.getAttribute('data-raw'));
for (let boxId in loadedBoxes) {
let freshBox = oldAttachments.querySelector(`#${boxId}`);
if (freshBox) freshBox.replaceWith(loadedBoxes[boxId]);
}
}
} else { } else {
let contentNode = existingChild.querySelector('.chat-message-content'); let contentNode = existingChild.querySelector('.chat-message-content');
if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions); if (oldReactions) contentNode.insertBefore(newAttachments, oldReactions);
@ -3861,7 +3756,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
<div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;"> <div class="chat-message" id="${pendingMsgId}" style="opacity: 0.5;">
<div style="width: 2.5rem; flex-shrink: 0;"></div> <div style="width: 2.5rem; flex-shrink: 0;"></div>
<div class="chat-message-content"> <div class="chat-message-content">
<div class="chat-message-text">${escapeHtml(content)} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div> <div class="chat-message-text">${content} <span style="font-size: 0.8rem; opacity: 0.7;">(${processBlah("info.sending.message")})</span></div>
</div> </div>
</div> </div>
`; `;
@ -3893,7 +3788,6 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
res = await fetch(`${url}/dm/attachment/staging?id=${id}`, { res = await fetch(`${url}/dm/attachment/staging?id=${id}`, {
method: "POST", method: "POST",
headers: { headers: {
"X-Crypto": "g1",
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce) "secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}, },
body: encryptedFileBytes body: encryptedFileBytes
@ -4097,10 +3991,6 @@ function setupWebSocket() {
}, 300); }, 300);
} }
} }
else if (data.startsWith("group_removed:")) {
let removedGroupId = data.substring("group_removed:".length);
handleGroupRemoved(removedGroupId);
}
else if (data.startsWith("call_update:")) { else if (data.startsWith("call_update:")) {
if (typeof handleCallUpdate === "function") { if (typeof handleCallUpdate === "function") {
handleCallUpdate(data); handleCallUpdate(data);
@ -4153,7 +4043,7 @@ function handleMessageContextMenu(e, msgId) {
if (pinContainer) { if (pinContainer) {
let isPinned = msg && msg.pinned === "true"; let isPinned = msg && msg.pinned === "true";
pinContainer.innerHTML = ` pinContainer.innerHTML = `
<button onclick="togglePinMessage('${sanitizeId(String(msgId))}')"> <button onclick="togglePinMessage('${msgId}')">
${ICONS.pin()} ${ICONS.pin()}
<span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span> <span class="blah">${isPinned ? processBlah("title.unpin.message") : processBlah("title.pin.message")}</span>
</button>`; </button>`;
@ -4569,14 +4459,7 @@ async function changePassword() {
if (!newPass) return; if (!newPass) return;
showAction("action.account.saving", "accountsave"); showAction("action.account.saving", "accountsave");
//sól KDF konta (wymagana; nie zmienia się przy zmianie hasła) let newPassHash = await hashSHA3_512(newPass);
let saltRes = await fetchAsync(`${url}/kdfsalt?id=${window.id}`);
if (!saltRes || !saltRes.startsWith("P:")) {
clearAction("accountsave");
showBlahNotification("error:kdf.salt.missing");
return;
}
let newPassHash = await deriveCredentialKdf(newPass, saltRes.substring(2).trim());
let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass); let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass);
let payload = JSON.stringify({ let payload = JSON.stringify({
@ -5087,91 +4970,6 @@ if (cropperContainer) {
}).observe(cropperContainer); }).observe(cropperContainer);
} }
//Usuwa pokój z cache listy (dms/groups). Zwraca true jeśli coś usunięto.
function removeRoomFromCachedList(roomId, type) {
try {
roomId = String(roomId);
if (type === 'groups') {
if (!window.cachedGroups) return false;
let parsed = JSON.parse(window.cachedGroups);
let removed = false;
if (parsed.groups && parsed.groups[roomId]) {
delete parsed.groups[roomId];
removed = true;
}
//fallback: wpis może być zapisany z domena albo bez
if (!removed && parsed.groups && !roomId.includes(';')) {
for (let key in parsed.groups) {
if (key.split(';')[0] === roomId) {
delete parsed.groups[key];
removed = true;
break;
}
}
}
if (removed) window.cachedGroups = JSON.stringify(parsed);
return removed;
} else {
if (!window.cachedDms) return false;
let parsed = JSON.parse(window.cachedDms);
if (parsed.dms && parsed.dms[roomId]) {
delete parsed.dms[roomId];
window.cachedDms = JSON.stringify(parsed);
return true;
}
}
} catch (e) {
console.error(e);
}
return false;
}
function removeRoomButton(roomId, type) {
let btn = document.getElementById(`${type === 'groups' ? 'group' : 'dm'}-btn-${sanitizeId(roomId)}`);
if (btn) btn.remove();
}
//Serwer informuje, że użytkownik został usunięty z grupy (kick) -
//usuń grupę z listy, a jeśli jest otwarta, wyjdź z ekranu rozmowy.
async function handleGroupRemoved(groupId) {
try {
removeRoomFromCachedList(groupId, 'groups');
removeRoomButton(sanitizeId(groupId), 'groups');
if (currentDmId === groupId || (currentDmId && currentDmId.split(';')[0] === groupId.split(';')[0] && window.currentRoomType === 'groups')) {
await handleLostRoomAccess(groupId, 'groups', "info:removed.from.group");
}
if (typeof refreshGroups === 'function') refreshGroups(0, false);
} catch (e) {
console.error(e);
}
}
//Pokój do którego straciliśmy dostęp: usuń go z listy i wyjdź z ekranu rozmowy.
async function handleLostRoomAccess(roomId, type = null, notifyKey = null) {
if (!type) type = window.currentRoomType === 'groups' ? 'groups' : 'dms';
let wasOpen = currentDmId === roomId;
removeRoomFromCachedList(roomId, type);
removeRoomButton(sanitizeId(roomId), type);
if (typeof refreshDms === 'function') refreshDms(0, false);
if (typeof refreshGroups === 'function') refreshGroups(0, false);
if (wasOpen) {
showBlahNotification(notifyKey || (type === 'groups' ? "error:not.a.member" : "error:forbidden"));
if (dmMessagePollInterval) {
clearInterval(dmMessagePollInterval);
dmMessagePollInterval = null;
}
currentDmId = null;
window.currentRoomType = null;
if (mainScreen.classList.contains('mobile-details')) {
mainScreen.classList.remove('mobile-details');
}
gotoHome();
}
}
function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) { function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
try { try {
if (type === 'groups') { if (type === 'groups') {
@ -5428,9 +5226,6 @@ function handleMemberContextMenu(e, groupId, memberId) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
groupId = sanitizeId(groupId);
memberId = sanitizeId(memberId);
if (e && e.type === "contextmenu") { if (e && e.type === "contextmenu") {
touchContextMenuFired = true; touchContextMenuFired = true;
setTimeout(() => { touchContextMenuFired = false; }, 500); setTimeout(() => { touchContextMenuFired = false; }, 500);

View file

@ -774,7 +774,7 @@ space{
border-radius: 0.8rem; border-radius: 0.8rem;
transform: translateY(calc( ( var(--icon-button-height) + (var(--button-margin) * 2) ) / 2 - 0.6rem)); transform: translateY(calc( ( var(--icon-button-height) + (var(--button-margin) * 2) ) / 2 - 0.6rem));
} }
button:hover, input:not([type="range"]):hover, textarea:hover { button:hover, input:hover, textarea:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
.submit-button:hover, .profile-action-btn:hover { .submit-button:hover, .profile-action-btn:hover {
@ -791,11 +791,7 @@ space{
filter: brightness(0.93); filter: brightness(0.93);
} }
} }
/* Suwaki ([type=range]) są wyłączone z efektu klikania całego pola - button:active, button.is-pressed, input:active, input.is-pressed, textarea:active, textarea.is-pressed {
przytrzymanie skaluje tylko uchwyt (patrz ::-webkit-slider-thumb:active). */
button:active, button.is-pressed,
input:not([type="range"]):active, input:not([type="range"]).is-pressed,
textarea:active, textarea.is-pressed {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
transform: var(--press-scale); transform: var(--press-scale);
} }
@ -1025,18 +1021,15 @@ button.profile-action-btn.popup-btn-danger:hover, button.submit-button.popup-btn
height: auto; height: auto;
} }
/* Custom Zoom Slider Styling */
input[type="range"] { #cropper-zoom,
.voice-setting-row input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
display: block;
margin: 0;
padding: 0.4rem 0.05rem;
border: none;
width: 100%; width: 100%;
height: 0.375rem; height: 0.375rem;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border-radius: 0.34rem; border-radius: 0.1875rem;
outline: none; outline: none;
} }
@ -1044,7 +1037,8 @@ input[type="range"] {
margin: 0.625rem 0; margin: 0.625rem 0;
} }
input[type="range"]::-webkit-slider-thumb { #cropper-zoom::-webkit-slider-thumb,
.voice-setting-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 1rem; width: 1rem;
@ -1055,16 +1049,14 @@ input[type="range"]::-webkit-slider-thumb {
transition: transform 0.2s, background 0.2s; transition: transform 0.2s, background 0.2s;
} }
input[type="range"]::-webkit-slider-thumb:hover { #cropper-zoom::-webkit-slider-thumb:hover,
transform: scale(1.08); .voice-setting-row input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: #fff; background: #fff;
} }
input[type="range"]::-webkit-slider-thumb:active { #cropper-zoom::-moz-range-thumb,
transform: scale(0.87); .voice-setting-row input[type="range"]::-moz-range-thumb {
}
input[type="range"]::-moz-range-thumb {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
border-radius: 50%; border-radius: 50%;
@ -1074,15 +1066,12 @@ input[type="range"]::-moz-range-thumb {
border: none; border: none;
} }
input[type="range"]::-moz-range-thumb:hover { #cropper-zoom::-moz-range-thumb:hover,
transform: scale(1.08); .voice-setting-row input[type="range"]::-moz-range-thumb:hover {
transform: scale(1.2);
background: #fff; background: #fff;
} }
input[type="range"]::-moz-range-thumb:active {
transform: scale(0.87);
}
roomcontent roomtopbar .room-action-pin, roomcontent roomtopbar .room-action-pin,
roomcontent roomtopbar .room-action-call { roomcontent roomtopbar .room-action-call {
display: flex; display: flex;
@ -1429,16 +1418,10 @@ input:checked + .toggle-slider:before {
.voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; } .voice-resize-se { bottom: 0; right: 0; cursor: nwse-resize; }
.voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; } .voice-resize-sw { bottom: 0; left: 0; cursor: nesw-resize; }
/*
* Kafelki rozmowy - wszystkie mają TEN SAM rozmiar (kwadrat, --voice-tile-size
* ustawiany przez relayoutVoiceTiles() w voice.js). Kafelki płyną obok siebie,
* a gdy się nie mieszczą w rzędzie, flex-wrap przenosi je do następnego.
*/
.voice-tile { .voice-tile {
position: relative; position: relative;
width: var(--voice-tile-size, 9rem); width: var(--voice-tile-w, 9rem);
height: var(--voice-tile-size, 9rem); height: var(--voice-tile-h, 8rem);
flex: 0 0 auto;
background: var(--light-border-color); background: var(--light-border-color);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@ -1724,6 +1707,13 @@ input:checked + .toggle-slider:before {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.voice-setting-row input[type="range"] {
margin: 0;
width: 100%;
appearance: none;
-webkit-appearance: none;
outline: none;
}
/* In-call Voice Device Settings Popup */ /* In-call Voice Device Settings Popup */
#voice-device-settings { #voice-device-settings {
@ -1892,10 +1882,10 @@ input:checked + .toggle-slider:before {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 0.125rem; width: 2px;
margin-left: -0.0625rem; margin-left: -1px;
background: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.65);
border-radius: 0.0625rem; border-radius: 1px;
pointer-events: none; pointer-events: none;
} }
/* Settings button keeps its hover look while the settings popup is open */ /* Settings button keeps its hover look while the settings popup is open */

View file

@ -1 +1 @@
const CLIENT_VERSION = "0.2.0"; const CLIENT_VERSION = "0.1.4";

View file

@ -3,99 +3,10 @@ let currentVoiceCallId = null;
let currentVoiceCallType = null; let currentVoiceCallType = null;
let voiceWsHeartbeat = null; let voiceWsHeartbeat = null;
let voiceActiveUsers = []; let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceAesKeyBytes = null;
let voiceAesCryptoKey = null;
let voiceUiContainer = null; let voiceUiContainer = null;
// === Gwarantowana unikalność IV (AES-GCM): ===
// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId)
// -> kolizja między nadawcami niemożliwa z konstrukcji,
// 2) IV(12B) = epoka(4B = SHA-256(ticketPublicId)[0..4]) || licznik(8B BE, ściśle rosnący)
// -> kolizja w ramach jednego nadawcy niemożliwa w obrębie epoki; każde dołączenie
// do calla ma unikalny ticketPublicId (single-use po stronie serwera), a licznik
// może się resetować TYLKO razem ze zmianą klucza root.
let voiceMySenderCryptoKey = null; // mój podklucz nadawcy (CryptoKey)
const voicePeerSenderKeys = new Map(); // clientIdStr -> Promise<CryptoKey> odbiorczy
let voiceSendEpoch = null; // Uint8Array(4)
let voiceSendCounter = 0n; // BigInt, monotoniczny w ramach epoki
//Promise rozstrzygany gdy operacja na kluczu root (ustawienie/wyczyszczenie)
//jest gotowa - pozwala innym kodom czekać zamiast wchodzić w wyścig z derivacją.
let voiceRootKeyReady = Promise.resolve();
const VOICE_HKDF_INFO_PREFIX = "larpix:voice:v1:";
function voiceHkdfInfoBytes(senderIdStr) {
const prefix = new TextEncoder().encode(VOICE_HKDF_INFO_PREFIX);
const idBytes = new TextEncoder().encode(String(senderIdStr));
const out = new Uint8Array(prefix.length + idBytes.length);
out.set(prefix, 0);
out.set(idBytes, prefix.length);
return out;
}
async function deriveVoiceSenderKey(rootBytes, senderIdStr) {
const ikm = await crypto.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: voiceHkdfInfoBytes(senderIdStr)},
ikm, 256
);
return await crypto.subtle.importKey("raw", new Uint8Array(bits), {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
}
//Jedyny punkt zmiany klucza root. Reset licznika jest poprawny wyłącznie tutaj -
//nowy root = świeża przestrzeń IV. Null czyści cały stan.
function setVoiceRootKey(bytesOrNull) {
const done = (async () => {
if (!bytesOrNull) {
voiceAesKeyBytes = null;
voiceMySenderCryptoKey = null;
voicePeerSenderKeys.clear();
return;
}
voicePeerSenderKeys.clear();
const k = await deriveVoiceSenderKey(bytesOrNull, String(window.id));
voiceAesKeyBytes = bytesOrNull;
voiceMySenderCryptoKey = k;
})();
voiceRootKeyReady = done;
return done;
}
//Epoka z unikalnego per-dołączenie ticketPublicId - deterministycznie, bez dodatkowego losowania.
function setVoiceEpochFromTicket(publicIdB64u) {
crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(publicIdB64u))).then(d => {
voiceSendEpoch = new Uint8Array(d).slice(0, 4);
voiceSendCounter = 0n;
}).catch(() => { voiceSendEpoch = null; voiceSendCounter = 0n; });
}
//Jedynne źródło IV do wysyłki. Inkrementacja synchroniczna (single-thread JS = atomiczne).
function nextVoiceIv() {
if (!voiceSendEpoch) throw new Error("voice epoch not ready");
voiceSendCounter += 1n;
if (voiceSendCounter >= (1n << 63n)) throw new Error("voice iv counter exhausted"); //praktycznie nieosiągalne
const iv = new Uint8Array(12);
iv.set(voiceSendEpoch, 0);
let c = voiceSendCounter;
for (let i = 0; i < 8; i++) {
iv[11 - i] = Number(c & 0xffn);
c >>= 8n;
}
return iv;
}
//Podklucz odbiorczy danego nadawcy (cache). clientId to dziesiętny string ulonga -
//dokładnie ta sama forma, której nadawca użył przy derivacji własnego klucza.
function getVoicePeerSenderKey(clientIdStr) {
let p = voicePeerSenderKeys.get(clientIdStr);
if (!p && voiceAesKeyBytes) {
p = deriveVoiceSenderKey(voiceAesKeyBytes, clientIdStr);
voicePeerSenderKeys.set(clientIdStr, p);
}
return p || null;
}
let micStream = null, audioContext = null, isAudioWorkletLoaded = false; let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null; let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {}; let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
@ -128,8 +39,6 @@ let peerLastSeen = {};
let deadPeers = new Set(); let deadPeers = new Set();
let lastVoiceServerPacketAt = 0; let lastVoiceServerPacketAt = 0;
let voicePingTimer = null; let voicePingTimer = null;
let voiceRosterRetryTimer = null;
let lastVoiceKeyRequestAt = 0;
let voiceReconnectAttempts = 0; let voiceReconnectAttempts = 0;
let voiceSocketOpenedAt = 0; let voiceSocketOpenedAt = 0;
@ -152,9 +61,9 @@ function cleanupPeerMedia(userId) {
} }
function sendVoicePing() { function sendVoicePing() {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => { crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, new Uint8Array([Date.now() & 255])).then(cipher => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return; if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 5; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
@ -621,16 +530,6 @@ setInterval(() => {
} else if (audioContext && audioContext.state === 'suspended') { } else if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().catch(() => {}); audioContext.resume().catch(() => {});
} }
// Safety net for the call-key handshake: if others are in the call but
// we never received the root key (raced/lost signal), ask for it
// instead of staying deaf until someone refreshes the page.
if (others > 0 && !voiceAesKeyBytes && now - lastVoiceKeyRequestAt > 4000) {
lastVoiceKeyRequestAt = now;
console.debug("Voice: requesting call key from peers");
for (const u of voiceActiveUsers) {
if (u !== String(window.id)) sendVoiceSignalToUser(u, currentVoiceCallId, true);
}
}
} }
if (changed) updateVoiceUIUsers(); if (changed) updateVoiceUIUsers();
}, 2000); }, 2000);
@ -783,8 +682,6 @@ async function requestVoiceJoin() {
let serverUrl = parts[0]; let serverUrl = parts[0];
const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]]; const [publicId, connKey, proof] = [parts[1], parts[2], parts[3]];
//epoka IV z unikalnego (single-use) publicId - każde dołączenie = rozłączna przestrzeń IV
setVoiceEpochFromTicket(publicId);
if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) { if (!serverUrl.startsWith("ws://") && !serverUrl.startsWith("wss://")) {
serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl; serverUrl = serverUrl.includes("localhost") || serverUrl.includes("127.0.0.1") ? "ws://" + serverUrl : "wss://" + serverUrl;
} }
@ -883,21 +780,7 @@ async function startVoiceSession(serverUrl, ticket) {
} catch (e) {} } catch (e) {}
}; };
fetchUsers(); fetchUsers();
//The voice-server roster is joined only AFTER ticket validation (an voiceWsHeartbeat = setInterval(fetchUsers, 5000);
//HTTP roundtrip to the main server), so the immediate fetch can still
//miss us - retry quickly until our own tile shows up instead of
//staring at an empty call for the first heartbeat.
if (voiceRosterRetryTimer) clearInterval(voiceRosterRetryTimer);
let rosterTries = 0;
voiceRosterRetryTimer = setInterval(() => {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN ||
voiceActiveUsers.includes(String(window.id)) || ++rosterTries > 12) {
clearInterval(voiceRosterRetryTimer);
voiceRosterRetryTimer = null;
return;
}
fetchUsers();
}, 400);
voiceReconnectAttempts = 0; voiceReconnectAttempts = 0;
lastVoiceServerPacketAt = Date.now(); lastVoiceServerPacketAt = Date.now();
@ -964,9 +847,9 @@ async function startVoiceSession(serverUrl, ticket) {
if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)'; if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)';
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceMySenderCryptoKey) return; if (!voiceAesCryptoKey) return;
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
@ -1003,10 +886,7 @@ async function startVoiceSession(serverUrl, ticket) {
const iv = new Uint8Array(arrayBuffer, 10, 12); const iv = new Uint8Array(arrayBuffer, 10, 12);
const ciphertext = new Uint8Array(arrayBuffer, 22); const ciphertext = new Uint8Array(arrayBuffer, 22);
if (!voiceAesKeyBytes) return; if (!voiceAesCryptoKey) return;
//podklucz nadawcy - deterministyczny z (root, clientId)
const senderKeyPromise = getVoicePeerSenderKey(clientId);
if (type === 0 || type === 4) { if (type === 0 || type === 4) {
const isScreenAudio = (type === 4); const isScreenAudio = (type === 4);
@ -1019,9 +899,7 @@ async function startVoiceSession(serverUrl, ticket) {
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode; const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
audioDecryptChains[audioId].enqueue(async () => { audioDecryptChains[audioId].enqueue(async () => {
try { try {
const senderKey = await senderKeyPromise; const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if (!senderKey) return;
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
@ -1033,9 +911,7 @@ async function startVoiceSession(serverUrl, ticket) {
videoDecryptChains[decoderId].enqueue(async () => { videoDecryptChains[decoderId].enqueue(async () => {
try { try {
const senderKey = await senderKeyPromise; const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if (!senderKey) return;
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if (!remoteVideos[decoderId]) { if (!remoteVideos[decoderId]) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -1095,12 +971,8 @@ async function startVoiceSession(serverUrl, ticket) {
} catch (err) {} } catch (err) {}
}); });
} else if (type === 3) { } else if (type === 3) {
(async () => { crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => {
try { const stoppedTypeId = new Uint8Array(decrypted)[0];
const senderKey = await senderKeyPromise;
if (!senderKey) return;
const decrypted = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
const stoppedTypeId = new Uint8Array(decrypted)[0];
if (stoppedTypeId === 4) { if (stoppedTypeId === 4) {
const audioId = `${clientId}_screen`; const audioId = `${clientId}_screen`;
if (remoteAudio[audioId]) { if (remoteAudio[audioId]) {
@ -1117,8 +989,7 @@ async function startVoiceSession(serverUrl, ticket) {
updateVoiceUIUsers(); updateVoiceUIUsers();
} }
} }
} catch (err) {} }).catch(() => {});
})();
} }
}; };
@ -1150,12 +1021,9 @@ async function startVoiceSession(serverUrl, ticket) {
} }
} }
async function handleCallUpdate(data) { function handleCallUpdate(data) {
let parts = data.split(":"); let parts = data.split(":");
let roomId = parts[1]; let roomId = parts[1];
//Ignore updates for rooms we have nothing to do with - they would otherwise
//overwrite the participant list of an ongoing call and wipe its tiles.
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
let state = []; let state = [];
if(parts.slice(2).join(":") !== "") { if(parts.slice(2).join(":") !== "") {
let stateRaw = JSON.parse(parts.slice(2).join(":")); let stateRaw = JSON.parse(parts.slice(2).join(":"));
@ -1166,13 +1034,10 @@ async function handleCallUpdate(data) {
if (currentVoiceCallId === roomId) { if (currentVoiceCallId === roomId) {
let myIdStr = String(window.id); let myIdStr = String(window.id);
if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) { if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) {
setVoiceRootKey(crypto.getRandomValues(new Uint8Array(32))); voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"])
.then(k => voiceAesCryptoKey = k);
} }
//The root key is set asynchronously - wait for it before distributing.
//Otherwise a fast second join produces [me,peer] while the derivation
//is still running, no signal is sent and the peer never gets the key
//(audio stayed broken for everyone until someone refreshed the page).
await voiceRootKeyReady;
if (voiceAesKeyBytes) { if (voiceAesKeyBytes) {
for (let u of state) { for (let u of state) {
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
@ -1255,7 +1120,7 @@ async function resolveCallerInfo(roomId) {
} }
function showIncomingCallUI(roomId) { function showIncomingCallUI(roomId) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
@ -1309,24 +1174,18 @@ function joinIncomingCall(roomId) {
initiateVoiceCall(roomId); initiateVoiceCall(roomId);
} }
async function sendVoiceSignalToUser(targetUserId, roomId, requestData = false) { async function sendVoiceSignalToUser(targetUserId, roomId) {
try { try {
let setupPayloadJson; let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`);
if (!requestData && voiceAesKeyBytes) { let publicKeys = JSON.parse(publicKeysRaw);
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`); let hybrid = await deriveHybridKeyToUser(publicKeys);
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes); let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes);
setupPayloadJson = JSON.stringify({ let setupPayloadJson = JSON.stringify({
v: 1, v: 1,
ctMlKem: hybrid.ciphertextMlKemBase64, ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember enc: encForMember
}); });
} else {
//Prośba o klucz rozmowy (bez sekretów) - adresat z kluczem odpowie pełnym sygnałem.
setupPayloadJson = JSON.stringify({ v: 1, needKey: true });
}
let payload = JSON.stringify({ let payload = JSON.stringify({
string1: currentVoiceCallType, string1: currentVoiceCallType,
@ -1345,22 +1204,15 @@ async function handleRoomSignal(data) {
let signalStr = parts.slice(2).join(":"); let signalStr = parts.slice(2).join(":");
let signalObj = JSON.parse(signalStr); let signalObj = JSON.parse(signalStr);
if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId) { if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) {
try { try {
let setupData = JSON.parse(signalObj.data); let setupData = JSON.parse(signalObj.data);
if (setupData.needKey) { let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
//Peer nie dostał klucza rozmowy (wygryziony/zagubiony sygnał) - let otherPublicKeysObj = JSON.parse(publicKeysRaw);
//odeślij mu go, ale tylko jeśli sami już go mamy. let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
if (voiceAesKeyBytes) await sendVoiceSignalToUser(signalObj.from, roomId); let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
return; voiceAesKeyBytes = base64ToUint8(decRoomKeyB64);
} voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
if (!voiceAesKeyBytes) {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
let otherPublicKeysObj = JSON.parse(publicKeysRaw);
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
}
} catch(e) { } catch(e) {
console.error("Signal decryption failed", e); console.error("Signal decryption failed", e);
} }
@ -2186,12 +2038,6 @@ function ensureVoiceGridObserver() {
voiceGridRO.observe(container); voiceGridRO.observe(container);
} }
/*
* Wszystkie kafelki to kwadraty o wspólnym rozmiarze --voice-tile-size.
* Wybieramy największy rozmiar kwadratu, przy którym n kafelków mieści się
* w oknie rozmowy - układanie i zawijanie do następnych rzędów robi sam
* flex-wrap w CSS (justify-content: center).
*/
function relayoutVoiceTiles() { function relayoutVoiceTiles() {
let container = document.getElementById("voice-users"); let container = document.getElementById("voice-users");
if (!container || !container.isConnected) return; if (!container || !container.isConnected) return;
@ -2206,34 +2052,28 @@ function relayoutVoiceTiles() {
const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); const innerH = container.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) { if (!isFinite(innerW) || !isFinite(innerH) || !isFinite(rootFs) || innerW <= 0 || innerH <= 0 || rootFs <= 0) {
container.style.removeProperty("--voice-tile-size"); container.style.removeProperty("--voice-tile-w");
container.style.removeProperty("--voice-tile-h");
delete container.dataset.layoutSig; delete container.dataset.layoutSig;
return; return;
} }
const minCol = 6.25 * rootFs;
const minRow = 7 * rootFs;
const gap = 0.75 * rootFs; const gap = 0.75 * rootFs;
const maxSize = 18 * rootFs; // jedna osoba nie dostanie gigantycznego kafelka
const minSize = 4.5 * rootFs; // poniżej tego wolimy scrollować niż spłaszczać
//Największy kwadrat, przy którym n kafelków mieści się w dostępnej powierzchni: const maxCols = Math.max(1, Math.floor((innerW + gap) / (minCol + gap)));
//próbujemy liczby kolumn 1..n, dla każdej wyliczamy ograniczony rozmiar const cols = Math.min(n, maxCols);
//(szerokość kolumny vs wysokość rzędu) i bierzemy najlepszy wariant. const rows = Math.ceil(n / cols);
let best = 0; const w = Math.floor((innerW - gap * (cols - 1)) / cols);
for (let cols = 1; cols <= n; cols++) { const h = Math.floor(Math.max(minRow, (innerH - gap * (rows - 1)) / rows));
const rows = Math.ceil(n / cols);
const s = Math.min(
(innerW - gap * (cols - 1)) / cols,
(innerH - gap * (rows - 1)) / rows
);
if (s > best) best = s;
}
const size = Math.min(maxSize, Math.max(minSize, Math.floor(best))); const sig = `${n}:${cols}:${w}x${h}`;
const sig = `${n}:${size}`;
if (container.dataset.layoutSig === sig) return; if (container.dataset.layoutSig === sig) return;
container.dataset.layoutSig = sig; container.dataset.layoutSig = sig;
container.style.setProperty("--voice-tile-size", size + "px"); container.style.setProperty("--voice-tile-w", w + "px");
container.style.setProperty("--voice-tile-h", h + "px");
} }
let voiceSettingsState = null; let voiceSettingsState = null;
@ -2339,16 +2179,10 @@ function teardownVoiceTransport() {
audioDecryptChains = {}; audioDecryptChains = {};
peerLastSeen = {}; peerLastSeen = {};
deadPeers.clear(); deadPeers.clear();
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
lastVoiceKeyRequestAt = 0;
//Stale participants from the previous attempt must not linger in the UI
voiceActiveUsers = [];
updateVoiceUIUsers();
if (receiverNode) receiverNode.port.postMessage({action: 'clear'}); if (receiverNode) receiverNode.port.postMessage({action: 'clear'});
if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'}); if (screenReceiverNode) screenReceiverNode.port.postMessage({action: 'clear'});
setVoiceRootKey(null); voiceAesKeyBytes = null;
voiceSendEpoch = null; voiceAesCryptoKey = null;
voiceSendCounter = 0n;
lastVoiceServerPacketAt = Date.now(); lastVoiceServerPacketAt = Date.now();
} }
@ -2409,11 +2243,8 @@ function endVoiceSession() {
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; } if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; } if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
peerLastSeen = {}; peerLastSeen = {};
deadPeers.clear(); deadPeers.clear();
lastVoiceKeyRequestAt = 0;
voiceActiveUsers = [];
for (let id in audioDecoders) { for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){} try { audioDecoders[id].close(); } catch(e){}
@ -2444,9 +2275,8 @@ function endVoiceSession() {
remoteVideos = {}; remoteVideos = {};
videoDecryptChains = {}; videoDecryptChains = {};
setVoiceRootKey(null); voiceAesKeyBytes = null;
voiceSendEpoch = null; voiceAesCryptoKey = null;
voiceSendCounter = 0n;
currentVoiceWs = null; currentVoiceWs = null;
currentVoiceCallType = null; currentVoiceCallType = null;
currentVoiceCallId = null; currentVoiceCallId = null;
@ -2492,11 +2322,11 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
screenAudioSourceNode = audioContext.createMediaStreamSource(stream); screenAudioSourceNode = audioContext.createMediaStreamSource(stream);
screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender'); screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
screenSenderNode.port.onmessage = async (e) => { screenSenderNode.port.onmessage = async (e) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
// Same backpressure valve as the mic sender. // Same backpressure valve as the mic sender.
if (currentVoiceWs.bufferedAmount > 256 * 1024) return; if (currentVoiceWs.bufferedAmount > 256 * 1024) return;
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, e.data.audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
@ -2527,10 +2357,10 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0; let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
const encoder = new VideoEncoder({ const encoder = new VideoEncoder({
output: async (chunk) => { output: async (chunk) => {
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return; if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer); const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer);
const iv = nextVoiceIv(); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer); currentVoiceWs.send(payload.buffer);
@ -2582,9 +2412,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
updateVoiceUIUsers(); updateVoiceUIUsers();
btnEl.classList.remove('voice-btn-active'); btnEl.classList.remove('voice-btn-active');
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
const buffer = new Uint8Array([typeId]); const iv = nextVoiceIv(); const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, buffer); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer); payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer);
} }
@ -2593,9 +2423,9 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; } if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; }
if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; } if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; }
if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; } if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; }
if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceMySenderCryptoKey) { if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
const bufferAudioInfo = new Uint8Array([4]); const ivA = nextVoiceIv(); const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12));
const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceMySenderCryptoKey, bufferAudioInfo); const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo);
const payloadA = new Uint8Array(14 + cipherA.byteLength); const payloadA = new Uint8Array(14 + cipherA.byteLength);
payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer); payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer);
} }