big updat:
All checks were successful
Android Build / publish (push) Successful in 57s
Linux Build / publish (push) Successful in 53s

- update dependencies
- add webp support and webp conversion for profile images
This commit is contained in:
olcxja 2026-07-02 22:40:46 +02:00
commit 95aaaa69ea
244 changed files with 121382 additions and 86 deletions

View file

@ -449,6 +449,29 @@ async function encryptString(plainText, passphrase) {
return uint8ToBase64(combined);
}
async function encryptBytesWithNonce(dataUint8, key, nonce) {
const encoder = new TextEncoder();
const passphrase = nonce + key;
const pwHash = await crypto.subtle.digest('SHA-256', encoder.encode(passphrase));
const importedKey = await crypto.subtle.importKey(
'raw', pwHash, {name: 'AES-CBC'}, false, ['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await crypto.subtle.encrypt(
{name: 'AES-CBC', iv: iv},
importedKey,
dataUint8
);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return combined;
}
async function decryptString(base64Text, passphrase) {
const encoder = new TextEncoder();
const combined = new Uint8Array(atob(base64Text).split("").map(c => c.charCodeAt(0)));
@ -608,11 +631,43 @@ async function fetchEncrypted(request, body = "") {
let data = await response.text();
if (data.startsWith("error:")) return data;
return await decryptString(data, passwordHash);
} catch (e) {
console.error("fetchEncrypted error:", e);
return "error:network.error";
} finally {
release();
}
}
async function fetchEncryptedUpload(target, fileBytesUint8) {
let release;
const lock = new Promise(resolve => release = resolve);
const prevMutex = requestMutex;
requestMutex = prevMutex.then(() => lock);
await prevMutex;
try {
let nonce = await getNonce(id, passwordHash);
let response = await fetch(`${url}/upload?id=${id}`, {
method: "POST",
body: await encryptBytesWithNonce(fileBytesUint8, passwordHash, nonce),
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce),
"target": await encryptWithNonce(target, passwordHash, nonce)
}
});
let data = await response.text();
if (data.startsWith("error:")) return data;
return await decryptString(data, passwordHash);
} catch (e) {
console.error("fetchEncryptedUpload error:", e);
return "error:network.error";
} finally {
release();
}
}
function power(base, exponent, mod) {
let res = 1n;
base = base % mod;
@ -914,6 +969,9 @@ function processBlah(blahmessage) {
}
return message;
} catch (e) {
if (blahmessage.startsWith("error:")) {
return blah["error.unknown.error"] || "Unknown error";
}
if (prepended) {
return blahmessage.substring(1);
}
@ -1092,7 +1150,12 @@ id = localStorage.getItem('id');
username = localStorage.getItem('username');
password = localStorage.getItem('password');
host = localStorage.getItem('host');
mainJS();
window.addEventListener("load", () => {
mainJS();
});
function showFixedContextMenu(rect, html) {
@ -2848,14 +2911,19 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
async function gotoSettings(tab = 'account') {
fixedContextMenu.classList.remove("show");
if (roomsBarContainer) roomsBarContainer.style.display = "";
switchRoomsBar("title.settings", settingsBar);
switchSettingsTab(tab);
await switchRoomsBar("title.settings", settingsBar);
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
if (window.innerWidth > 52 * rem) {
switchSettingsTab(tab);
}
}
function switchSettingsTab(tabName) {
setActiveRoombarItem('tab-settings-' + tabName);
if (tabName === 'account') {
switchRoomContent("title.account", accountSettings, false, null, true); //skip mobile slide
switchRoomContent("title.account", accountSettings, false);
} else if (tabName === 'profile') {
switchRoomContent("title.profile", profileSettings, false);
setTimeout(async () => {
@ -3093,38 +3161,126 @@ async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
let targetWidth = cropperType === 'avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' ? 512 : 400;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
let drawW = cropperImageObj.width * zoom * scaleRatio;
let drawH = cropperImageObj.height * zoom * scaleRatio;
let b64;
let newSrc;
if (cropperOriginalType === 'image/gif') {
b64 = cropperOriginalB64.split(',')[1];
newSrc = cropperOriginalB64;
showAction("action.processing", "uploadmedia");
try {
let res = await fetch(cropperOriginalB64);
let buffer = await res.arrayBuffer();
let parsedGif = gifuct.parseGIF(buffer);
let frames = gifuct.decompressFrames(parsedGif, true);
let webpFrames = [];
let tempCanvas = document.createElement('canvas');
let tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = parsedGif.lsd.width;
tempCanvas.height = parsedGif.lsd.height;
let gifCanvas = document.createElement('canvas');
let gifCtx = gifCanvas.getContext('2d', { willReadFrequently: true });
gifCanvas.width = targetWidth;
gifCanvas.height = targetHeight;
let frameImageData = null;
let previousImageData = null;
for (let i = 0; i < frames.length; i++) {
let frame = frames[i];
if (frame.disposalType === 3) {
previousImageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
}
if (!frameImageData || frameImageData.width !== frame.dims.width || frameImageData.height !== frame.dims.height) {
frameImageData = tempCtx.createImageData(frame.dims.width, frame.dims.height);
}
frameImageData.data.set(frame.patch);
let patchCanvas = document.createElement('canvas');
patchCanvas.width = frame.dims.width;
patchCanvas.height = frame.dims.height;
patchCanvas.getContext('2d').putImageData(frameImageData, 0, 0);
tempCtx.drawImage(patchCanvas, frame.dims.left, frame.dims.top);
gifCtx.fillStyle = '#000';
gifCtx.fillRect(0, 0, targetWidth, targetHeight);
gifCtx.drawImage(tempCanvas, drawX, drawY, drawW, drawH);
let frameImgData = gifCtx.getImageData(0, 0, targetWidth, targetHeight);
webpFrames.push({
data: frameImgData.data,
duration: Math.max(20, frame.delay || 0),
config: { lossless: 0, quality: 90 }
});
if (frame.disposalType === 2) {
tempCtx.clearRect(frame.dims.left, frame.dims.top, frame.dims.width, frame.dims.height);
} else if (frame.disposalType === 3 && previousImageData) {
tempCtx.putImageData(previousImageData, 0, 0);
}
}
let webpUint8 = await new Promise((resolve, reject) => {
let worker = new Worker('webp-worker.js', { type: 'module' });
worker.onmessage = (e) => {
if (e.data.success) {
resolve(e.data.data);
} else {
reject(new Error(e.data.error));
}
worker.terminate();
};
worker.onerror = (err) => {
reject(err);
worker.terminate();
};
worker.postMessage({ targetWidth, targetHeight, webpFrames });
});
let webpBlob = new Blob([webpUint8], {type: 'image/webp'});
b64 = await new Promise((resolve) => {
let reader = new FileReader();
reader.onload = function() {
resolve(reader.result.split(',')[1]);
};
reader.readAsDataURL(webpBlob);
});
newSrc = 'data:image/webp;base64,' + b64;
} catch (e) {
console.error("GIF crop failed", e);
b64 = cropperOriginalB64.split(',')[1];
newSrc = cropperOriginalB64;
}
} else {
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
let targetWidth = cropperType === 'avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' ? 512 : 400;
canvas.width = targetWidth;
canvas.height = targetHeight;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
let drawW = cropperImageObj.width * zoom * scaleRatio;
let drawH = cropperImageObj.height * zoom * scaleRatio;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(cropperImageObj, drawX, drawY, drawW, drawH);
b64 = canvas.toDataURL('image/jpeg', 0.9).split(',')[1];
newSrc = 'data:image/jpeg;base64,' + b64;
b64 = canvas.toDataURL('image/webp', 0.9).split(',')[1];
newSrc = 'data:image/webp;base64,' + b64;
}
let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner';
let payload = { string1: payloadKey, string2: b64 };
let fileBytes = base64ToUint8(b64);
showAction("action.uploading", "uploadmedia");
let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload));
let res = await fetchEncryptedUpload(payloadKey, fileBytes);
clearAction("uploadmedia");
if (res && res.startsWith("error:")) {
showBlahNotification(res);