vietnamese
This commit is contained in:
Generated
+1
@@ -2060,6 +2060,7 @@ dependencies = [
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-normalization",
|
||||
"uuid",
|
||||
"web-push-native",
|
||||
"xmpp-parsers",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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("<b>a</b>.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ẻ"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user