vietnamese
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
; Strings added by the Rust edition of Movim
|
||||
; Strings added by the Rust edition of Movim.
|
||||
; Translations: locales/extra/<lang>.po (msgid = the English text below).
|
||||
[room]
|
||||
kick = Kick from the room
|
||||
ban = Ban from the room
|
||||
@@ -23,5 +24,32 @@ this_device = This device
|
||||
this_device = This device
|
||||
|
||||
[chat]
|
||||
restore_history = Restore the history from the server
|
||||
history_restored = The history will be fetched again from the server
|
||||
restore_history = Restore history from the server
|
||||
history_restored = Loading the history from the server
|
||||
|
||||
[account]
|
||||
omemo_backup = Back up encryption keys
|
||||
omemo_backup_text = Save the keys of this device to a file
|
||||
omemo_backup_explain = Keep this file private. A password is optional but recommended.
|
||||
omemo_password_hint = Optional, recommended
|
||||
omemo_download = Download
|
||||
omemo_restore = Restore encryption keys
|
||||
omemo_restore_text = Load the keys from a backup file
|
||||
omemo_restore_explain = This replaces the keys of this device. Stop using the old device afterwards.
|
||||
omemo_file = Backup file
|
||||
omemo_no_file = Choose a file
|
||||
omemo_invalid = This is not a valid backup file
|
||||
omemo_other_account = This backup is for another account:
|
||||
password_placeholder = Choose a nice password
|
||||
password_again = Type your password again
|
||||
omemo_wrong_password = Wrong password
|
||||
omemo_restore_button = Restore
|
||||
|
||||
[chatactions]
|
||||
files = Shared files
|
||||
files_images = Images
|
||||
files_videos = Videos
|
||||
files_recordings = Recordings
|
||||
files_files = Files
|
||||
files_message = Show the message
|
||||
files_empty = Nothing shared yet
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,5 +3,14 @@ const KEY_ALGO = {
|
||||
'length': 128
|
||||
};
|
||||
const NUM_PREKEYS = 50;
|
||||
// Set by a key backup restore until the restored device is published
|
||||
const OMEMO_RESTORED_KEY = 'omemoRestored';
|
||||
const SIGNED_PREKEY_ID = 1;
|
||||
const AESGCM_REGEX = /^aesgcm:\/\/([^#]+\/([^\/]+\.([a-z0-9]+)))#([a-z0-9]+)/i;
|
||||
// XEP-0454: the whole message is the URL, the fragment is IV (12 or 16 bytes) + key (32 bytes)
|
||||
const AESGCM_REGEX = /^aesgcm:\/\/([^\s#]+\/([^\/\s#]+\.([a-z0-9]+)))#([0-9a-f]{88}|[0-9a-f]{96})$/i;
|
||||
const AESGCM_TYPES = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
||||
avif: 'image/avif', opus: 'audio/ogg', ogg: 'audio/ogg', oga: 'audio/ogg', mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4', aac: 'audio/aac', mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm',
|
||||
mov: 'video/quicktime'
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user