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
Generated
+1
View File
@@ -2060,6 +2060,7 @@ dependencies = [
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"unicode-normalization",
"uuid",
"web-push-native",
"xmpp-parsers",
+1
View File
@@ -21,6 +21,7 @@ indexmap = "2.14.2"
minidom = "0.19"
minijinja = { version = "2", features = ["loader", "json"] }
percent-encoding = "2.3.2"
unicode-normalization = "0.1.26"
rand = "0.9"
regex = "1.13.1"
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "stream", "json"] }
+2
View File
@@ -30,6 +30,8 @@ non-anonymous room (the default for group chats created by Movim).
Environment: `MOVIM_BIND`, `MOVIM_ROOT`, `MOVIM_DATA_DIR`, `MOVIM_DATABASE_URL`,
`MOVIM_DOMAIN`, `MOVIM_XMPP_WHITELIST`, `MOVIM_MAX_SESSIONS`,
`MOVIM_DISABLE_REGISTRATION`, `MOVIM_LOCALE`, `MOVIM_SESSION_TIMEOUT_HOURS`,
`MOVIM_MESSAGE_RETENTION_DAYS` (delete cached messages older than this, e.g. the
XMPP server's archive expiry),
dev only: `MOVIM_XMPP_PLAINTEXT=host:port`, `MOVIM_ALLOW_PRIVATE_URLS=1`,
`MOVIM_DEBUG_XMPP=1`.
+31 -3
View File
@@ -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
+10 -1
View File
@@ -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'
};
+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();
+3
View File
@@ -184,6 +184,9 @@ MOVIM_DOMAIN=$XMPP_DOMAIN
#MOVIM_MAX_SESSIONS=0
#MOVIM_LOCALE=en
#MOVIM_SESSION_TIMEOUT_HOURS=168
# Delete cached messages older than this many days (0: keep), e.g. the same
# as the XMPP server's archive expiry (Prosody: archive_expires_after).
#MOVIM_MESSAGE_RETENTION_DAYS=90
#MOVIM_BANNER=
#MOVIM_INFO=
EOF
+4
View File
@@ -22,6 +22,9 @@ pub struct Config {
pub session_timeout_hours: u64,
/// Allow the picture proxy and link previews to reach private networks (development)
pub allow_private_urls: bool,
/// Delete the cached messages older than this many days (0: keep them),
/// to follow the XMPP server's archive expiry
pub message_retention_days: u32,
}
fn env(key: &str) -> Option<String> {
@@ -68,6 +71,7 @@ impl Config {
xmpp_plaintext: env("MOVIM_XMPP_PLAINTEXT"),
session_timeout_hours: env("MOVIM_SESSION_TIMEOUT_HOURS").and_then(|s| s.parse().ok()).unwrap_or(24 * 7),
allow_private_urls: env("MOVIM_ALLOW_PRIVATE_URLS").is_some_and(|v| v == "1" || v == "true"),
message_retention_days: env("MOVIM_MESSAGE_RETENTION_DAYS").and_then(|s| s.parse().ok()).unwrap_or(0),
}
}
}
+148 -13
View File
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS messages_user_jidfrom ON messages (user_id, jidfrom);
CREATE INDEX IF NOT EXISTS messages_user_jidto ON messages (user_id, jidto);
CREATE INDEX IF NOT EXISTS messages_user_published ON messages (user_id, published);
CREATE INDEX IF NOT EXISTS messages_published ON messages (published);
CREATE INDEX IF NOT EXISTS messages_user_stanzaid ON messages (user_id, stanzaid);
CREATE INDEX IF NOT EXISTS messages_user_originid ON messages (user_id, originid);
CREATE INDEX IF NOT EXISTS messages_user_messageid ON messages (user_id, messageid);
@@ -741,6 +742,22 @@ pub async fn history_restore(db: &Db, user: &str, archive_key: &str) -> Result<(
Ok(())
}
/// Delete the messages published more than `days` ago, with their reactions and
/// files. Returns the number of deleted messages.
pub async fn messages_expire(db: &Db, days: u32) -> Result<u64> {
let cutoff = (chrono::Utc::now() - chrono::Duration::days(days.into())).format("%Y-%m-%d %H:%M:%S").to_string();
let mut tx = db.begin().await?;
for sql in [
"DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE published < ?)",
"DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE published < ?)",
] {
sqlx::query(sql).bind(&cutoff).execute(&mut *tx).await?;
}
let deleted = sqlx::query("DELETE FROM messages WHERE published < ?").bind(&cutoff).execute(&mut *tx).await?.rows_affected();
tx.commit().await?;
Ok(deleted)
}
/// When the history of a conversation was last cleared (`archive_key` as above)
pub async fn history_cleared_at(db: &Db, user: &str, archive_key: &str) -> Result<Option<String>> {
Ok(sqlx::query_scalar("SELECT cleared_at FROM history_clears WHERE user_id = ? AND jid = ?")
@@ -1057,25 +1074,106 @@ pub async fn open_chats(db: &Db, user: &str) -> Result<Vec<String>> {
}
/// Plain text search in a discussion
pub async fn messages_search(db: &Db, user: &str, jid: &str, muc: bool, keywords: &str, limit: i64) -> Result<Vec<Message>> {
fn conversation_types(muc: bool) -> String {
let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE };
let types_sql = types.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(",");
let mut sql = format!(
"SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql})
AND encrypted = 0 AND retracted = 0"
);
let words: Vec<String> = keywords.split_whitespace().take(8).map(|w| format!("%{}%", w.replace('%', "\\%").replace('_', "\\_"))).collect();
for _ in &words {
sql.push_str(" AND lower(body) LIKE ? ESCAPE '\\'");
types.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(",")
}
/// Search the messages of a conversation, ignoring case and accents. The
/// server cannot read OMEMO messages: the browser searches them and gives
/// their mids (`encrypted_mids`), merged here with the clear text matches.
pub async fn messages_search(
db: &Db,
user: &str,
jid: &str,
muc: bool,
keywords: &str,
encrypted_mids: &[i64],
limit: i64,
) -> Result<Vec<Message>> {
let types_sql = conversation_types(muc);
let words: Vec<String> = crate::utils::search_normalize(keywords).split_whitespace().take(8).map(String::from).collect();
let rows: Vec<(i64, String)> = sqlx::query_as(AssertSqlSafe(format!(
"SELECT mid, body FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql})
AND encrypted = 0 AND retracted = 0 AND body IS NOT NULL ORDER BY published DESC"
)))
.bind(user)
.bind(jid)
.bind(jid)
.fetch_all(db)
.await?;
let mut mids: Vec<i64> = rows
.into_iter()
.filter(|(_, body)| {
let body = crate::utils::search_normalize(body);
!words.is_empty() && words.iter().all(|w| body.contains(w.as_str()))
})
.map(|(mid, _)| mid)
.take(limit as usize)
.collect();
mids.extend(encrypted_mids.iter().take(limit as usize));
if mids.is_empty() {
return Ok(vec![]);
}
sql.push_str(" ORDER BY published DESC LIMIT ?");
let mut q = sqlx::query_as::<_, Message>(AssertSqlSafe(sql)).bind(user).bind(jid).bind(jid);
for w in &words {
q = q.bind(w);
let placeholders = vec!["?"; mids.len()].join(",");
let mut q = sqlx::query_as::<_, Message>(AssertSqlSafe(format!(
"SELECT {MESSAGE_COLUMNS} FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?) AND type IN ({types_sql})
AND retracted = 0 AND mid IN ({placeholders}) ORDER BY published DESC LIMIT ?"
)))
.bind(user)
.bind(jid)
.bind(jid);
for mid in &mids {
q = q.bind(mid);
}
Ok(q.bind(limit).fetch_all(db).await?)
}
/// OMEMO messages of a conversation, newest first: (mid, id, messageid, published).
/// The browser looks their decrypted bodies up with id or messageid.
pub async fn encrypted_index(db: &Db, user: &str, jid: &str, muc: bool) -> Result<Vec<(i64, String, Option<String>, String)>> {
let types_sql = conversation_types(muc);
Ok(sqlx::query_as(AssertSqlSafe(format!(
"SELECT mid, id, messageid, published FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)
AND type IN ({types_sql}) AND encrypted = 1 AND retracted = 0 ORDER BY published DESC LIMIT 5000"
)))
.bind(user)
.bind(jid)
.bind(jid)
.fetch_all(db)
.await?)
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct ConversationFile {
pub mid: i64,
pub name: Option<String>,
#[sqlx(rename = "type")]
#[serde(rename = "type")]
pub type_: Option<String>,
pub size: Option<i64>,
pub url: String,
pub published: String,
}
/// Files shared in a conversation (clear text ones), newest first
pub async fn conversation_files(db: &Db, user: &str, jid: &str, muc: bool, limit: i64) -> Result<Vec<ConversationFile>> {
let types_sql = conversation_types(muc);
Ok(sqlx::query_as(AssertSqlSafe(format!(
"SELECT m.mid, f.name, f.type, f.size, f.url, m.published FROM message_files f JOIN messages m ON m.mid = f.message_mid
WHERE m.user_id = ? AND (m.jidfrom = ? OR m.jidto = ?) AND m.type IN ({types_sql}) AND m.retracted = 0
ORDER BY m.published DESC LIMIT ?"
)))
.bind(user)
.bind(jid)
.bind(jid)
.bind(limit)
.fetch_all(db)
.await?)
}
/// Forget everything stored for an account
pub async fn user_delete_data(db: &Db, user: &str) -> Result<()> {
let mut tx = db.begin().await?;
@@ -1142,6 +1240,10 @@ pub async fn push_subscription_toggle(db: &Db, user: &str, id: i64, enabled: boo
mod tests {
use super::*;
fn days_ago(days: i64) -> String {
(chrono::Utc::now() - chrono::Duration::days(days)).format("%Y-%m-%d %H:%M:%S").to_string()
}
#[tokio::test]
async fn clear_history_is_remembered() {
// A file: every pool connection to sqlite::memory: gets its own database
@@ -1176,6 +1278,39 @@ mod tests {
history_restore(&db, me, bob).await.unwrap();
assert!(!mam_earliest_exists(&db, me, bob).await.unwrap());
assert_eq!(history_cleared_at(&db, me, bob).await.unwrap(), None);
// Search: clear text matches ignore case and accents, encrypted
// matches come from the browser (their mids)
let hello = Message { id: "s1".into(), body: Some("Xin CHÀO bạn".into()), published: days_ago(2), ..m.clone() };
let secret = Message {
id: "s2".into(),
body: Some("ciphertext".into()),
encrypted: true,
published: days_ago(1),
..m.clone()
};
let hello_mid = message_save(&db, &hello).await.unwrap();
let secret_mid = message_save(&db, &secret).await.unwrap();
let found = messages_search(&db, me, bob, false, "xin chao", &[], 20).await.unwrap();
assert_eq!(found.iter().map(|m| m.mid).collect::<Vec<_>>(), vec![hello_mid]);
assert!(messages_search(&db, me, bob, false, "ciphertext", &[], 20).await.unwrap().is_empty());
let found = messages_search(&db, me, bob, false, "chao", &[secret_mid], 20).await.unwrap();
assert_eq!(found.iter().map(|m| m.mid).collect::<Vec<_>>(), vec![secret_mid, hello_mid]);
// A mid of another conversation is ignored
assert!(messages_search(&db, me, "eve@example.org", false, "x", &[secret_mid], 20).await.unwrap().is_empty());
let index = encrypted_index(&db, me, bob, false).await.unwrap();
assert_eq!(index.iter().map(|e| e.0).collect::<Vec<_>>(), vec![secret_mid]);
// Expiry: an old message goes, a recent one stays
let old = Message { id: "old".into(), published: "2020-01-01 10:00:00".into(), ..m.clone() };
let new = Message { id: "new".into(), published: crate::utils::now_sql(), ..m.clone() };
let old_mid = message_save(&db, &old).await.unwrap();
message_save(&db, &new).await.unwrap();
reactions_replace(&db, old_mid, bob, &["👍".into()]).await.unwrap();
let before = messages_count(&db, me, bob).await.unwrap();
assert_eq!(messages_expire(&db, 90).await.unwrap(), 1);
assert_eq!(messages_count(&db, me, bob).await.unwrap(), before - 1);
assert!(reactions_for(&db, &[old_mid]).await.unwrap().is_empty());
db.close().await;
for ext in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{ext}", path.display()));
+39 -1
View File
@@ -17,6 +17,10 @@ pub struct I18n {
static I18N: OnceLock<I18n> = OnceLock::new();
pub fn init(frontend: &Path) {
let _ = I18N.set(load(frontend));
}
fn load(frontend: &Path) -> I18n {
let mut i18n = I18n::default();
i18n.load_ini(&frontend.join("locales/locales.ini"));
@@ -42,7 +46,21 @@ pub fn init(frontend: &Path) {
}
}
let _ = I18N.set(i18n);
// Translations of the Rust edition strings (extra.ini), and fixes for
// missing upstream ones: they win over the upstream catalogs
if let Ok(dir) = fs::read_dir(frontend.join("locales/extra")) {
for entry in dir.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "po") {
let lang = path.file_stem().unwrap().to_string_lossy().to_string();
if let Ok(content) = fs::read_to_string(&path) {
i18n.catalogs.entry(lang).or_default().extend(parse_po(&content));
}
}
}
}
i18n
}
pub fn get() -> &'static I18n {
@@ -262,4 +280,24 @@ mod tests {
assert_eq!(map.get("Hello").unwrap(), "Bonjour");
assert_eq!(map.get("Long text").unwrap(), "Long texte");
}
#[test]
fn extra_vietnamese() {
let frontend = Path::new(env!("CARGO_MANIFEST_DIR")).join("frontend");
let i18n = load(&frontend);
assert_eq!(i18n.translate("vi", "account.omemo_backup", &[]), "Sao lưu khóa mã hóa");
// Upstream string missing from vi.po, fixed in extra/vi.po
assert_eq!(i18n.translate("vi", "account.password", &[]), "Mật khẩu");
assert_eq!(i18n.translate("fr", "account.omemo_backup", &[]), "Back up encryption keys");
assert_eq!(i18n.translate("vi", "button.cancel", &[]), "Hủy");
// Every interface string is translated
let vi = &i18n.catalogs["vi"];
let mut missing: Vec<_> =
i18n.keys.values().filter(|e| vi.get(*e).is_none_or(|t| t.is_empty())).collect();
missing.sort();
missing.dedup();
assert!(missing.is_empty(), "missing in locales/extra/vi.po: {missing:?}");
}
}
+8
View File
@@ -56,6 +56,14 @@ async fn main() -> anyhow::Result<()> {
loop {
let _ = db::cleanup(&app.db).await;
let _ = db::urls_cleanup(&app.db).await;
let days = app.cfg.message_retention_days;
if days > 0 {
match db::messages_expire(&app.db, days).await {
Ok(0) => {}
Ok(n) => tracing::info!("deleted {n} messages older than {days} days"),
Err(e) => tracing::warn!("message expiry: {e}"),
}
}
tokio::time::sleep(Duration::from_secs(3600)).await;
}
});
+27
View File
@@ -90,3 +90,30 @@ fn add_emojis(state: &State, s: String, no_title: Option<bool>) -> Value {
let escaped = utils::html_escape(&s);
Value::from_safe_string(utils::replace_emojis(&escaped, &base, no_title.unwrap_or(false)).html)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn shared_files_drawer() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
i18n::init(&root.join("frontend"));
let env = environment(&root.join("templates"));
let tpl = env.get_template("ChatActions/_chatactions_files.html").unwrap();
let file = json!({
"mid": 7, "url": "https://up.example.org/a.pdf", "name": "<b>a</b>.pdf", "size": "1 KiB",
"date": "today", "published": "2026-01-01 10:00:00", "thumb": "/picture?url=x",
});
let data = |lang| json!({ "lang": lang, "jid": "bob@example.org", "images": [file], "videos": [], "recordings": [file], "files": [file] });
let html = tpl.render(data("en")).unwrap();
assert!(html.contains("Recordings") && html.contains("Shared files"));
assert!(html.contains("&lt;b&gt;a&lt;/b&gt;.pdf") && !html.contains("<b>a</b>"));
assert!(html.contains(r#"<audio controls preload="none" src="https://up.example.org/a.pdf""#));
let html = tpl.render(data("vi")).unwrap();
assert!(html.contains("Ghi âm") && html.contains("Tệp đã chia sẻ"));
}
}
+16
View File
@@ -142,6 +142,16 @@ pub fn html_decode(s: &str) -> String {
html_escape::decode_html_entities(s).to_string()
}
/// Lowercase without accents, for searches: "chao" finds "Chào", "do" finds "Đỗ"
pub fn search_normalize(s: &str) -> String {
use unicode_normalization::{char::is_combining_mark, UnicodeNormalization};
s.nfd()
.filter(|c| !is_combining_mark(*c))
.flat_map(char::to_lowercase)
.map(|c| if c == 'đ' { 'd' } else { c })
.collect()
}
pub fn type_is_picture(t: &str) -> bool {
matches!(t, "image/jpeg" | "image/png" | "image/jpg" | "image/gif" | "image/webp")
}
@@ -441,6 +451,12 @@ pub fn now_sql_micro() -> String {
mod tests {
use super::*;
#[test]
fn search_normalize_vietnamese() {
assert_eq!(search_normalize("Chào ĐỖ Hoàng, Ước MƠ"), "chao do hoang, uoc mo");
assert_eq!(search_normalize("Café"), "cafe");
}
#[test]
fn ids() {
assert_eq!(cleanup_id("foo@bar.com"), "id-foo-bar-com");
+7
View File
@@ -239,6 +239,13 @@ pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
}
rpc!(ctx, "Account.refreshFingerprints");
}
// OMEMO key backups: only the dialogs, the keys stay in the browser
("Account", "ajaxOmemoBackup") => {
super::dialog(ctx, &ctx.render("Account/_account_omemo_backup.html", json!({})), false, false);
}
("Account", "ajaxOmemoRestore") => {
super::dialog(ctx, &ctx.render("Account/_account_omemo_restore.html", json!({})), false, false);
}
("Account", "ajaxChangePassword") => {
super::dialog(ctx, &ctx.render("Account/_account_password.html", json!({})), false, false);
}
+66 -4
View File
@@ -1,5 +1,5 @@
//! ChatActions widget: actions on a message (react, copy, reply, edit,
//! retract, moderate), search in a discussion, URL previews.
//! retract, moderate), search in a discussion, shared files, URL previews.
use serde_json::json;
@@ -22,9 +22,14 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
}),
);
super::drawer(ctx, "chat_search", &html, false, false);
send_encrypted_index(ctx, &p.str(0), p.b(1)).await;
rpc!(ctx, "ChatActions.focusSearch");
}
"ajaxSearchMessages" => search(ctx, &p.str(0), &p.str(1), p.b(2)).await,
"ajaxSearchMessages" => {
let encrypted: Vec<i64> = p.v(3).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|m| m.as_i64()).collect()).unwrap_or_default();
search(ctx, &p.str(0), &p.str(1), p.b(2), &encrypted).await;
}
"ajaxShowFiles" => show_files(ctx, &p.str(0), p.b(1)).await,
"ajaxCopiedMessageText" => ctx.toast(&ctx.t("chatactions.copied_text", &[])),
"ajaxHttpDaemonRetract" => retract(ctx, p.i(0).unwrap_or(0)).await,
"ajaxHttpDaemonModerate" => moderate(ctx, p.i(0).unwrap_or(0)).await,
@@ -117,7 +122,63 @@ async fn show_message_dialog(ctx: &Ctx, mid: i64) {
super::dialog(ctx, &html, false, false);
}
async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool) {
/// The browser searches and lists the OMEMO messages itself (the server
/// cannot read them): it gets their ids, and looks the decrypted bodies up
async fn send_encrypted_index(ctx: &Ctx, jid: &str, muc: bool) {
if !utils::validate_jid(jid) || !ctx.me().is_some_and(|u| u.omemoenabled) {
return;
}
let index = db::encrypted_index(ctx.db(), &ctx.jid(), jid, muc).await.unwrap_or_default();
rpc!(ctx, "ChatActions.setIndex", jid, index);
}
async fn show_files(ctx: &Ctx, jid: &str, muc: bool) {
if !utils::validate_jid(jid) {
return;
}
let units = [
ctx.t("filesize.byte", &[]),
ctx.t("filesize.kilobyte", &[]),
ctx.t("filesize.megabyte", &[]),
ctx.t("filesize.gigabyte", &[]),
ctx.t("filesize.terabyte", &[]),
];
let (mut images, mut videos, mut recordings, mut files) = (vec![], vec![], vec![], vec![]);
for f in db::conversation_files(ctx.db(), &ctx.jid(), jid, muc, 500).await.unwrap_or_default() {
// Opened by the browser: never a javascript: or data: URL
if !f.url.starts_with("https://") && !f.url.starts_with("http://") {
continue;
}
let type_ = f.type_.clone().unwrap_or_default();
let item = json!({
"mid": f.mid,
"url": f.url,
"name": f.name.clone().unwrap_or_else(|| f.url.rsplit('/').next().unwrap_or("").to_string()),
"size": f.size.map(|s| utils::human_size(s, &units)),
"date": ctx.prepare_date(&f.published, false, false),
"published": f.published,
"thumb": format!("{}picture?url={}", ctx.base_uri(), percent_encoding::utf8_percent_encode(&f.url, percent_encoding::NON_ALPHANUMERIC)),
});
if utils::type_is_picture(&type_) {
images.push(item);
} else if type_.starts_with("video/") {
videos.push(item);
} else if type_.starts_with("audio/") {
recordings.push(item);
} else {
files.push(item);
}
}
let html = ctx.render(
"ChatActions/_chatactions_files.html",
json!({ "jid": jid, "images": images, "videos": videos, "recordings": recordings, "files": files }),
);
super::drawer(ctx, "chat_files", &html, false, false);
send_encrypted_index(ctx, jid, muc).await;
rpc!(ctx, "ChatActions.addEncryptedFiles", jid);
}
async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool, encrypted: &[i64]) {
if !utils::validate_jid(jid) {
return;
}
@@ -127,7 +188,7 @@ async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool) {
return;
}
let me = ctx.jid();
let messages = db::messages_search(ctx.db(), &me, jid, muc, &keywords, 20).await.unwrap_or_default();
let messages = db::messages_search(ctx.db(), &me, jid, muc, &keywords, encrypted, 20).await.unwrap_or_default();
let mut list = vec![];
for m in &messages {
let body = m.body.as_deref().unwrap_or("");
@@ -136,6 +197,7 @@ async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool) {
list.push(prepare_message(ctx, m, true, Some(highlighted)).await);
}
rpc!(ctx, "MovimTpl.fill", "#chat_search", ctx.render("ChatActions/_chatactions_search_result.html", json!({ "messages": list })));
rpc!(ctx, "ChatActions.fillEncrypted");
}
fn highlight(text: &str, keywords: &str) -> String {
+2 -2
View File
@@ -107,10 +107,10 @@ async fn request_slot(ctx: &Ctx, name: &str, size: i64, type_: &str) {
ctx.st(|st| {
st.uploads.insert(
id.clone(),
Upload { put: put_url, headers, get, name: name.to_string(), size, type_: type_.to_string(), uploaded: false },
Upload { put: put_url, headers, get: get.clone(), name: name.to_string(), size, type_: type_.to_string(), uploaded: false },
)
});
rpc!(ctx, "Upload.request", ctx.route("upload", &[&id]), id);
rpc!(ctx, "Upload.request", ctx.route("upload", &[&id]), id, get);
}
Err(e) => {
let msg = e.to_string();
+4 -1
View File
@@ -30,6 +30,8 @@ pub static WIDGETS: &[WidgetDef] = &[
("ajaxHttpGetFingerprints", &["fingerprints"]),
("ajaxDeleteBundle", &["fingerprint"]),
("ajaxDeleteBundleConfirm", &["id", "devicesIds"]),
("ajaxOmemoBackup", &[]),
("ajaxOmemoRestore", &[]),
("ajaxGetGateways", &[]),
("ajaxRemoveAccountConfirm", &["form"]),
("ajaxGetRegistration", &["server"]),
@@ -158,7 +160,8 @@ pub static WIDGETS: &[WidgetDef] = &[
("ajaxUnblock", &["jid"]),
("ajaxShowMessageDialog", &["mid"]),
("ajaxShowSearchDialog", &["jid", "muc"]),
("ajaxSearchMessages", &["jid", "keywords", "muc"]),
("ajaxSearchMessages", &["jid", "keywords", "muc", "encrypted"]),
("ajaxShowFiles", &["jid", "muc"]),
("ajaxCopiedMessageText", &[]),
("ajaxEnableWideConferenceCall", &["conference"]),
("ajaxEditMessage", &["mid"]),
@@ -30,4 +30,24 @@
</li>
{% endfor %}
</ul>
<ul class="list active">
<li onclick="Account_ajaxOmemoBackup()">
<span class="primary icon gray">
<i class="material-symbols">key</i>
</span>
<div>
<p class="line">{{ __('account.omemo_backup') }}</p>
<p class="line">{{ __('account.omemo_backup_text') }}</p>
</div>
</li>
<li onclick="Account_ajaxOmemoRestore()">
<span class="primary icon gray">
<i class="material-symbols">settings_backup_restore</i>
</span>
<div>
<p class="line">{{ __('account.omemo_restore') }}</p>
<p class="line">{{ __('account.omemo_restore_text') }}</p>
</div>
</li>
</ul>
{% endif %}
@@ -0,0 +1,28 @@
<section>
<h3>{{ __('account.omemo_backup') }}</h3>
<br />
<h4 class="gray">{{ __('account.omemo_backup_explain') }}</h4>
<form name="omemo_backup" onsubmit="return false;"
data-notsame="{{ __('account.password_not_same') }}">
<div>
<input type="password" name="password" autocomplete="new-password"
placeholder="{{ __('account.omemo_password_hint') }}"/>
<label>{{ __('account.password') }}</label>
</div>
<div>
<input type="password" name="password_confirmation" autocomplete="new-password"
placeholder="{{ __('account.omemo_password_hint') }}"/>
<label>{{ __('account.password_confirmation') }}</label>
</div>
</form>
<p class="error" id="omemo_backup_error"></p>
</section>
<footer>
<button onclick="Dialog_ajaxClear()" class="button flat">
{{ __('button.cancel') }}
</button>
<button onclick="Account.omemoBackup()" class="button flat color oppose">
{{ __('account.omemo_download') }}
</button>
</footer>
@@ -0,0 +1,29 @@
<section>
<h3>{{ __('account.omemo_restore') }}</h3>
<br />
<h4 class="gray">{{ __('account.omemo_restore_explain') }}</h4>
<form name="omemo_restore" onsubmit="return false;"
data-nofile="{{ __('account.omemo_no_file') }}"
data-invalid="{{ __('account.omemo_invalid') }}"
data-otheraccount="{{ __('account.omemo_other_account') }}"
data-wrongpassword="{{ __('account.omemo_wrong_password') }}">
<div>
<input type="file" name="file" accept=".json,application/json"/>
<label>{{ __('account.omemo_file') }}</label>
</div>
<div>
<input type="password" name="password" autocomplete="current-password"/>
<label>{{ __('account.password') }}</label>
</div>
</form>
<p class="error" id="omemo_backup_error"></p>
</section>
<footer>
<button onclick="Dialog_ajaxClear()" class="button flat">
{{ __('button.cancel') }}
</button>
<button onclick="Account.omemoRestore()" class="button flat color red">
{{ __('account.omemo_restore_button') }}
</button>
</footer>
+2 -2
View File
@@ -4,11 +4,11 @@
<form name="password">
<div>
<input type="password" placeholder="Choose a nice password" name="password"/>
<input type="password" placeholder="{{ __('account.password_placeholder') }}" name="password"/>
<label>{{ __('account.password') }}</label>
</div>
<div>
<input type="password" placeholder="Type your password again" name="password_confirmation"/>
<input type="password" placeholder="{{ __('account.password_again') }}" name="password_confirmation"/>
<label>{{ __('account.password_confirmation') }}</label>
</div>
</form>
+22
View File
@@ -20,6 +20,9 @@
<span class="control icon active" onclick="ChatActions_ajaxShowSearchDialog('{{ jid|echapJS }}', true)">
<i class="material-symbols">manage_search</i>
</span>
<span class="control icon active files_button" onclick="ChatActions_ajaxShowFiles('{{ jid|echapJS }}', true)" title="{{ __('chatactions.files') }}">
<i class="material-symbols">perm_media</i>
</span>
{% if conference.group and conference.connected and conference.muji.count == 0 %}
<span class="control icon active divided {% if in_call %}disabled{% endif %}" onclick="Visio_ajaxGetMujiLobby('{{ jid|echapJS }}', true, false);">
@@ -112,6 +115,14 @@
</ul>
<ul class="list context_menu thin active">
<li class="files_menu" onclick="ChatActions_ajaxShowFiles('{{ jid|echapJS }}', true)">
<span class="primary icon gray">
<i class="material-symbols">perm_media</i>
</span>
<div>
<p class="line">{{ __('chatactions.files') }}</p>
</div>
</li>
{% if conference.role == 'moderator' or conference.affiliation == 'owner' %}
<li class="subheader">
<div>
@@ -209,6 +220,9 @@
<span class="control icon active" onclick="ChatActions_ajaxShowSearchDialog('{{ jid|echapJS }}')">
<i class="material-symbols">manage_search</i>
</span>
<span class="control icon active files_button" onclick="ChatActions_ajaxShowFiles('{{ jid|echapJS }}', false)" title="{{ __('chatactions.files') }}">
<i class="material-symbols">perm_media</i>
</span>
{% if not in_call %}
{% if call_audio %}
@@ -266,6 +280,14 @@
</li>
</ul>
<ul class="list context_menu active">
<li class="files_menu" onclick="ChatActions_ajaxShowFiles('{{ contact.id|echapJS }}', false)">
<span class="primary icon gray">
<i class="material-symbols">perm_media</i>
</span>
<div>
<p class="line">{{ __('chatactions.files') }}</p>
</div>
</li>
<li onclick="ChatActions_ajaxGetContact('{{ contact.id|echapJS }}')">
<span class="primary icon gray">
<i class="material-symbols">person</i>
@@ -0,0 +1,57 @@
<section id="chat_files">
<header>
<ul class="list">
<li>
<div>
<p>{{ __('chatactions.files') }}</p>
</div>
</li>
</ul>
<ul class="tabs wide">
{% for tab in ["images", "videos", "recordings", "files"] %}
<li class="{% if loop.first %}active{% endif %}" data-tab="{{ tab }}" onclick="ChatActions.filesTab('{{ tab }}')">
<a href="#" onclick="event.preventDefault()">{{ __('chatactions.files_' ~ tab) }}</a>
</li>
{% endfor %}
</ul>
</header>
<div class="files_tab" data-tab="images">
<ul class="files_grid" data-list="images">
{% for f in images %}
<li data-published="{{ f.published }}">
<img src="{{ f.thumb }}" alt="{{ f.name }}" title="{{ f.date }}" loading="lazy"
onclick="Preview_ajaxHttpShow('{{ f.url|echapJS }}', {{ f.mid }})"/>
</li>
{% endfor %}
</ul>
</div>
{% for tab, list, icon in [["videos", videos, "movie"], ["recordings", recordings, "mic"], ["files", files, "description"]] %}
<div class="files_tab hide" data-tab="{{ tab }}">
<ul class="list active divided" data-list="{{ tab }}" data-icon="{{ icon }}">
{% for f in list %}
<li data-published="{{ f.published }}">
<span class="primary icon gray"><i class="material-symbols">{{ icon }}</i></span>
<span class="control icon active gray" title="{{ __('chatactions.files_message') }}"
onclick="event.stopPropagation(); Chat_ajaxGetMessageContext('{{ jid|echapJS }}', {{ f.mid }}); Drawer.clear()">
<i class="material-symbols">chat</i>
</span>
<div onclick="window.open('{{ f.url|echapJS }}', '_blank', 'noopener')">
<p class="line">{{ f.name }}</p>
{% if tab == "recordings" %}
<audio controls preload="none" src="{{ f.url }}" onclick="event.stopPropagation()"></audio>
{% endif %}
<p class="line">{% if f.size %}{{ f.size }} · {% endif %}{{ f.date }}</p>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
<div class="placeholder files_empty">
<i class="material-symbols">perm_media</i>
<h4>{{ __('chatactions.files_empty') }}</h4>
</div>
</section>
@@ -7,7 +7,7 @@
<span class="resource {{ color }}">{{ message.resource }}</span>
{% endif %}
{% if message.encrypted %}
<p class="encrypted">{% if message.retracted %}{{ __('message.retracted') }}{% else %}{{ __('message.encrypted') }}{% endif %} <i class="material-symbols fill">lock</i></p>
<p class="encrypted" data-mid="{{ message.mid }}">{% if message.retracted %}{{ __('message.retracted') }}{% else %}{{ __('message.encrypted') }}{% endif %} <i class="material-symbols fill">lock</i></p>
{% elif message.retracted %}
<p class="retracted">{{ __('message.retracted') }} <i class="material-symbols">delete</i></p>
{% elif picture %}
@@ -6,7 +6,7 @@
<form name="search" onsubmit="return false;">
<div>
<input name="keyword" autocomplete="off"
placeholder="{{ __('button.search') }}" oninput="ChatActions_ajaxSearchMessages('{{ jid|echapJS }}', this.value, {% if muc %}true{% else %}false{% endif %});" type="text">
placeholder="{{ __('button.search') }}" oninput="ChatActions.search('{{ jid|echapJS }}', this.value, {% if muc %}true{% else %}false{% endif %});" type="text">
</div>
</form>
</li>
+100
View File
@@ -0,0 +1,100 @@
"""Search (clear and OMEMO messages) and the shared files drawer, on a phone screen."""
import asyncio, os
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
S = os.path.dirname(os.path.abspath(__file__))
PHONE = {"width": 390, "height": 844}
async def upload(page, path):
await page.evaluate("Upload_ajaxGetPanel()")
await page.wait_for_selector("#upload", timeout=10000)
await page.set_input_files("input#file", path)
await page.wait_for_timeout(1500)
await page.click("#upload_button")
await page.wait_for_timeout(4000)
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "filesalice")
bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "filesbob")
await alice.wait_for_timeout(4000)
# Clear text messages and files
await alice.goto(BASE + "/chat/bob@localhost")
await alice.wait_for_selector("#chat_textarea", timeout=15000)
await alice.wait_for_timeout(2000)
await alice.fill("#chat_textarea", "Xin chào Bob, hẹn gặp lại")
await alice.press("#chat_textarea", "Enter")
await alice.wait_for_timeout(1500)
await upload(alice, f"{S}/test.png")
await upload(alice, f"{S}/notes.txt")
# OMEMO messages and an encrypted picture
await alice.evaluate("ChatOmemo.enableContactState('bob@localhost', false)")
await alice.wait_for_timeout(5000)
print("alice omemo:", await alice.evaluate("Chat.getOmemoState()"))
await alice.fill("#chat_textarea", "Bí mật: CHÀO buổi sáng")
await alice.press("#chat_textarea", "Enter")
await alice.wait_for_timeout(3000)
await upload(alice, f"{S}/test.png")
# Bob reads them (decrypted into his browser), on a phone
await bob.set_viewport_size(PHONE)
await bob.goto(BASE + "/chat/alice@localhost")
await bob.wait_for_selector("#chat_textarea", timeout=15000)
await bob.wait_for_timeout(6000)
await bob.screenshot(path=f"{OUT}/files_1_chat_phone.png")
print("bob bubbles:", (await bob.locator("#chat_widget .bubble p").all_inner_texts())[-4:])
# Search, without accents: finds the clear and the encrypted message
await bob.evaluate("ChatActions_ajaxShowSearchDialog('alice@localhost', false)")
await bob.wait_for_selector("form[name=search] input", timeout=10000)
await bob.wait_for_timeout(1000)
await bob.fill("form[name=search] input", "chao")
await bob.wait_for_timeout(3000)
print("search 'chao':", await bob.locator("#chat_search #message_preview li p").all_inner_texts())
await bob.screenshot(path=f"{OUT}/files_2_search_phone.png")
await bob.evaluate("Drawer.clear()")
await bob.wait_for_timeout(1000)
# Shared files
await bob.evaluate("ChatActions_ajaxShowFiles('alice@localhost', false)")
await bob.wait_for_selector("#chat_files", timeout=10000)
await bob.wait_for_timeout(5000)
for tab in ("images", "videos", "recordings", "files"):
count = await bob.locator(f"#chat_files [data-list={tab}] > li").count()
print(f"files {tab}:", count)
await bob.screenshot(path=f"{OUT}/files_3_images_phone.png")
await bob.evaluate("ChatActions.filesTab('files')")
await bob.wait_for_timeout(500)
await bob.screenshot(path=f"{OUT}/files_4_files_phone.png")
await bob.evaluate("ChatActions.filesTab('videos')")
await bob.wait_for_timeout(500)
await bob.screenshot(path=f"{OUT}/files_5_empty_phone.png")
await bob.evaluate("Drawer.clear()")
await bob.wait_for_timeout(1000)
# On a phone the files button moves to the menu
await bob.click("#chat_widget header .show_context_menu")
await bob.wait_for_timeout(1000)
print("phone: header button", await bob.locator("#chat_widget header .files_button").first.is_visible(),
"| menu item", await bob.locator("#chat_widget li.files_menu").first.is_visible())
await bob.screenshot(path=f"{OUT}/files_6_menu_phone.png")
await bob.keyboard.press("Escape")
await bob.set_viewport_size({"width": 1400, "height": 900})
await bob.wait_for_timeout(1000)
print("desktop: header button", await bob.locator("#chat_widget header .files_button").first.is_visible())
await bob.set_viewport_size(PHONE)
width = await bob.evaluate("document.documentElement.scrollWidth")
print("page width:", width, "(viewport 390)")
for e in aerr + berr:
print(e)
await browser.close()
asyncio.run(main())