vietnamese

This commit is contained in:
nnduc
2026-10-24 17:46:20 +07:00
parent 5467b0aa2c
commit a3cf10cb0a
33 changed files with 4306 additions and 94 deletions
+109
View File
@@ -39,6 +39,115 @@ var Account = {
Account_ajaxDeleteBundleConfirm(bundleid, store.getOwnSessionsIds());
},
/**
* OMEMO key backup of this device: identity, prekeys and sessions,
* always encrypted (PBKDF2 + AES-256-GCM), even with an empty password.
* Everything happens in the browser, the server never sees the keys.
*/
backupFormat: 'movim-omemo-backup',
backupIterations: 600000,
backupCipherKey: async function (password, salt, iterations) {
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: iterations, hash: 'SHA-256' },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
},
backupToBase64: function (buffer) {
let bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
},
backupError: function (text) {
document.querySelector('#omemo_backup_error').textContent = text;
},
omemoBackup: async function () {
let form = document.querySelector('form[name=omemo_backup]');
let password = form.password.value;
if (password != form.password_confirmation.value) return Account.backupError(form.dataset.notsame);
let store = new ChatOmemoStorage();
let prefix = store.storageVersion + '.' + store.jid + '.';
let keys = {};
Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => keys[k] = localStorage.getItem(k));
let deviceId = await store.getLocalRegistrationId();
let payload = JSON.stringify({
jid: store.jid,
deviceId: deviceId,
keys: keys
});
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await Account.backupCipherKey(password, salt, Account.backupIterations);
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, new TextEncoder().encode(payload));
let backup = JSON.stringify({
format: Account.backupFormat,
version: 1,
jid: store.jid,
deviceId: deviceId,
created: new Date().toISOString(),
kdf: { name: 'PBKDF2-SHA256', iterations: Account.backupIterations, salt: Account.backupToBase64(salt) },
iv: Account.backupToBase64(iv),
data: Account.backupToBase64(cipher)
});
let link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([backup], { type: 'application/json' }));
link.download = 'movim-omemo-' + store.jid + '-' + deviceId + '-' + new Date().toISOString().slice(0, 10) + '.json';
link.click();
URL.revokeObjectURL(link.href);
Dialog_ajaxClear();
},
omemoRestore: async function () {
let form = document.querySelector('form[name=omemo_restore]');
let file = form.file.files[0];
if (!file) return Account.backupError(form.dataset.nofile);
let backup, payload;
try {
backup = JSON.parse(await file.text());
if (backup.format != Account.backupFormat || backup.version != 1) throw new Error();
} catch (e) {
return Account.backupError(form.dataset.invalid);
}
if (backup.jid != USER_JID) return Account.backupError(form.dataset.otheraccount + ' ' + backup.jid);
try {
const key = await Account.backupCipherKey(form.password.value,
MovimUtils.base64ToArrayBuffer(backup.kdf.salt), backup.kdf.iterations);
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: MovimUtils.base64ToArrayBuffer(backup.iv) },
key, MovimUtils.base64ToArrayBuffer(backup.data));
payload = JSON.parse(new TextDecoder().decode(plain));
} catch (e) {
return Account.backupError(form.dataset.wrongpassword);
}
let store = new ChatOmemoStorage();
let prefix = store.storageVersion + '.' + store.jid + '.';
if (Object.keys(payload.keys).some(k => !k.startsWith(prefix))) {
return Account.backupError(form.dataset.invalid);
}
// Replace the keys of this device with the backup ones
let replaced = await store.getLocalRegistrationId();
Object.keys(localStorage).filter(k => k.startsWith(prefix)).forEach(k => localStorage.removeItem(k));
Object.entries(payload.keys).forEach(([k, v]) => localStorage.setItem(k, v));
localStorage.setObject(OMEMO_RESTORED_KEY, { replaced: replaced != payload.deviceId ? replaced ?? null : null });
// The page reload publishes the restored key (ChatOmemo.ownDevicesReceived)
location.reload();
},
refreshFingerprints: async function () {
ChatOmemo.resolveContactFingerprints(USER_JID).then(remoteKeys => {
let omemoStorage = new ChatOmemoStorage;
+28 -1
View File
@@ -1077,4 +1077,31 @@ ul.list li>div.bubble.file div.file a span.size:after {
100% {
opacity: 0.1;
}
}
}
/* Decrypted OMEMO files (XEP-0454) */
#chat_widget .encrypted_media {
display: block;
max-width: 100%;
}
#chat_widget img.encrypted_media {
max-height: 30rem;
border-radius: 0.5rem;
cursor: pointer;
}
/* Shared files: a header button, in the menu on phones */
#chat_widget .context_menu li.files_menu {
display: none;
}
@media screen and (max-width: 576px) {
#chat_widget header .files_button {
display: none;
}
#chat_widget .context_menu li.files_menu {
display: flex;
}
}
+35 -10
View File
@@ -1178,8 +1178,9 @@ var Chat = {
let refreshP = document.querySelector('#id' + data.id + ' p.encrypted');
if (refreshP) {
if (plaintext) {
let escaped = MovimUtils.linkify(MovimUtils.htmlEscape(plaintext));
refreshP.innerHTML = ChatOmemo.searchEncryptedFile(escaped);
if (!ChatOmemo.renderEncryptedFile(refreshP, plaintext)) {
refreshP.innerHTML = MovimUtils.linkify(MovimUtils.htmlEscape(plaintext));
}
refreshP.classList.remove('encrypted');
} else {
@@ -1381,8 +1382,9 @@ var Chat = {
var messages = document.querySelectorAll('#chat_widget > div ul.conversation div.bubble p');
if (messages.length > counter && counter > 0) {
var p = messages[messages.length - counter];
list.insertBefore(separatorNode, p.parentNode.parentNode.parentNode);
// The row of the message: file and encrypted bubbles nest deeper
var item = messages[messages.length - counter].closest('ul.conversation > li');
if (item && item.parentNode == list) list.insertBefore(separatorNode, item);
}
},
getStickerHtml: function (data) {
@@ -1697,6 +1699,27 @@ var Chat = {
var textarea = document.querySelector('#chat_textarea');
if (textarea) return textarea;
},
// XEP-0454: the aesgcm:// URL (with the key) is sent as an OMEMO message
sendEncryptedFile: function (jid, isMuc, url) {
let counter = document.querySelector('#chat_widget header span.counter');
let mucReceipts = isMuc && counter && counter.dataset.mucreceipts == 'true';
let omemo = ChatOmemo.encrypt(jid, url, isMuc);
if (!omemo) {
Chat.failedMessage();
return;
}
omemo.then(omemoheader => {
let id = omemoheader.id = crypto.randomUUID();
Chat.lastSentUUID = id;
Chat.tempMessages[id] = url;
Chat_ajaxHttpDaemonSendMessage(id, jid, null, isMuc, null, false, mucReceipts, omemoheader)
.then(e => Chat.sentMessage())
.catch(e => Chat.failedMessage());
});
},
isEncrypted: function () {
return (Chat.getTextarea() && Chat.getTextarea().dataset.encryptedstate == 'yes');
},
@@ -1842,12 +1865,14 @@ MovimEvents.registerWindow('loaded', 'chat', () => {
Upload.attach((file) => {
if (['chat', 'space'].includes(MovimUtils.urlParts().page)
&& (typeof (PublishStories) == 'undefined' || PublishStories.main == undefined)) {
Chat_ajaxHttpDaemonSendMessage(
Chat.getTextarea().dataset.jid,
false,
Boolean(Chat.getTextarea().dataset.muc),
file
);
let textarea = Chat.getTextarea();
let isMuc = Boolean(textarea.dataset.muc);
if (file.aesgcm) {
Chat.sendEncryptedFile(textarea.dataset.jid, isMuc, file.aesgcm);
} else {
Chat_ajaxHttpDaemonSendMessage(textarea.dataset.jid, false, isMuc, file);
}
}
});
}
+60 -1
View File
@@ -47,4 +47,63 @@ ul#message_preview li.reactions {
ul#message_preview li.reactions a.chip {
max-width: 100%;
}
}
/* Shared files */
#chat_files ul.tabs > li {
width: 25%;
float: left;
}
#chat_files ul.tabs > li > a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 0.25rem;
}
#chat_files ul.files_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0.25rem;
padding: 0.5rem;
margin: 0;
list-style: none;
justify-items: stretch;
}
#chat_files ul.files_grid > li {
display: block;
justify-self: stretch;
width: auto;
margin: 0;
list-style: none;
position: relative;
aspect-ratio: 1 / 1;
min-height: 0;
padding: 0;
overflow: hidden;
background-color: rgba(var(--movim-font), 0.06);
}
#chat_files ul.files_grid > li > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
max-width: none;
object-fit: cover;
cursor: pointer;
}
#chat_files audio {
display: block;
width: 100%;
margin: 0.25rem 0;
}
@media screen and (max-width: 576px) {
#chat_files ul.files_grid {
grid-template-columns: repeat(3, 1fr);
}
}
+187
View File
@@ -1,11 +1,198 @@
var ChatActions = {
message: null,
// OMEMO messages of the open conversation: {jid, entries: [[mid, id, messageid, published]], bodies}
index: null,
keywords: '',
setMessage: function (message) {
ChatActions.message = message;
},
focusSearch: function () {
document.querySelector('form[name=search] input').focus();
},
// Same as utils::search_normalize on the server
normalize: function (text) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replaceAll('đ', 'd');
},
setIndex: function (jid, entries) {
ChatActions.index = { jid: jid, entries: entries, bodies: null };
},
// Decrypted bodies of the indexed messages, from this browser: mid → body
loadBodies: async function () {
let index = ChatActions.index;
if (!index) return new Map();
if (index.bodies) return index.bodies;
let bodies = new Map();
if (typeof ChatOmemoDB != 'undefined' && ChatOmemoDB.db) {
for (const [mid, id, messageid] of index.entries) {
let body = await ChatOmemoDB.getMessage(id);
if (typeof body != 'string' && messageid) body = await ChatOmemoDB.getMessage(messageid);
if (typeof body == 'string' && body) bodies.set(mid, body);
}
}
index.bodies = bodies;
return bodies;
},
published: function (mid) {
let entry = ChatActions.index?.entries.find(e => e[0] == mid);
return entry ? entry[3] : '';
},
// Search: the encrypted messages here, then the server merges them with
// the clear text ones
search: async function (jid, keywords, muc) {
ChatActions.keywords = keywords;
let words = ChatActions.normalize(keywords).split(/\s+/).filter(w => w);
let mids = [];
if (words.length && ChatActions.index && ChatActions.index.jid == jid) {
for (const [mid, body] of await ChatActions.loadBodies()) {
if (typeof AESGCM_REGEX != 'undefined' && AESGCM_REGEX.test(body.trim())) continue;
let normalized = ChatActions.normalize(body);
if (words.every(w => normalized.includes(w))) mids.push(mid);
if (mids.length >= 20) break;
}
}
// A newer search started meanwhile
if (keywords != ChatActions.keywords) return;
ChatActions_ajaxSearchMessages(jid, keywords, muc, mids);
},
fillEncrypted: async function () {
let bodies = await ChatActions.loadBodies();
let words = ChatActions.keywords.split(/\s+/).filter(w => w);
document.querySelectorAll('#chat_search p.encrypted[data-mid]').forEach(p => {
let body = bodies.get(parseInt(p.dataset.mid));
if (!body) return;
p.classList.remove('encrypted');
ChatActions.highlight(p, body, words);
});
},
// Text with the words marked, built with the DOM (the body is not trusted)
highlight: function (element, text, words) {
element.textContent = '';
let escaped = words.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
let parts = escaped.length ? text.split(new RegExp('(' + escaped.join('|') + ')', 'gi')) : [text];
parts.forEach((part, i) => {
if (i % 2 == 1) {
let mark = document.createElement('mark');
mark.textContent = part;
element.appendChild(mark);
} else if (part) {
element.appendChild(document.createTextNode(part));
}
});
},
// Shared files drawer
filesTab: function (tab) {
document.querySelectorAll('#chat_files .tabs li').forEach(li => li.classList.toggle('active', li.dataset.tab == tab));
document.querySelectorAll('#chat_files .files_tab').forEach(div => div.classList.toggle('hide', div.dataset.tab != tab));
ChatActions.filesEmpty();
},
filesEmpty: function () {
let current = document.querySelector('#chat_files .files_tab:not(.hide) [data-list]');
let empty = document.querySelector('#chat_files .files_empty');
if (current && empty) empty.classList.toggle('hide', current.children.length > 0);
},
addEncryptedFiles: async function (jid) {
if (typeof AESGCM_REGEX == 'undefined') return ChatActions.filesEmpty();
for (const [mid, body] of await ChatActions.loadBodies()) {
let url = body.trim();
let matches = url.match(AESGCM_REGEX);
if (!matches) continue;
let filename = matches[2];
let type = AESGCM_TYPES[matches[3].toLowerCase()] ?? '';
let tab = type.startsWith('image/') ? 'images'
: type.startsWith('video/') ? 'videos'
: type.startsWith('audio/') ? 'recordings'
: 'files';
let list = document.querySelector('#chat_files [data-list=' + tab + ']');
if (!list) return;
let li = tab == 'images'
? ChatActions.encryptedPicture(url, filename)
: ChatActions.encryptedFileItem(jid, mid, url, filename, tab, list.dataset.icon);
li.dataset.published = ChatActions.published(mid);
// Keep the newest first, mixed with the clear text files
let older = Array.from(list.children).find(item => item.dataset.published < li.dataset.published);
list.insertBefore(li, older ?? null);
}
ChatActions.filesEmpty();
},
encryptedPicture: function (url, filename) {
let li = document.createElement('li');
let img = document.createElement('img');
img.alt = img.title = filename;
li.appendChild(img);
// Downloaded and decrypted when shown
new IntersectionObserver((entries, observer) => {
if (!entries.some(e => e.isIntersecting)) return;
observer.disconnect();
ChatOmemo.decryptFile(url).then(blobUrl => {
img.src = blobUrl;
img.onclick = () => window.open(blobUrl, '_blank');
}).catch(e => li.classList.add('error'));
}).observe(li);
return li;
},
encryptedFileItem: function (jid, mid, url, filename, tab, icon) {
let li = document.createElement('li');
let primary = document.createElement('span');
primary.className = 'primary icon gray';
primary.innerHTML = '<i class="material-symbols"></i>';
primary.firstChild.textContent = icon;
let control = document.createElement('span');
control.className = 'control icon active gray';
control.innerHTML = '<i class="material-symbols">chat</i>';
control.onclick = e => {
e.stopPropagation();
Chat_ajaxGetMessageContext(jid, mid);
Drawer.clear();
};
let div = document.createElement('div');
let name = document.createElement('p');
name.className = 'line';
name.textContent = filename;
let info = document.createElement('p');
info.className = 'line';
let published = ChatActions.published(mid);
info.textContent = published ? new Date(published.replace(' ', 'T') + 'Z').toLocaleString() : '';
div.append(name, info);
div.onclick = async () => {
if (tab == 'recordings') {
// Play it in place
if (div.querySelector('audio')) return;
let audio = document.createElement('audio');
audio.controls = true;
audio.src = await ChatOmemo.decryptFile(url);
name.after(audio);
audio.play();
} else if (tab == 'videos') {
window.open(await ChatOmemo.decryptFile(url), '_blank');
} else {
ChatOmemo.getEncryptedFile(url);
}
};
li.append(primary, control, div);
return li;
}
}
+93 -39
View File
@@ -88,6 +88,17 @@ var ChatOmemo = {
const localDeviceId = await store.getLocalRegistrationId();
devicesIds = Object.values(devicesIds);
// A key backup was restored (Account.omemoRestore): publish it with new
// prekeys and drop the device it replaced from our list
let restored = localStorage.getObject(OMEMO_RESTORED_KEY);
if (restored && localDeviceId != undefined) {
localStorage.removeItem(OMEMO_RESTORED_KEY);
devicesIds = devicesIds.filter(id => id != restored.replaced);
ChatOmemo.refreshBundle(devicesIds, false);
ChatOmemo.refreshed = true;
return;
}
if (localDeviceId == undefined) {
ChatOmemo.initiateBundle(devicesIds);
return;
@@ -427,56 +438,99 @@ var ChatOmemo = {
? await sessionCipher.decryptPreKeyWhisperMessage(ciphertext, 'binary')
: await sessionCipher.decryptWhisperMessage(ciphertext, 'binary');
},
searchEncryptedFile: function (plaintext) {
let lines = plaintext.split('\n');
let matches = lines[0].match(AESGCM_REGEX);
// Decrypted files of this page: aesgcm URL → blob URL
decryptedFiles: new Map(),
/**
* Render an aesgcm:// message (XEP-0454) in p: pictures and audio inline,
* other files as a download link. Returns false if it is not one.
* Built with the DOM: the URL comes from the contact.
*/
renderEncryptedFile: function (p, plaintext) {
let encryptedUrl = plaintext.trim();
let matches = encryptedUrl.match(AESGCM_REGEX);
if (!matches) return false;
if (!matches) {
return plaintext;
let [, , filename, extension] = matches;
let type = AESGCM_TYPES[extension.toLowerCase()] ?? '';
p.textContent = '';
if (type.startsWith('image/') || type.startsWith('audio/')) {
let media = document.createElement(type.startsWith('image/') ? 'img' : 'audio');
media.className = 'encrypted_media';
media.title = filename;
if (media.tagName == 'AUDIO') media.controls = true;
p.appendChild(media);
ChatOmemo.decryptFile(encryptedUrl).then(url => {
media.src = url;
if (media.tagName == 'IMG') media.onclick = () => window.open(url, '_blank');
}).catch(e => {
console.log('Cannot decrypt ' + filename, e);
p.textContent = '';
p.appendChild(ChatOmemo.encryptedFileLink(encryptedUrl, filename));
});
} else {
p.appendChild(ChatOmemo.encryptedFileLink(encryptedUrl, filename));
}
let [match, , filename, extension, hash] = matches;
return '<i class="material-symbols">file_download</i> <a href="#" class="encrypted_file" onclick="ChatOmemo.getEncryptedFile(\''
+ lines[0]
+ '\')">'
+ filename
+ '</a>';
return true;
},
encryptedFileLink: function (encryptedUrl, filename) {
let fragment = document.createDocumentFragment();
let icon = document.createElement('i');
icon.className = 'material-symbols';
icon.textContent = 'file_download';
let link = document.createElement('a');
link.href = '#';
link.className = 'encrypted_file';
link.textContent = filename;
link.onclick = e => {
e.preventDefault();
ChatOmemo.getEncryptedFile(encryptedUrl);
};
fragment.append(icon, ' ', link);
return fragment;
},
// Fetch and decrypt an aesgcm:// file, returns a blob URL (cached)
decryptFile: async function (encryptedUrl) {
if (ChatOmemo.decryptedFiles.has(encryptedUrl)) {
return ChatOmemo.decryptedFiles.get(encryptedUrl);
}
let [, url, , extension, hash] = encryptedUrl.match(AESGCM_REGEX);
let response = await fetch('https://' + url);
if (!response.ok) throw new Error('HTTP ' + response.status);
let cipher = await response.arrayBuffer();
const iv = hash.slice(0, hash.length - 64);
const key = hash.slice(hash.length - 64);
const keyObj = await crypto.subtle.importKey('raw', MovimUtils.hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
const algo = {
'name': 'AES-GCM',
'iv': MovimUtils.hexToArrayBuffer(iv),
};
let plainFile = await crypto.subtle.decrypt(algo, keyObj, cipher);
let blobUrl = URL.createObjectURL(new Blob([plainFile], { type: AESGCM_TYPES[extension.toLowerCase()] ?? 'application/octet-stream' }));
ChatOmemo.decryptedFiles.set(encryptedUrl, blobUrl);
return blobUrl;
},
getEncryptedFile: async function (encryptedUrl) {
Chat.enableSending();
let [, url, filename, , hash] = encryptedUrl.match(AESGCM_REGEX);
url = 'https://' + url;
let response;
let [, , filename] = encryptedUrl.match(AESGCM_REGEX);
try {
response = await fetch(url)
} catch (e) {
console.log('Cannot get the following file: ' + url)
return null;
}
if (response.status >= 200 && response.status < 400) {
let cipher = await response.arrayBuffer();
const iv = hash.slice(0, 24);
const key = hash.slice(24);
const keyObj = await crypto.subtle.importKey('raw', MovimUtils.hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
const algo = {
'name': 'AES-GCM',
'iv': MovimUtils.hexToArrayBuffer(iv),
};
let plainFile = await crypto.subtle.decrypt(algo, keyObj, cipher);
const file = new File([plainFile], filename);
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.href = await ChatOmemo.decryptFile(encryptedUrl);
link.download = filename;
link.click();
Chat.disableSending();
URL.revokeObjectURL(link.href)
} catch (e) {
console.log('Cannot get the following file: ' + filename, e);
}
Chat.disableSending();
},
resolveContactFingerprints: function (jid) {
let omemoStorage = new ChatOmemoStorage;
@@ -512,7 +566,7 @@ var ChatOmemo = {
MovimWebsocket.attach(async () => {
var store = new ChatOmemoStorage();
const localDeviceId = await store.getLocalRegistrationId();
if (!localDeviceId) {
if (!localDeviceId || localStorage.getItem(OMEMO_RESTORED_KEY)) {
ChatOmemo_ajaxGetDevicesList(USER_JID);
}
});
+56 -14
View File
@@ -11,6 +11,8 @@ var Upload = {
canvas: null,
thumbhash: null,
uploadButton: null,
// OMEMO media sharing (XEP-0454): {blob, fragment} while encrypting a chat upload
encrypted: null,
init: function (appendDate) {
Upload.launchInitiated();
@@ -37,15 +39,49 @@ var Upload = {
}
Upload.name = name + '.' + extension;
Upload.encrypted = null;
Upload_ajaxPrepare({
name: Upload.name,
size: Upload.file.size,
type: Upload.file.type
});
if (Upload.prependName == 'chat' && typeof Chat != 'undefined' && Chat.isEncrypted()) {
Upload.encrypt(Upload.file, extension).then(() => {
// Random name and no type: the upload service learns nothing
Upload_ajaxPrepare({
name: Upload.encrypted.name,
size: Upload.encrypted.blob.size,
type: 'application/octet-stream'
});
}).catch(e => {
console.error(e);
Upload.launchFailed();
Upload_ajaxFailed();
});
} else {
Upload_ajaxPrepare({
name: Upload.name,
size: Upload.file.size,
type: Upload.file.type
});
}
}
},
// AES-256-GCM, the key and IV go in the aesgcm:// URL fragment sent with OMEMO
encrypt: async function (file, extension) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = crypto.getRandomValues(new Uint8Array(32));
const keyObj = await crypto.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, keyObj, await file.arrayBuffer());
const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
extension = (extension || '').toLowerCase();
if (!/^[a-z0-9]{1,10}$/.test(extension)) extension = 'bin';
Upload.encrypted = {
blob: new Blob([cipher], { type: 'application/octet-stream' }),
name: hex(crypto.getRandomValues(new Uint8Array(16))) + '.' + extension,
fragment: hex(iv) + hex(key)
};
},
openFile: function () {
Upload.clear();
document.querySelector('input#file').click();
@@ -87,13 +123,18 @@ var Upload = {
},
launchAttached: function () {
for (var i = 0; i < Upload.attached.length; i++) {
Upload.attached[i]({
// Encrypted: only the aesgcm:// URL, no thumbnail in clear
let file = Upload.encrypted
? { id: Upload.id, aesgcm: Upload.get.replace(/^https?:\/\//, 'aesgcm://') + '#' + Upload.encrypted.fragment }
: {
id: Upload.id,
thumbhash: Upload.thumbhash ?? null,
thumbhashWidth: Upload.canvas ? Upload.canvas.width : null,
thumbhashHeight: Upload.canvas ? Upload.canvas.height : null
});
};
for (var i = 0; i < Upload.attached.length; i++) {
Upload.attached[i](file);
}
},
@@ -267,9 +308,10 @@ var Upload = {
}
},
request: function (route, id) {
request: function (route, id, get) {
Upload.xhr = new XMLHttpRequest();
Upload.id = id;
Upload.get = get;
if (Upload.uploadButton) {
Upload.uploadButton.classList.add('disabled');
@@ -310,7 +352,11 @@ var Upload = {
if (Upload.file != null) {
const formData = new FormData();
formData.append(Upload.id, Upload.file, Upload.name);
if (Upload.encrypted) {
formData.append(Upload.id, Upload.encrypted.blob, Upload.encrypted.name);
} else {
formData.append(Upload.id, Upload.file, Upload.name);
}
Upload.xhr.send(formData);
Upload.setProgress('arrow_upload_ready', '');
@@ -354,10 +400,6 @@ var Upload = {
Upload.preview(Upload.file);
}
if (typeof Chat != 'undefined' && Chat.isEncrypted()) {
document.querySelector('#upload').classList.add('encrypted');
}
document.querySelector('#upload div.drop').addEventListener('drop', ev => {
ev.preventDefault();