This commit is contained in:
nnduc
2026-10-24 14:03:52 +07:00
commit 4fe046de58
4790 changed files with 311146 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
//! Configuration page widgets: profile (Vcard4, Avatar), Config, Account,
//! NotificationConfig and Blocked. The Subscribe page creates accounts (XEP-0077).
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use serde_json::{json, Value};
use sha1::{Digest, Sha1};
use std::collections::BTreeMap;
use super::{contact_view, P};
use crate::{
app::Ctx,
db,
proto::{self, ns},
rpc, utils,
xmpp::{self, el, BuilderExt},
};
const ACCENT_COLORS: &[&str] = &["red", "pink", "purple", "dpurple", "indigo", "blue", "cyan", "teal", "green", "lgreen", "orange", "dorange"];
fn languages(ctx: &Ctx) -> BTreeMap<String, String> {
std::fs::read_to_string(ctx.app.cfg.frontend.join("languages.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn config_form(ctx: &Ctx) -> String {
let user = ctx.me().unwrap_or_default();
let domain = ctx.base_uri().trim_matches('/').to_string();
ctx.render(
"Config/_config_form.html",
json!({ "user": user, "languages": languages(ctx), "accent_colors": ACCENT_COLORS, "domain": domain }),
)
}
async fn vcard_form(ctx: &Ctx) -> String {
let contact = db::contact_get_or_new(ctx.db(), &ctx.jid()).await;
ctx.render("Vcard4/_vcard4_form.html", json!({ "contact": contact }))
}
async fn avatar_header(ctx: &Ctx) -> String {
let me = contact_view(ctx, &ctx.jid()).await;
ctx.render("Avatar/_avatar.html", json!({ "me": me }))
}
pub async fn build(ctx: &Ctx, name: &str) -> String {
match name {
"Config" => ctx.render(
"Config/config.html",
json!({ "form": config_form(ctx), "nickname": ctx.me().and_then(|u| u.nickname) }),
),
"Vcard4" => ctx.render("Vcard4/vcard4.html", json!({ "form": vcard_form(ctx).await })),
"Account" => ctx.render("Account/account.html", json!({ "omemo": ctx.me().is_some_and(|u| u.omemoenabled) })),
"Blocked" => ctx.render("Blocked/blocked.html", json!({})),
"NotificationConfig" => ctx.render("NotificationConfig/notificationconfig.html", json!({})),
_ => String::new(),
}
}
pub async fn on_blocklist_changed(ctx: &Ctx) {
let mut list: Vec<String> = ctx.st(|st| st.blocked.iter().cloned().collect()).unwrap_or_default();
list.sort();
rpc!(ctx, "MovimTpl.fill", "#blocked_widget_list", ctx.render("Blocked/_blocked_list.html", json!({ "list": list })));
}
async fn reload_user(ctx: &Ctx) {
let jid = ctx.jid();
if let Ok(Some(u)) = db::user_get(ctx.db(), &jid).await {
ctx.st(|st| {
if let Some(lang) = u.language.clone().filter(|l| crate::i18n::get().has_language(l)) {
st.lang = lang;
} else {
st.lang = "en".into();
}
st.user = Some(u);
});
}
}
fn form_bool(p: &P<'_>, field: &str) -> Option<bool> {
p.v(0)?.get(field)?.get("value").map(|v| match v {
Value::Bool(b) => *b,
Value::String(s) => s == "true" || s == "on" || s == "1",
_ => false,
})
}
pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
let me = ctx.jid();
match (widget, func) {
// ------------------------------------------------ Config
("Config", "ajaxSubmit") => {
for field in ["notificationcall", "notificationchat", "omemoenabled", "nightmode"] {
if let Some(b) = form_bool(p, field) {
let _ = db::user_set(ctx.db(), &me, field, &json!(b)).await;
}
}
if let Some(lang) = p.form(0, "language") {
if lang == "en" || languages(ctx).contains_key(&lang) {
let _ = db::user_set(ctx.db(), &me, "language", &json!(lang)).await;
}
}
if let Some(color) = p.form(0, "accentcolor").filter(|c| ACCENT_COLORS.contains(&c.as_str())) {
let _ = db::user_set(ctx.db(), &me, "accentcolor", &json!(color)).await;
}
reload_user(ctx).await;
if let Some(u) = ctx.me() {
rpc!(ctx, "Config.updateSystemVariable", "NOTIFICATION_CALL", u.notificationcall);
rpc!(ctx, "Config.updateSystemVariable", "NOTIFICATION_CHAT", u.notificationchat);
}
rpc!(ctx, "MovimTpl.fill", "#config_widget_form", config_form(ctx));
ctx.toast(&ctx.t("config.updated", &[]));
}
("Config", "ajaxMAMGetConfig") => {
let Some(x) = ctx.xmpp() else { return true };
if let Ok(res) = x.iq(xmpp::iq("get", None, el("prefs", ns::MAM).build())).await {
let default = res.get_child("prefs", ns::MAM).and_then(|p| p.attr("default")).unwrap_or("always").to_string();
rpc!(ctx, "MovimTpl.fill", "#config_widget_mam", ctx.render("Config/_config_mam.html", json!({ "default": default })));
}
}
("Config", "ajaxMAMSetConfig") => {
let value = p.str(0);
if !["never", "roster", "always"].contains(&value.as_str()) {
return true;
}
let Some(x) = ctx.xmpp() else { return true };
let prefs = el("prefs", ns::MAM).a("default", value).build();
match x.iq(xmpp::iq("set", None, prefs)).await {
Ok(_) => ctx.toast(&ctx.t("config.mam_saved", &[])),
Err(e) => ctx.toast(&e.to_string()),
}
}
("Config", "ajaxEditNickname") => {
let nickname = ctx.me().and_then(|u| u.nickname).unwrap_or_default();
super::dialog(ctx, &ctx.render("Config/_config_nickname.html", json!({ "nickname": nickname })), false, false);
}
("Config", "ajaxSaveNickname") => {
let nick = p.str(0).trim().to_string();
if nick.is_empty() || nick.chars().count() > 64 {
ctx.toast(&ctx.t("profile.nickname_error", &[]));
return true;
}
let _ = db::user_set(ctx.db(), &me, "nickname", &json!(nick)).await;
reload_user(ctx).await;
rpc!(ctx, "Dialog_ajaxClear");
ctx.toast(&ctx.t("profile.nickname_saved", &[]));
}
("Config", _) => {}
// ------------------------------------------------ Vcard4
("Vcard4", "ajaxGetVcard") => {
crate::pep::refresh_profile(ctx, &me).await;
rpc!(ctx, "MovimTpl.fill", "#vcard_form", vcard_form(ctx).await);
}
("Vcard4", "ajaxVcardSubmit") => save_vcard(ctx, p).await,
// ------------------------------------------------ Avatar
("Avatar", "ajaxHttpGetCurrent") | ("Avatar", "ajaxGetAvatar") => {
rpc!(ctx, "MovimTpl.fill", "#avatar", avatar_header(ctx).await);
}
("Avatar", "ajaxGetForm") => {
let me_view = contact_view(ctx, &me).await;
super::dialog(ctx, &ctx.render("Avatar/_avatar_form.html", json!({ "me": me_view })), false, false);
}
("Avatar", "ajaxSubmit") => {
let data = p.form(0, "photobin").unwrap_or_default();
publish_avatar(ctx, &data).await;
}
("Avatar", _) => {}
// ------------------------------------------------ Account
("Account", "ajaxHttpGetPresences") => {
let current = ctx.st(|st| st.resource.clone()).unwrap_or_default();
let presences: Vec<Value> = ctx
.st(|st| {
st.presences
.get(&me)
.map(|p| {
p.values()
.map(|p| {
let name = p.ver.as_ref().and_then(|v| st.caps.get(v)).and_then(|(n, _)| n.clone());
json!({ "resource": p.resource, "presence": p.key(), "name": name, "current": p.resource == current })
})
.collect()
})
.unwrap_or_default()
})
.unwrap_or_default();
if !presences.is_empty() {
rpc!(ctx, "MovimTpl.fill", "#account_presences", ctx.render("Account/_account_presences.html", json!({ "presences": presences })));
}
}
("Account", "ajaxHttpGetFingerprints") => {
let list: Vec<Value> = p
.v(0)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.map(|f| {
json!({
"jid": f.get("jid").and_then(|v| v.as_str()).unwrap_or(&me),
"bundleid": f.get("bundleid").map(|b| b.as_str().map(String::from).unwrap_or_else(|| b.to_string())),
"self": f.get("self").and_then(|v| v.as_bool()).unwrap_or(false),
"fingerprint": super::contactactions::fingerprint(f.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("")),
})
})
.collect()
})
.unwrap_or_default();
rpc!(ctx, "MovimTpl.fill", "#account_fingerprints", ctx.render("Account/_account_fingerprints.html", json!({ "fingerprints": list })));
rpc!(ctx, "Account.resolveSessionsStates");
}
("Account", "ajaxDeleteBundle") => {
let f = p.v(0).cloned().unwrap_or_default();
let fingerprint = json!({
"bundleid": f.get("bundleid"),
"fingerprint": super::contactactions::fingerprint(f.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("")),
});
super::dialog(ctx, &ctx.render("Account/_account_delete_bundle.html", json!({ "fingerprint": fingerprint })), false, false);
}
("Account", "ajaxDeleteBundleConfirm") => {
let id = p.str(0);
let devices: Vec<String> = p
.v(1)
.and_then(|v| v.as_array())
.map(|a| a.iter().map(|d| d.as_str().map(String::from).unwrap_or_else(|| d.to_string())).filter(|d| *d != id).collect())
.unwrap_or_default();
if let Some(x) = ctx.xmpp() {
let q = el("pubsub", ns::PUBSUB_OWNER).append(el("delete", ns::PUBSUB_OWNER).a("node", format!("{}{}", ns::OMEMO_BUNDLES_PREFIX, id)).build()).build();
let _ = x.iq(xmpp::iq("set", None, q)).await;
}
let mut list = el("list", ns::OMEMO);
for d in &devices {
list = list.append(el("device", ns::OMEMO).a("id", d.as_str()).build());
}
if let Some(x) = ctx.xmpp() {
let options = proto::publish_options(&[("pubsub#access_model", "open")]);
let _ = x.iq(proto::pubsub_publish(None, ns::OMEMO_DEVICELIST, Some("current"), list.build(), Some(options))).await;
}
rpc!(ctx, "Account.refreshFingerprints");
}
("Account", "ajaxChangePassword") => {
super::dialog(ctx, &ctx.render("Account/_account_password.html", json!({})), false, false);
}
("Account", "ajaxChangePasswordConfirm") => {
let (pass, confirm) = (p.form(0, "password").unwrap_or_default(), p.form(0, "password_confirmation").unwrap_or_default());
if pass.is_empty() || pass != confirm {
ctx.toast(&ctx.t("account.password_not_same", &[]));
rpc!(ctx, "Account.resetPassword");
return true;
}
let Some(x) = ctx.xmpp() else { return true };
let username = me.split('@').next().unwrap_or("").to_string();
let host = me.split('@').nth(1).unwrap_or("").to_string();
let q = el("query", ns::REGISTER)
.append(el("username", ns::REGISTER).append(username).build())
.append(el("password", ns::REGISTER).append(pass).build())
.build();
rpc!(ctx, "Dialog_ajaxClear");
match x.iq(xmpp::iq("set", Some(&host), q)).await {
Ok(_) => {
ctx.toast(&ctx.t("account.password_changed", &[]));
let _ = db::encrypted_passwords_clear(ctx.db(), &me).await;
rpc!(ctx, "Presence_ajaxLogout");
}
Err(e) => {
ctx.toast(&e.to_string());
rpc!(ctx, "Account.resetPassword");
}
}
}
("Account", "ajaxClearAccount") => {
super::dialog(ctx, &ctx.render("Account/_account_clear.html", json!({})), false, false);
}
("Account", "ajaxClearAccountConfirm") => {
let _ = db::user_delete_data(ctx.db(), &me).await;
rpc!(ctx, "Presence_ajaxLogout");
}
("Account", "ajaxRemoveAccount") => {
super::dialog(ctx, &ctx.render("Account/_account_remove.html", json!({})), false, false);
}
("Account", "ajaxRemoveAccountConfirm") => {
if p.form(0, "jid").as_deref() != Some(me.as_str()) {
ctx.toast(&ctx.t("account.delete_text_error", &[]));
return true;
}
let Some(x) = ctx.xmpp() else { return true };
let host = me.split('@').nth(1).unwrap_or("").to_string();
match x.iq(xmpp::iq("set", Some(&host), el("query", ns::REGISTER).append(el("remove", ns::REGISTER).build()).build())).await {
Ok(_) => {
let _ = db::user_delete_data(ctx.db(), &me).await;
rpc!(ctx, "Presence.clearQuick");
rpc!(ctx, "MovimUtils.redirect", ctx.route("disconnect", &[]));
}
Err(e) => ctx.toast(&e.to_string()),
}
}
("Account", _) => {}
// ------------------------------------------------ Blocked
("Blocked", "ajaxGet") => on_blocklist_changed(ctx).await,
("Blocked", "ajaxUnblock") => super::contactactions::block(ctx, &p.str(0), false).await,
// ------------------------------------------------ Push notifications
("NotificationConfig", "ajaxHttpRequest") => {
rpc!(ctx, "MovimTpl.fill", "#notificationconfig_widget_request", ctx.render("NotificationConfig/_notificationconfig_request.html", json!({})));
}
("NotificationConfig", "ajaxHttpPushGetConfig") => {
let current = p.s(0);
let list: Vec<Value> = db::push_subscriptions_list(ctx.db(), &me)
.await
.unwrap_or_default()
.into_iter()
.map(|s| {
let host = s.endpoint.split("://").nth(1).and_then(|h| h.split('/').next()).unwrap_or("").to_string();
json!({
"id": s.id,
"enabled": s.enabled,
"self": current.as_deref() == Some(s.endpoint.as_str()),
"activity": ctx.prepare_date(&s.activity_at, true, true),
"host": host,
})
})
.collect();
rpc!(ctx, "MovimTpl.fill", "#notificationconfig_widget_push", ctx.render("NotificationConfig/_notificationconfig_push.html", json!({ "subscriptions": list })));
}
("NotificationConfig", "ajaxTogglePushConfig") => {
let enabled = p.b(1);
let _ = db::push_subscription_toggle(ctx.db(), &me, p.i(0).unwrap_or(0), enabled).await;
ctx.toast(&ctx.t(if enabled { "notificationconfig.push_enabled" } else { "notificationconfig.push_disabled" }, &[]));
}
("NotificationConfig", _) => {}
_ => return false,
}
true
}
async fn save_vcard(ctx: &Ctx, p: &P<'_>) {
let me = ctx.jid();
let Some(x) = ctx.xmpp() else { return };
let field = |f: &str| p.form(0, f).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let mut contact = db::contact_get_or_new(ctx.db(), &me).await;
contact.fn_ = field("fn");
contact.nickname = field("name");
contact.email = field("email").filter(|e| e.contains('@'));
contact.url = field("url").filter(|u| u.starts_with("http://") || u.starts_with("https://"));
contact.description = field("desc");
let _ = db::contact_save(ctx.db(), &contact).await;
// XEP-0292 vCard4 over PEP
let mut vcard = el("vcard", ns::VCARD4);
let text = |name: &str, kind: &str, value: &str| el(name, ns::VCARD4).append(el(kind, ns::VCARD4).append(value).build()).build();
if let Some(v) = &contact.fn_ {
vcard = vcard.append(text("fn", "text", v));
}
if let Some(v) = &contact.nickname {
vcard = vcard.append(text("nickname", "text", v));
}
if let Some(v) = &contact.email {
vcard = vcard.append(text("email", "text", v));
}
if let Some(v) = &contact.url {
vcard = vcard.append(text("url", "uri", v));
}
if let Some(v) = &contact.description {
vcard = vcard.append(text("note", "text", v));
}
let options = proto::publish_options(&[("pubsub#access_model", "open"), ("pubsub#persist_items", "true")]);
let res = x.iq(proto::pubsub_publish(None, ns::VCARD4_NODE, Some("current"), vcard.build(), Some(options))).await;
// XEP-0172 nickname
if let Some(nick) = &contact.nickname {
let options = proto::publish_options(&[("pubsub#access_model", "presence")]);
x.iq_spawn(proto::pubsub_publish(None, ns::NICK, None, el("nick", ns::NICK).append(nick.as_str()).build(), Some(options)));
}
match res {
Ok(_) => ctx.toast(&ctx.t("vcard.updated", &[])),
Err(e) => ctx.toast(&format!("{}: {e}", ctx.t("vcard.not_updated", &[]))),
}
rpc!(ctx, "MovimTpl.fill", "#vcard_form", vcard_form(ctx).await);
super::presence::on_my_presence(ctx).await;
}
/// XEP-0084 User Avatar, plus the vCard photo (XEP-0153) for older clients
async fn publish_avatar(ctx: &Ctx, data_b64: &str) {
let me = ctx.jid();
let Some(x) = ctx.xmpp() else { return };
let Ok(bytes) = B64.decode(data_b64.trim()) else { return };
if bytes.is_empty() {
return;
}
let (width, height) = image::load_from_memory(&bytes).map(|i| (i.width(), i.height())).unwrap_or((384, 384));
let hash = hex::encode(Sha1::digest(&bytes));
let b64 = B64.encode(&bytes);
let data_res = x
.iq(proto::pubsub_publish(
None,
ns::AVATAR_DATA,
Some(&hash),
el("data", ns::AVATAR_DATA).append(b64.clone()).build(),
Some(proto::publish_options(&[("pubsub#access_model", "open")])),
))
.await;
if let Err(e) = data_res {
let text = if e.to_string().contains("payload-too-big") { ctx.t("avatar.too_big", &[]) } else { ctx.t("avatar.not_updated", &[]) };
ctx.toast(&text);
return;
}
let metadata = el("metadata", ns::AVATAR_METADATA)
.append(
el("info", ns::AVATAR_METADATA)
.a("id", hash.clone())
.a("bytes", bytes.len().to_string())
.a("type", "image/jpeg")
.a("width", width.to_string())
.a("height", height.to_string())
.build(),
)
.build();
let _ = x
.iq(proto::pubsub_publish(None, ns::AVATAR_METADATA, Some(&hash), metadata, Some(proto::publish_options(&[("pubsub#access_model", "open")]))))
.await;
// vcard-temp, keeping the other fields
if let Ok(res) = x.iq(proto::vcard_get(None)).await {
let mut vcard = res.get_child("vCard", ns::VCARD).cloned().unwrap_or_else(|| el("vCard", ns::VCARD).build());
vcard.remove_child("PHOTO", ns::VCARD);
vcard.append_child(
el("PHOTO", ns::VCARD)
.append(el("TYPE", ns::VCARD).append("image/jpeg").build())
.append(el("BINVAL", ns::VCARD).append(b64).build())
.build(),
);
x.iq_spawn(xmpp::iq("set", None, vcard));
}
let _ = db::avatar_save(ctx.db(), &hash, "image/jpeg", &bytes).await;
let _ = db::contact_set_avatar(ctx.db(), &me, Some(&hash), "pep").await;
rpc!(ctx, "Dialog_ajaxClear");
rpc!(ctx, "MovimTpl.fill", "#avatar", avatar_header(ctx).await);
super::presence::on_my_presence(ctx).await;
ctx.toast(&ctx.t("avatar.updated", &[]));
let _ = utils::bare_jid;
}
// ---------------------------------------------------------------- Account creation
pub async fn build_subscribe(ctx: &Ctx) -> String {
super::register::build(ctx).await
}
+1230
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
//! ChatActions widget: actions on a message (react, copy, reply, edit,
//! retract, moderate), search in a discussion, URL previews.
use serde_json::json;
use super::{chat, contactactions, rooms, P};
use crate::{app::Ctx, db, proto, rpc, utils};
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxGetContact" => contactactions::get_drawer(ctx, &p.str(0)).await,
"ajaxBlock" => contactactions::block(ctx, &p.str(0), true).await,
"ajaxUnblock" => contactactions::block(ctx, &p.str(0), false).await,
"ajaxShowMessageDialog" => show_message_dialog(ctx, p.i(0).unwrap_or(0)).await,
"ajaxShowSearchDialog" => {
let html = ctx.render(
"ChatActions/_chatactions_search.html",
json!({
"jid": p.str(0),
"muc": p.b(1),
"placeholder": ctx.render("ChatActions/_chatactions_search_placeholder.html", json!({})),
}),
);
super::drawer(ctx, "chat_search", &html, false, false);
rpc!(ctx, "ChatActions.focusSearch");
}
"ajaxSearchMessages" => search(ctx, &p.str(0), &p.str(1), p.b(2)).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,
"ajaxResolveMessage" => resolve_message(ctx, p.i(0).unwrap_or(0)).await,
"ajaxResolveUrl" => {
let url = p.str(0);
if let Some(html) = super::preview::embed_html(ctx, &url).await {
rpc!(ctx, "MovimTpl.fill", "#embed", html);
}
rpc!(ctx, "Chat.finishedSending");
rpc!(ctx, "Chat.disableSending");
}
"ajaxEditMessage" | "ajaxEnableWideConferenceCall" => {}
_ => return false,
}
true
}
async fn prepare_message(ctx: &Ctx, m: &db::Message, search: bool, headline: Option<String>) -> String {
let me = ctx.jid();
let file = db::message_file(ctx.db(), m.mid).await.ok().flatten();
let picture = file
.as_ref()
.filter(|f| utils::type_is_picture(f.type_.as_deref().unwrap_or("")))
.map(|f| format!("{}picture?url={}", ctx.base_uri(), percent_encoding::utf8_percent_encode(&f.url, percent_encoding::NON_ALPHANUMERIC)));
let body = headline.unwrap_or_else(|| chat::format_body(ctx, m.body.as_deref().unwrap_or("").trim()).0);
let mine = if m.is_muc() { rooms::my_nick(ctx, &m.jidfrom) == m.resource } else { m.jidfrom == me };
let date = if search { ctx.prepare_date(&m.published, false, true) } else { ctx.prepare_time(&m.published) };
ctx.render(
"ChatActions/_chatactions_message.html",
json!({
"message": m,
"jid": m.jid(),
"mine": mine,
"muc": m.is_muc(),
"color": m.color(),
"picture": picture,
"body": body,
"date": date,
}),
)
}
async fn show_message_dialog(ctx: &Ctx, mid: i64) {
let me = ctx.jid();
let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return };
if !m.is_classic() || m.retracted {
return;
}
let preview = prepare_message(ctx, &m, false, None).await;
let reactions = db::reactions_for(ctx.db(), &[mid]).await.unwrap_or_default();
let mut merged: Vec<(String, Vec<String>)> = vec![];
for r in reactions {
let who = if m.is_muc() { r.jidfrom.clone() } else { super::contact_view(ctx, &r.jidfrom).await.truename };
match merged.iter_mut().find(|(e, _)| *e == r.emoji) {
Some((_, f)) => f.push(who),
None => merged.push((r.emoji.clone(), vec![who])),
}
}
let reactions: Vec<_> = merged.into_iter().map(|(e, f)| json!({ "emoji": e, "from": f })).collect();
let mine = if m.is_muc() { rooms::my_nick(ctx, &m.jidfrom) == m.resource && m.resource.is_some() } else { m.jidfrom == me };
let has_file = db::message_file(ctx.db(), mid).await.ok().flatten().is_some();
let editable = mine && !m.encrypted && !has_file;
let moderator = m.is_muc()
&& m.stanzaid.is_some()
&& ctx
.st(|st| {
st.muc_self(&m.jidfrom).is_some_and(|p| p.role == "moderator")
&& st.conferences.get(&m.jidfrom).is_some_and(|c| c.features.iter().any(|f| f.starts_with("urn:xmpp:message-moderate")))
})
.unwrap_or(false);
let mut for_js = serde_json::to_value(&m).unwrap_or(json!({}));
for_js["body"] = json!(utils::html_escape(m.body.as_deref().unwrap_or("")));
rpc!(ctx, "ChatActions.setMessage", for_js);
let html = ctx.render(
"ChatActions/_chatactions_message_dialog.html",
json!({
"message": m,
"preview": preview,
"reactions": reactions,
"mine": mine,
"editable": editable,
"moderator": moderator,
}),
);
super::dialog(ctx, &html, false, false);
}
async fn search(ctx: &Ctx, jid: &str, keywords: &str, muc: bool) {
if !utils::validate_jid(jid) {
return;
}
let keywords = keywords.trim().to_lowercase();
if keywords.is_empty() {
rpc!(ctx, "MovimTpl.fill", "#chat_search", ctx.render("ChatActions/_chatactions_search_placeholder.html", json!({})));
return;
}
let me = ctx.jid();
let messages = db::messages_search(ctx.db(), &me, jid, muc, &keywords, 20).await.unwrap_or_default();
let mut list = vec![];
for m in &messages {
let body = m.body.as_deref().unwrap_or("");
let escaped = utils::html_escape(body);
let highlighted = highlight(&escaped, &utils::html_escape(&keywords));
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 })));
}
fn highlight(text: &str, keywords: &str) -> String {
let lower = text.to_lowercase();
let mut out = String::new();
let mut last = 0;
for word in keywords.split_whitespace() {
let _ = word;
}
let first = keywords.split_whitespace().next().unwrap_or("");
if first.is_empty() {
return text.to_string();
}
let mut start = 0;
while let Some(pos) = lower[start..].find(first) {
let begin = start + pos;
let end = begin + first.len();
if !text.is_char_boundary(begin) || !text.is_char_boundary(end) {
break;
}
out.push_str(&text[last..begin]);
out.push_str("<mark>");
out.push_str(&text[begin..end]);
out.push_str("</mark>");
last = end;
start = end;
}
out.push_str(&text[last..]);
out
}
async fn retract(ctx: &Ctx, mid: i64) {
let me = ctx.jid();
let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return };
let muc = m.is_muc();
let id = if muc { m.stanzaid.clone() } else { m.originid.clone().or(m.messageid.clone()) };
let Some(id) = id else { return };
rpc!(ctx, "Dialog.clear");
let to = if muc { m.jidfrom.clone() } else { m.jidto.clone() };
ctx.send(proto::retract(&to, muc, &id));
if !muc {
let _ = db::message_retract(ctx.db(), mid).await;
chat::refresh_message(ctx, mid).await;
super::chats::refresh_item(ctx, &to).await;
}
}
async fn moderate(ctx: &Ctx, mid: i64) {
let me = ctx.jid();
let Some(m) = db::message_by_mid(ctx.db(), &me, mid).await.ok().flatten() else { return };
let Some(sid) = m.stanzaid.clone() else { return };
rpc!(ctx, "Dialog.clear");
if let Some(x) = ctx.xmpp() {
if let Err(e) = x.iq(proto::moderate(&m.jidfrom, &sid, None)).await {
ctx.toast(&e.to_string());
}
}
}
/// Link preview of a message: resolve its first URL, a picture link becomes a picture
async fn resolve_message(ctx: &Ctx, mid: i64) {
let me = ctx.jid();
let Ok(Some(m)) = db::message_by_mid(ctx.db(), &me, mid).await else { return };
if m.resolved || m.encrypted || m.retracted {
return;
}
let body = m.body.clone().unwrap_or_default();
let Some(url) = utils::find_url(&body) else { return };
let _ = db::message_set_resolved(ctx.db(), mid).await;
let Some(info) = super::preview::resolve(&ctx.app, &url).await else { return };
if info.type_ == "image" && body.trim() == url && db::message_file(ctx.db(), mid).await.ok().flatten().is_none() {
let file = db::MessageFile {
message_mid: mid,
name: info.title.clone(),
type_: Some(info.content_type.clone()),
size: Some(info.content_length),
url: url.clone(),
..Default::default()
};
let _ = db::message_file_save(ctx.db(), &file).await;
}
rpc!(ctx, "Chat.refreshMessage", mid);
}
+247
View File
@@ -0,0 +1,247 @@
//! ChatOmemo widget: OMEMO (XEP-0384, legacy `eu.siacs.conversations.axolotl`
//! namespace, compatible with Conversations). The cryptography runs in the
//! browser (libsignal), the server publishes and fetches device lists and bundles.
use minidom::Element;
use rand::seq::IteratorRandom;
use serde_json::{json, Value};
use super::P;
use crate::{
app::Ctx,
proto::{self, ns},
rpc,
xmpp::{child_text, el, BuilderExt},
};
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxGetBundle" => {
let (jid, id) = (p.str(0), p.str(1));
let ctx = ctx.clone();
tokio::spawn(async move {
get_bundle(&ctx, &jid, &id, false).await;
});
}
"ajaxGetDevicesList" => {
let to = p.str(0);
ctx.toast(&ctx.t("omemo.resolving_devices", &[]));
let ctx = ctx.clone();
tokio::spawn(async move {
get_devices_list(&ctx, &to).await;
});
}
"ajaxAnnounceBundle" => announce_bundle(ctx, p.v(0).cloned().unwrap_or_default(), p.v(1).cloned().unwrap_or_default()).await,
"ajaxCleanDevicesList" => {
let ids: Vec<String> = p.v(0).and_then(|v| v.as_array()).map(|a| a.iter().map(id_string).collect()).unwrap_or_default();
publish_devices(ctx, &ids).await;
}
"ajaxEnableContactState" => {
ctx.toast(&ctx.t("omemo.enable_contact", &[]));
set_state(ctx, true).await;
}
"ajaxEnableRoomState" => {
ctx.toast(&ctx.t("omemo.enable_room", &[]));
set_state(ctx, true).await;
}
"ajaxDisableContactState" => {
ctx.toast(&ctx.t("omemo.disable_contact", &[]));
set_state(ctx, false).await;
}
"ajaxDisableRoomState" => {
ctx.toast(&ctx.t("omemo.disable_room", &[]));
set_state(ctx, false).await;
}
"ajaxNotifyGeneratedBundle" => ctx.toast(&ctx.t("omemo.generated_bundle", &[])),
_ => return false,
}
true
}
/// Remember the state for the open discussion, to show the lock in the header
async fn set_state(ctx: &Ctx, enabled: bool) {
if let Some(key) = super::notif::current(ctx) {
if let Some(jid) = key.strip_prefix("chat|") {
let _ = crate::db::omemo_state_set(ctx.db(), &ctx.jid(), jid, enabled).await;
}
}
}
fn id_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub async fn on_devicelist(ctx: &Ctx, from: &str, devices: Vec<i64>) {
let ids: Vec<String> = devices.iter().map(|d| d.to_string()).collect();
ctx.st(|st| st.omemo_devices.insert(from.to_string(), devices.clone()));
if from == ctx.jid() {
rpc!(ctx, "ChatOmemo.ownDevicesReceived", from, ids);
} else {
rpc!(ctx, "ChatOmemo.devicesReceived", from, ids);
}
}
pub async fn refresh_own_devices(ctx: &Ctx) {
if !ctx.me().is_some_and(|u| u.omemoenabled) {
return;
}
let me = ctx.jid();
get_devices_list(ctx, &me).await;
}
async fn get_devices_list(ctx: &Ctx, to: &str) {
let Some(x) = ctx.xmpp() else { return };
let me = ctx.jid();
let target = if to == me { None } else { Some(to) };
let res = x.iq(proto::pubsub_items(target, ns::OMEMO_DEVICELIST, None, None)).await;
let devices: Vec<String> = match res {
Ok(res) => {
let items = res.get_child("pubsub", ns::PUBSUB).and_then(|p| p.get_child("items", ns::PUBSUB));
let mut ids = vec![];
if let Some(items) = items {
let count = items.children().filter(|c| c.name() == "item").count();
for item in items.children().filter(|c| c.name() == "item") {
if item.attr("id") == Some("current") || count == 1 {
if let Some(list) = item.get_child("list", ns::OMEMO) {
for d in list.children().filter_map(|d| d.attr("id")) {
if !ids.contains(&d.to_string()) {
ids.push(d.to_string());
}
}
}
}
}
}
ids
}
Err(_) => {
if to == me {
rpc!(ctx, "ChatOmemo.initiateBundle", Vec::<String>::new());
}
rpc!(ctx, "ChatOmemo.bundlesRefreshError", to);
return;
}
};
if to == me {
rpc!(ctx, "ChatOmemo.ownDevicesReceived", to, devices.clone());
return;
}
if devices.is_empty() {
rpc!(ctx, "ChatOmemo.bundlesRefreshError", to);
return;
}
let total = devices.len();
for (i, id) in devices.iter().enumerate() {
get_bundle(ctx, to, id, i + 1 == total).await;
}
}
async fn get_bundle(ctx: &Ctx, jid: &str, id: &str, notify_last: bool) {
let Some(x) = ctx.xmpp() else { return };
let node = format!("{}{}", ns::OMEMO_BUNDLES_PREFIX, id);
let target = if jid == ctx.jid() { None } else { Some(jid) };
match x.iq(proto::pubsub_items(target, &node, None, None)).await {
Ok(res) => {
let bundle = res
.get_child("pubsub", ns::PUBSUB)
.and_then(|p| p.get_child("items", ns::PUBSUB))
.and_then(|i| i.children().find(|c| c.name() == "item"))
.and_then(|i| i.get_child("bundle", ns::OMEMO));
if let Some(bundle) = bundle {
if let Some(prekey) = extract_prekey(jid, bundle) {
rpc!(ctx, "ChatOmemo.handlePreKey", jid, id, prekey);
}
}
}
Err(e) => tracing::debug!("bundle {jid}/{id}: {e}"),
}
if notify_last {
rpc!(ctx, "ChatOmemo.bundlesRefreshed", jid);
}
}
fn extract_prekey(jid: &str, bundle: &Element) -> Option<Value> {
let spk = bundle.children().find(|c| c.name() == "signedPreKeyPublic")?;
let prekeys = bundle.children().find(|c| c.name() == "prekeys")?;
let picked = prekeys.children().filter(|c| c.name() == "preKeyPublic").choose(&mut rand::rng())?;
let id: i64 = picked.attr("preKeyId").and_then(|i| i.parse().ok())?;
Some(json!({
"jid": jid,
"identitykey": child_text(bundle, "identityKey").unwrap_or_default(),
"signedprekeypublic": spk.text(),
"signedprekeyid": spk.attr("signedPreKeyId").and_then(|i| i.parse::<i64>().ok()).unwrap_or(1),
"signedprekeysignature": child_text(bundle, "signedPreKeySignature").unwrap_or_default(),
"prekey": { "id": id, "value": picked.text() },
}))
}
async fn announce_bundle(ctx: &Ctx, bundle: Value, devices: Value) {
let Some(x) = ctx.xmpp() else { return };
let device_id = bundle.get("deviceId").map(id_string).unwrap_or_default();
if device_id.is_empty() {
return;
}
let mut ids: Vec<String> = match &devices {
Value::Array(a) => a.iter().map(id_string).collect(),
Value::Object(o) => o.values().map(id_string).collect(),
_ => vec![],
};
if !ids.contains(&device_id) {
ids.push(device_id.clone());
}
let signed = bundle.get("signedPreKey").cloned().unwrap_or_default();
if !signed.get("publicKey").is_some_and(|v| v.is_string()) {
// First pass of initiateBundle (raw ArrayBuffers): ignore it, the device
// will be missing from the list so the browser publishes it again encoded
return;
}
let mut prekeys = el("prekeys", ns::OMEMO);
if let Some(list) = bundle.get("preKeys").and_then(|p| p.as_array()) {
for (i, k) in list.iter().enumerate() {
let key = match k.get("key") {
Some(Value::String(s)) => s.clone(),
// ArrayBuffers are serialized as {} by JSON.stringify: skip them
_ => continue,
};
let kid = k.get("id").or_else(|| k.get("keyId")).and_then(|v| v.as_i64()).unwrap_or(i as i64);
prekeys = prekeys.append(el("preKeyPublic", ns::OMEMO).a("preKeyId", kid.to_string()).append(key).build());
}
}
let payload = el("bundle", ns::OMEMO)
.append(
el("signedPreKeyPublic", ns::OMEMO)
.a("signedPreKeyId", signed.get("id").or_else(|| signed.get("keyId")).and_then(|v| v.as_i64()).unwrap_or(1).to_string())
.append(signed.get("publicKey").and_then(|v| v.as_str()).unwrap_or("").to_string())
.build(),
)
.append(el("signedPreKeySignature", ns::OMEMO).append(signed.get("signature").and_then(|v| v.as_str()).unwrap_or("").to_string()).build())
.append(el("identityKey", ns::OMEMO).append(bundle.get("identityKey").map(id_string).unwrap_or_default()).build())
.append(prekeys.build())
.build();
let node = format!("{}{}", ns::OMEMO_BUNDLES_PREFIX, device_id);
let options = proto::publish_options(&[("pubsub#access_model", "open"), ("pubsub#max_items", "max")]);
if let Err(e) = x.iq(proto::pubsub_publish(None, &node, Some("current"), payload, Some(options))).await {
tracing::info!("bundle publication failed: {e}");
}
publish_devices(ctx, &ids).await;
}
async fn publish_devices(ctx: &Ctx, ids: &[String]) {
let Some(x) = ctx.xmpp() else { return };
let mut list = el("list", ns::OMEMO);
for id in ids {
list = list.append(el("device", ns::OMEMO).a("id", id.as_str()).build());
}
let options = proto::publish_options(&[("pubsub#access_model", "open")]);
if let Err(e) = x.iq(proto::pubsub_publish(None, ns::OMEMO_DEVICELIST, Some("current"), list.build(), Some(options))).await {
tracing::info!("devicelist publication failed: {e}");
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Chats widget: the list of open 1:1 conversations.
use serde_json::{json, Value};
use super::{chat, contact_view, notif, P};
use crate::{app::Ctx, db, handler::Origin, rpc, utils};
const FILTERS: &[&str] = &["all", "roster"];
pub fn build(ctx: &Ctx) -> String {
let filter = ctx.st(|st| st.chats_filter.clone()).unwrap_or_else(|| "all".into());
ctx.render(
"Chats/chats.html",
json!({
"filters": FILTERS,
"filter": filter,
"addplaceholder": ctx.t("chats.empty", &["<i class=\"material-symbols\">chat_add_on</i>"]),
}),
)
}
fn item_id(jid: &str) -> String {
format!("#{}", utils::cleanup_id(&format!("{}_chat_item", utils::slugify(jid))))
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxHttpGet" => {
rpc!(ctx, "MovimTpl.fill", "#chats", prepare_chats(ctx).await);
rpc!(ctx, "Chats.refresh");
}
"ajaxGetMAMHistory" => {
let jid = p.s(0);
let ctx = ctx.to_ws();
tokio::spawn(async move {
match jid {
Some(jid) => {
crate::mam::fetch_history(&ctx, &jid, false).await;
}
None => crate::mam::catch_up(&ctx).await,
}
});
}
"ajaxSetFilter" => {
let filter = p.str(0);
if FILTERS.contains(&filter.as_str()) {
ctx.st(|st| st.chats_filter = filter.clone());
}
rpc!(ctx, "Chats.refreshFilters");
}
"ajaxOpen" => {
let and_show = p.b(1);
let history = p.v(2).is_none() || p.b(2);
open(ctx, &p.str(0), and_show, history).await;
}
"ajaxClose" => close(ctx, &p.str(0), p.b(1)).await,
_ => return false,
}
true
}
pub async fn prepare_chats(ctx: &Ctx) -> String {
let me = ctx.jid();
let chats = db::open_chats(ctx.db(), &me).await.unwrap_or_default();
let mut html = String::new();
for jid in chats.iter().rev() {
if utils::validate_jid(jid) && *jid != me {
html.push_str(&prepare_chat(ctx, jid).await);
}
}
html
}
pub async fn prepare_chat(ctx: &Ctx, jid: &str) -> String {
let me = ctx.jid();
let (bare, resource) = match jid.split_once('/') {
Some((b, r)) => (b.to_string(), Some(r.to_string())),
None => (jid.to_string(), None),
};
let contact = contact_view(ctx, jid).await;
let count = db::unreads_count(ctx.db(), &me, jid, false).await.unwrap_or(0);
let last = db::last_message(ctx.db(), &me, jid, false).await.ok().flatten();
let composing = ctx.st(|st| st.chat_states.get(jid).is_some_and(|s| !s.is_empty())).unwrap_or(false);
let message = match last {
Some(m) => {
let file = db::message_file(ctx.db(), m.mid).await.ok().flatten().map(|f| {
let t = f.type_.clone().unwrap_or_default();
json!({
"isPicture": utils::type_is_picture(&t),
"isAudio": utils::type_is_audio(&t),
"isVideo": utils::type_is_video(&t),
"thumbnail_type": f.thumbnail_type,
"thumbnail_url": f.thumbnail_url,
})
});
let body_html = m
.body
.as_deref()
.map(|b| ctx.emoji(&utils::html_escape(&b.replace('\n', " "))).html)
.unwrap_or_default();
json!({
"date": ctx.prepare_date(&m.published, true, true),
"date_full": ctx.prepare_date(&m.published, false, true),
"mine": m.jidfrom == me,
"displayed": m.displayed,
"delivered": m.delivered,
"encrypted": m.encrypted,
"retracted": m.retracted,
"type": m.type_,
"file": file,
"body": m.body,
"body_html": body_html,
})
}
None => Value::Null,
};
ctx.render(
"Chats/_chats_item.html",
json!({
"contact": contact,
"count": count,
"message": message,
"composing": composing,
"resource": resource,
"bare": bare,
"in_call": super::visio::is_in_call(ctx, jid),
}),
)
}
/// The discussion was opened: refresh its item
pub async fn chat_open(ctx: &Ctx, jid: &str) {
if !utils::validate_jid(jid) {
return;
}
let me = ctx.jid();
if !db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) {
let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await;
rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, jid).await);
} else {
rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await);
}
rpc!(ctx, "MovimUtils.addClass", item_id(jid), "active");
rpc!(ctx, "Chats.refresh");
}
pub async fn refresh_item(ctx: &Ctx, jid: &str) {
let me = ctx.jid();
if db::open_chat_exists(ctx.db(), &me, jid).await.unwrap_or(false) {
rpc!(ctx, "MovimTpl.replace", item_id(jid), prepare_chat(ctx, jid).await);
rpc!(ctx, "Chats.refresh");
if notif::is_current(ctx, jid) {
rpc!(ctx, "MovimUtils.addClass", item_id(jid), "active");
}
}
}
pub async fn open(ctx: &Ctx, jid: &str, and_show: bool, history: bool) {
let me = ctx.jid();
if !utils::validate_jid(jid) || jid == me {
return;
}
if history {
let ctx2 = ctx.to_ws();
let jid2 = jid.to_string();
tokio::spawn(async move {
crate::mam::fetch_history(&ctx2, &jid2, false).await;
});
}
let _ = db::open_chat_touch(ctx.db(), &me, jid, None).await;
rpc!(ctx, "MovimTpl.remove", item_id(jid));
rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, jid).await);
rpc!(ctx, "Chats.refresh");
if notif::is_current(ctx, jid) || and_show {
rpc!(ctx, "Chats.setActive", jid);
}
if and_show {
rpc!(ctx, "Chat.get", jid);
}
}
pub async fn close(ctx: &Ctx, jid: &str, close_discussion: bool) {
if !utils::validate_jid(jid) {
return;
}
let me = ctx.jid();
let _ = db::open_chat_close(ctx.db(), &me, jid).await;
let _ = db::messages_mark_seen(ctx.db(), &me, jid).await;
rpc!(ctx, "MovimTpl.remove", item_id(jid));
rpc!(ctx, "Chats.refresh");
notif::on_chat_counter(ctx).await;
if close_discussion {
rpc!(ctx, "Chat_ajaxGet");
}
notif::clear(ctx, &format!("chat|{jid}")).await;
}
pub async fn on_message(ctx: &Ctx, m: &db::Message, origin: &Origin) {
if m.is_muc() {
return;
}
let me = ctx.jid();
let jid = if m.jidfrom == me { m.jidto.clone() } else { m.jidfrom.clone() };
if matches!(origin, Origin::Mam { .. }) && m.seen {
// History sync: only refresh an already displayed item
refresh_item(ctx, &jid).await;
return;
}
let _ = db::open_chat_touch(ctx.db(), &me, &jid, Some(&m.published)).await;
rpc!(ctx, "MovimTpl.remove", item_id(&jid));
rpc!(ctx, "MovimTpl.prepend", "#chats", prepare_chat(ctx, &jid).await);
rpc!(ctx, "Chats.refresh");
if notif::is_current(ctx, &jid) {
rpc!(ctx, "Chats.setActive", jid.clone());
}
if !m.seen {
notif::on_chat_counter(ctx).await;
}
let _ = chat::message_preview;
}
pub async fn on_presence(ctx: &Ctx, jid: &str) {
refresh_item(ctx, jid).await;
}
pub async fn on_chat_state(ctx: &Ctx, jid: &str) {
let composing = ctx.st(|st| st.chat_states.get(jid).is_some_and(|s| !s.is_empty())).unwrap_or(false);
let f = if composing { "MovimUtils.addClass" } else { "MovimUtils.removeClass" };
rpc!(ctx, f, format!("{} span.primary", item_id(jid)), "composing");
}
+182
View File
@@ -0,0 +1,182 @@
//! ContactActions widget: the contact drawer (profile, resources, fingerprints,
//! roster and blocking actions). Also XEP-0070 HTTP auth confirmations.
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use minidom::Element;
use serde_json::{json, Value};
use super::{contact_view, P};
use crate::{app::Ctx, proto, rpc, utils, xmpp};
pub fn fingerprint(b64: &str) -> String {
let Ok(bytes) = B64.decode(b64) else { return String::new() };
let hex = hex::encode(bytes);
let hex = hex.get(2..).unwrap_or("");
hex.as_bytes().chunks(8).map(|c| String::from_utf8_lossy(c).to_string()).collect::<Vec<_>>().join(" ")
}
pub async fn get_drawer(ctx: &Ctx, jid: &str) {
if !utils::validate_jid(jid) {
return;
}
let me = ctx.jid();
let contact = contact_view(ctx, jid).await;
let description = contact
.description
.as_deref()
.filter(|d| !d.trim().is_empty())
.map(|d| ctx.emoji(&utils::linkify(&utils::html_escape(d.trim()))).html.replace('\n', "<br />"));
let resources: Vec<Value> = ctx
.st(|st| {
st.presences
.get(jid)
.map(|p| {
p.values()
.filter(|p| p.value < 5)
.map(|p| {
let name = p.ver.as_ref().and_then(|v| st.caps.get(v)).and_then(|(n, _)| n.clone());
json!({ "resource": p.resource, "presence": p.key(), "status": p.status, "name": name })
})
.collect()
})
.unwrap_or_default()
})
.unwrap_or_default();
let (audio, video) = super::visio::call_targets(ctx, jid);
let omemo = ctx.me().is_some_and(|u| u.omemoenabled) && jid != me;
let html = ctx.render(
"ContactActions/_contactactions_drawer.html",
json!({
"contact": contact,
"description": description,
"mine": jid == me,
"resources": resources,
"call_audio": audio,
"call_video": video,
"omemo": omemo,
}),
);
super::drawer(ctx, "contact_drawer", &html, false, false);
if omemo {
rpc!(ctx, "ContactActions.getDrawerFingerprints", jid);
}
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxGetDrawer" => get_drawer(ctx, &p.str(0)).await,
"ajaxGetDrawerFingerprints" => {
let jid = p.str(0);
let list: Vec<Value> = p
.v(1)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.map(|f| {
json!({
"jid": f.get("jid").and_then(|v| v.as_str()).unwrap_or(&jid),
"bundleid": f.get("bundleid"),
"fingerprint": fingerprint(f.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("")),
})
})
.collect()
})
.unwrap_or_default();
rpc!(
ctx,
"MovimTpl.fill",
"#omemo_fingerprints",
ctx.render("ContactActions/_contactactions_drawer_fingerprints.html", json!({ "fingerprints": list }))
);
rpc!(ctx, "ContactActions.resolveSessionsStates", jid);
}
"ajaxChat" => super::search::chat(ctx, &p.str(0), p.b(1)).await,
"ajaxBlock" => block(ctx, &p.str(0), true).await,
"ajaxUnblock" => block(ctx, &p.str(0), false).await,
"ajaxHttpGetPictures" | "ajaxHttpGetLinks" | "ajaxCheckAccessModel" => {}
_ => return false,
}
true
}
pub async fn block(ctx: &Ctx, jid: &str, block: bool) {
if !utils::validate_jid(jid) {
return;
}
let Some(x) = ctx.xmpp() else { return };
match x.iq(proto::block(jid, !block)).await {
Ok(_) => {
ctx.st(|st| {
if block {
st.blocked.insert(jid.to_string());
} else {
st.blocked.remove(jid);
}
});
ctx.toast(&ctx.t(if block { "blocked.account_blocked" } else { "blocked.account_unblocked" }, &[]));
if super::notif::is_current(ctx, jid) {
super::chat::get_header(ctx, jid, false).await;
}
super::account::on_blocklist_changed(ctx).await;
}
Err(e) => ctx.toast(&e.to_string()),
}
}
// ---------------------------------------------------------------- XEP-0070
pub async fn on_confirm(ctx: &Ctx, iq: &Element, confirm: &Element) {
let html = ctx.render(
"Confirm/_confirm.html",
json!({
"from": iq.attr("from").unwrap_or(""),
"id": confirm.attr("id").unwrap_or(""),
"url": confirm.attr("url").unwrap_or(""),
"method": confirm.attr("method").unwrap_or(""),
}),
);
super::dialog(ctx, &html, false, false);
// The answer is sent through Confirm_ajaxAccept/Refuse, keep the request id
let key = format!("confirm:{}", confirm.attr("id").unwrap_or(""));
ctx.st(|st| st.caps.insert(key, (iq.attr("id").map(String::from), vec![])));
}
pub async fn confirm_call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
let (to, id, url, method) = (p.str(0), p.str(1), p.str(2), p.str(3));
let iq_id = ctx.st(|st| st.caps.remove(&format!("confirm:{id}")).and_then(|(i, _)| i)).flatten();
let confirm = xmpp::el("confirm", proto::ns::HTTP_AUTH)
.a_kv("id", &id)
.a_kv("url", &url)
.a_kv("method", &method)
.build();
let mut b = xmpp::el("iq", xmpp::NS_CLIENT).a_kv("to", &to).a_kv("id", &iq_id.unwrap_or_else(|| utils::generate_key(12)));
match func {
"ajaxAccept" => b = b.a_kv("type", "result").append(confirm),
"ajaxRefuse" => {
b = b.a_kv("type", "error").append(confirm).append(
xmpp::el("error", xmpp::NS_CLIENT)
.a_kv("code", "401")
.a_kv("type", "auth")
.append(xmpp::el("not-authorized", "urn:ietf:params:xml:ns:xmpp-stanzas").build())
.build(),
)
}
_ => return false,
}
ctx.send(b.build());
true
}
trait KvExt {
fn a_kv(self, k: &str, v: &str) -> Self;
}
impl KvExt for minidom::element::ElementBuilder {
fn a_kv(self, k: &str, v: &str) -> Self {
use crate::xmpp::BuilderExt;
self.a(k, v)
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Login widget: XMPP authentication and "quick login" (the password is stored
//! encrypted server side, the key only lives in the browser localStorage).
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use rand::RngCore;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::str::FromStr;
use super::P;
use crate::{app::Ctx, db, rpc, utils, xmpp};
pub async fn build(ctx: &Ctx, _params: &HashMap<String, String>) -> String {
let cfg = &ctx.app.cfg;
let connected = ctx.app.hub.logged_count();
let max_reached = cfg.max_sessions > 0 && connected >= cfg.max_sessions;
ctx.render(
"Login/login.html",
json!({
"domain": cfg.default_domain,
"banner": cfg.banner,
"info": cfg.info,
"whitelist": cfg.xmpp_whitelist,
"maxsessionsreached": max_reached,
"maxsessions": cfg.max_sessions,
"connected": connected,
"pop": db::user_count(ctx.db()).await.unwrap_or(0),
"disableregistration": cfg.disable_registration,
"error": prepare_error(ctx, "default", None),
}),
)
}
fn prepare_error(ctx: &Ctx, error: &str, message: Option<&str>) -> String {
let key = format!("error.{error}");
let mut text = ctx.t(&key, &[]);
if text == key {
text = ctx.t("error.default", &[]);
}
ctx.render("Login/_login_error.html", json!({ "error": text, "errormessage": message }))
}
pub async fn show_error(ctx: &Ctx, error: &str, message: Option<&str>) {
if let Some(jid) = ctx.session.as_ref().and_then(|s| s.user_jid()) {
let _ = db::encrypted_passwords_clear(ctx.db(), &jid).await;
}
rpc!(ctx, "Login.clearQuick");
rpc!(ctx, "MovimTpl.fill", "#error", prepare_error(ctx, error, message));
rpc!(ctx, "MovimUtils.addClass", "#login_widget", "error");
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxLogin" => {
let username = p.form(0, "username").unwrap_or_default().to_lowercase();
let password = p.form(0, "password").unwrap_or_default();
do_login(ctx, username.trim(), &password, &p.str(2), &p.str(1), None).await;
}
"ajaxHTTPLogin" => {
do_login(ctx, &p.str(0), &p.str(1), &p.str(3), &p.str(2), None).await;
}
"ajaxQuickLogin" => quick_login(ctx, p).await,
_ => return false,
}
true
}
fn encrypt(password: &str) -> (String, String) {
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
let ct = cipher.encrypt(&Nonce::from(nonce), password.as_bytes()).unwrap();
let mut data = nonce.to_vec();
data.extend(ct);
(B64.encode(data), B64.encode(key))
}
fn decrypt(data: &str, key: &str) -> Option<String> {
let data = B64.decode(data).ok()?;
let key = B64.decode(key).ok()?;
if data.len() < 13 || key.len() != 32 {
return None;
}
let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
let plain = cipher.decrypt(&Nonce::try_from(&data[..12]).ok()?, &data[12..]).ok()?;
String::from_utf8(plain).ok()
}
async fn quick_login(ctx: &Ctx, p: &P<'_>) {
let (device_id, login, key, timezone) = (p.str(0), p.str(1), p.str(2), p.str(3));
let Some(sid) = p.s(4) else { return };
let check = p.b(5);
if !utils::validate_bare_jid(&login) {
show_error(ctx, "login_format", None).await;
return;
}
let stored = db::encrypted_password_get(ctx.db(), &login, &device_id).await.ok().flatten();
let Some(password) = stored.and_then(|data| decrypt(&data, &key)) else {
rpc!(ctx, "Login.clearQuick");
return;
};
if check {
rpc!(ctx, "Login.quickLoginRegister");
return;
}
do_login(ctx, &login, &password, &timezone, &sid, Some(device_id)).await;
}
fn session_hash(login: &str, password: &str) -> String {
hex::encode(Sha256::digest(format!("movim:{login}:{password}").as_bytes()))
}
async fn do_login(ctx: &Ctx, login: &str, password: &str, timezone: &str, sid: &str, device_id: Option<String>) {
let cfg = &ctx.app.cfg;
let Some(session) = ctx.session.clone() else { return };
if !utils::validate_bare_jid(login) || !login.contains('@') {
show_error(ctx, "login_format", None).await;
return;
}
if password.is_empty() || password.len() > 1024 {
show_error(ctx, "password_format", None).await;
return;
}
if !utils::validate_cookie(sid) || sid != session.sid {
show_error(ctx, "session", None).await;
return;
}
if cfg.max_sessions > 0 && ctx.app.hub.logged_count() >= cfg.max_sessions {
show_error(ctx, "max_sessions_reached", None).await;
return;
}
let host = login.split('@').nth(1).unwrap_or("").to_string();
if !cfg.xmpp_whitelist.is_empty() && !cfg.xmpp_whitelist.contains(&host) {
show_error(ctx, "unauthorized", None).await;
return;
}
let hash = session_hash(login, password);
// An existing session for this account: share it if the password matches
if let Some(other) = ctx.app.hub.find_by_user(login) {
if other.sid != session.sid {
if SESSION_HASHES.lock().unwrap().get(&other.sid) == Some(&hash) {
let expires = (chrono::Utc::now() + chrono::Duration::days(7)).format("%a, %d %b %Y %H:%M:%S GMT").to_string();
rpc!(ctx, "Login.setCookie", other.sid.clone(), expires);
rpc!(ctx, "MovimUtils.redirect", ctx.route("chat", &[]));
} else {
show_error(ctx, "wrong_account", None).await;
}
return;
}
}
let user = match db::user_get_or_create(ctx.db(), login).await {
Ok(u) => u,
Err(e) => {
show_error(ctx, "internal", Some(&e.to_string())).await;
return;
}
};
if device_id.is_none() {
let (data, key) = encrypt(password);
let device = utils::generate_key(16);
if db::encrypted_password_set(ctx.db(), login, &device, &data).await.is_ok() {
rpc!(ctx, "Login.setQuick", device, login, host.clone(), key);
}
}
let resource = format!("movim{}", utils::generate_key(6));
{
let mut st = session.st.lock().unwrap();
st.timezone = if timezone.parse::<chrono_tz::Tz>().is_ok() { timezone.to_string() } else { "UTC".into() };
st.resource = resource.clone();
if let Some(lang) = user.language.clone().filter(|l| crate::i18n::get().has_language(l)) {
st.lang = lang;
}
st.user = Some(user);
}
rpc!(ctx, "Toast.send", ctx.t("connection.socket_connected", &[]), 3000);
let jid = match xmpp_parsers::jid::BareJid::from_str(login) {
Ok(j) => j,
Err(_) => {
show_error(ctx, "login_format", None).await;
return;
}
};
match xmpp::connect(jid, password.to_string(), &resource, ctx.app.cfg.debug_xmpp, ctx.app.cfg.xmpp_plaintext.as_deref()).await {
Ok(conn) => {
SESSION_HASHES.lock().unwrap().insert(session.sid.clone(), hash);
session.st.lock().unwrap().password = Some(password.to_string());
rpc!(ctx, "Toast.send", ctx.t("connection.authenticated", &[]), 3000);
super::presence::start_session(ctx, conn).await;
rpc!(ctx, "MovimUtils.reloadThis");
}
Err(e) => {
tracing::info!("login failed for {login}: {e}");
session.st.lock().unwrap().user = None;
match e {
xmpp::LoginError::Auth(cond) => {
let error = if cond.contains("NotAuthorized") || cond.contains("not-authorized") || cond.contains("bad-auth") {
"wrong_account"
} else if cond.contains("mechanism") || cond.contains("Malformed") {
"mechanism"
} else {
"fail_auth"
};
show_error(ctx, error, None).await;
}
xmpp::LoginError::Dns => show_error(ctx, "dns", None).await,
xmpp::LoginError::Connection(m) => show_error(ctx, "connection", Some(&m)).await,
xmpp::LoginError::Stream(m) => show_error(ctx, "fail_auth", Some(&m)).await,
}
}
}
}
static SESSION_HASHES: std::sync::LazyLock<std::sync::Mutex<HashMap<String, String>>> =
std::sync::LazyLock::new(Default::default);
pub fn forget_session(sid: &str) {
SESSION_HASHES.lock().unwrap().remove(sid);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quick_login_crypto() {
let (data, key) = encrypt("secret");
assert_eq!(decrypt(&data, &key).as_deref(), Some("secret"));
let (_, other) = encrypt("x");
assert_eq!(decrypt(&data, &other), None);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! Server side of Movim's widgets. Each module implements the `ajax*` calls of
//! the widget (see `widgets_registry` for the JS stubs), its initial HTML, and the
//! reactions to XMPP events (called from `handler`).
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use crate::{app::Ctx, db, rpc, utils};
pub mod account;
pub mod chat;
pub mod chatactions;
pub mod chatomemo;
pub mod chats;
pub mod contactactions;
pub mod login;
pub mod notif;
pub mod notifications;
pub mod presence;
pub mod preview;
pub mod register;
pub mod rooms;
pub mod search;
pub mod stickers;
pub mod upload;
pub mod visio;
/// Positional parameters of an ajax call
pub struct P<'a>(pub &'a [Value]);
impl P<'_> {
pub fn v(&self, i: usize) -> Option<&Value> {
self.0.get(i).filter(|v| !v.is_null())
}
pub fn s(&self, i: usize) -> Option<String> {
self.v(i).map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
}
pub fn str(&self, i: usize) -> String {
self.s(i).unwrap_or_default()
}
pub fn b(&self, i: usize) -> bool {
match self.v(i) {
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_i64().unwrap_or(0) != 0,
Some(Value::String(s)) => s == "true" || s == "1",
_ => false,
}
}
pub fn i(&self, i: usize) -> Option<i64> {
match self.v(i) {
Some(Value::Number(n)) => n.as_i64(),
Some(Value::String(s)) => s.parse().ok(),
_ => None,
}
}
/// Value of a form field serialized by `MovimUtils.formToJson`
pub fn form(&self, i: usize, field: &str) -> Option<String> {
self.v(i)?.get(field)?.get("value").map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
}
}
/// Everything a template needs to display a contact
#[derive(Serialize, Clone, Debug, Default)]
pub struct ContactView {
pub id: String,
pub jid: String,
pub truename: String,
pub picture: String,
pub color: &'static str,
pub presence: Option<String>,
pub status: Option<String>,
/// XEP-0319 idle time, as a human readable date
pub seen: Option<String>,
pub in_roster: bool,
pub subscription: Option<String>,
pub groups: Vec<String>,
pub nickname: Option<String>,
pub email: Option<String>,
pub url: Option<String>,
pub description: Option<String>,
pub blocked: bool,
}
pub async fn contact_view(ctx: &Ctx, jid: &str) -> ContactView {
let contact = db::contact_get_or_new(ctx.db(), jid).await;
contact_view_from(ctx, &contact)
}
pub fn contact_view_from(ctx: &Ctx, contact: &db::Contact) -> ContactView {
let jid = contact.id.clone();
let (roster, presence, blocked) = ctx
.st(|st| (st.roster.get(&jid).cloned(), st.best_presence(&jid).cloned(), st.blocked.contains(&jid)))
.unwrap_or((None, None, false));
let truename = roster
.as_ref()
.and_then(|r| r.name.clone())
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| contact.truename());
ContactView {
id: jid.clone(),
jid: jid.clone(),
picture: ctx.avatar_url(contact.avatarhash.as_deref(), &truename),
color: utils::string_to_color(&jid),
truename,
presence: presence.as_ref().map(|p| p.key().to_string()),
seen: presence.as_ref().and_then(|p| p.idle.as_deref()).map(|i| ctx.prepare_date(i, true, true)),
status: presence.and_then(|p| p.status),
in_roster: roster.is_some(),
subscription: roster.as_ref().map(|r| r.subscription.clone()),
groups: roster.map(|r| r.groups).unwrap_or_default(),
nickname: contact.nickname.clone(),
email: contact.email.clone(),
url: contact.url.clone(),
description: contact.description.clone(),
blocked,
}
}
pub async fn contact_views(ctx: &Ctx, jids: &[String]) -> HashMap<String, ContactView> {
let contacts = db::contacts_get(ctx.db(), jids).await.unwrap_or_default();
let mut map: HashMap<String, ContactView> =
contacts.iter().map(|c| (c.id.clone(), contact_view_from(ctx, c))).collect();
for jid in jids {
map.entry(jid.clone()).or_insert_with(|| contact_view_from(ctx, &db::Contact::new(jid)));
}
map
}
// ---------------------------------------------------------------- Common helpers
pub fn dialog(ctx: &Ctx, html: &str, scroll: bool, locked: bool) {
rpc!(ctx, "MovimTpl.fill", "#dialog", html);
if scroll {
rpc!(ctx, "Dialog.addScroll");
} else {
rpc!(ctx, "Dialog.removeScroll");
}
if locked {
rpc!(ctx, "Dialog.addLocked");
}
}
pub fn drawer(ctx: &Ctx, key: &str, html: &str, actions: bool, tiny: bool) {
rpc!(ctx, "MovimTpl.fill", "#drawer", html);
rpc!(ctx, "MovimUtils.removeClass", "body > nav", "active");
if actions {
rpc!(ctx, "MovimUtils.addClass", "#drawer", "actions");
}
if tiny {
rpc!(ctx, "MovimUtils.addClass", "#drawer", "tiny");
}
rpc!(ctx, "Drawer.open", key);
}
/// Initial HTML of a widget on a page
pub async fn build(ctx: &Ctx, name: &str, page: &str, params: &HashMap<String, String>) -> String {
match name {
"Login" => login::build(ctx, params).await,
"Presence" => presence::build(ctx),
"BottomNavigation" => presence::build_bottom(ctx, page),
"Shortcuts" => presence::build_shortcuts(ctx, page).await,
"Navigation" => ctx.render("Navigation/navigation.html", serde_json::json!({ "page": page })),
"Chat" => chat::build(ctx, params),
"Chats" => chats::build(ctx),
"Rooms" => rooms::build(ctx),
"Search" => search::build(ctx),
"Notifications" => notifications::build(ctx),
"Upload" => upload::build(ctx),
"Visio" => visio::build(ctx),
"Stickers" => stickers::build(ctx),
"ChatOmemo" | "ChatActions" | "ContactActions" | "RoomsUtils" | "Notif" | "SendTo" | "AdHoc"
| "RoomsExplore" | "Snap" | "Draw" | "Dictaphone" | "Preview" => {
let file = format!("{}/{}.html", name, name.to_lowercase());
render_if_exists(ctx, &file)
}
"Vcard4" | "Avatar" | "Config" | "Account" | "NotificationConfig" | "Blocked" => {
account::build(ctx, name).await
}
"Tabs" => render_if_exists(ctx, "Tabs/tabs.html"),
"Subscribe" => account::build_subscribe(ctx).await,
"Register" => register::build_register(ctx, params.get("s").map(String::as_str).unwrap_or("")),
_ => {
let file = format!("{}/{}.html", name, name.to_lowercase());
render_if_exists(ctx, &file)
}
}
}
fn render_if_exists(ctx: &Ctx, file: &str) -> String {
if ctx.app.tpl.get_template(file).is_ok() {
ctx.render(file, serde_json::json!({}))
} else {
String::new()
}
}
/// Dispatch an `ajax*` call
pub async fn call(ctx: &Ctx, widget: &str, func: &str, params: Vec<Value>) {
let p = P(&params);
let handled = match widget {
"Login" => login::call(ctx, func, &p).await,
"Presence" | "BottomNavigation" | "Navigation" | "Shortcuts" => presence::call(ctx, widget, func, &p).await,
"Dialog" => {
if func == "ajaxClear" {
rpc!(ctx, "MovimUtils.removeClass", "#dialog", "scroll");
rpc!(ctx, "MovimTpl.fill", "#dialog", "");
}
true
}
"Drawer" => {
if func == "ajaxClear" {
rpc!(ctx, "MovimTpl.fill", "#drawer", "");
rpc!(ctx, "MovimUtils.removeClass", "#drawer", "actions");
rpc!(ctx, "MovimUtils.removeClass", "#drawer", "tiny");
}
true
}
"Register" => register::call(ctx, func, &p).await,
_ if !ctx.is_logged() => false,
"Chat" => chat::call(ctx, func, &p).await,
"Chats" => chats::call(ctx, func, &p).await,
"ChatActions" => chatactions::call(ctx, func, &p).await,
"ChatOmemo" => chatomemo::call(ctx, func, &p).await,
"ContactActions" => contactactions::call(ctx, func, &p).await,
"Rooms" | "RoomsUtils" | "RoomsExplore" => rooms::call(ctx, widget, func, &p).await,
"Search" | "SendTo" => search::call(ctx, widget, func, &p).await,
"Notif" => notif::call(ctx, func, &p).await,
"Notifications" => notifications::call(ctx, func, &p).await,
"Preview" => preview::call(ctx, func, &p).await,
"Stickers" => stickers::call(ctx, func, &p).await,
"Upload" | "Dictaphone" | "Snap" | "Draw" => upload::call(ctx, widget, func, &p).await,
"Visio" => visio::call(ctx, func, &p).await,
"Confirm" => contactactions::confirm_call(ctx, func, &p).await,
"Account" | "Config" | "Vcard4" | "Avatar" | "NotificationConfig" | "Blocked" => {
account::call(ctx, widget, func, &p).await
}
_ => false,
};
if !handled {
tracing::debug!("unhandled call {widget}::{func}");
}
}
/// A browser tab is back on a logged session
pub async fn session_up(ctx: &Ctx, only_client: bool) {
// The calls live in the browser tabs: a new single tab cannot hold an ongoing one
if only_client {
visio::end_orphan_call(ctx).await;
}
if let Some(x) = ctx.xmpp() {
x.set_active(true);
}
presence::send_presence(ctx, None).await;
}
/// No more browser tabs
pub async fn session_down(ctx: &Ctx) {
// Nobody is left to take part in a call
visio::terminate_current(ctx).await;
presence::send_away(ctx).await;
if let Some(x) = ctx.xmpp() {
x.set_active(false);
}
}
+196
View File
@@ -0,0 +1,196 @@
//! Notif widget: notification counters, desktop notifications and snackbars.
//! `notifs_key` tracks what the user is currently looking at (`chat|jid`…).
use serde_json::{json, Value};
use super::P;
use crate::{app::Ctx, db, rpc};
pub struct Notification<'a> {
pub key: String,
pub title: String,
pub body: String,
pub url: String,
pub picture: Option<String>,
pub time: i64,
pub actions: Value,
pub data: Value,
pub rpc_call: Option<&'a str>,
}
/// Number of conversations with unread messages
pub async fn unreads(ctx: &Ctx) -> i64 {
let me = ctx.jid();
let rooms: Vec<String> = ctx
.st(|st| st.conferences.values().filter(|c| c.notify > 0).map(|c| c.jid.clone()).collect())
.unwrap_or_default();
db::unreads_total(ctx.db(), &me, &rooms).await.unwrap_or(0)
}
pub async fn append(ctx: &Ctx, n: Notification<'_>) {
let picture = n.picture.clone().unwrap_or_else(|| format!("{}theme/img/app/128.png", ctx.base_uri()));
let mut data = n.data.clone();
data["url"] = json!(n.url);
crate::push::notify(ctx, &n.key, &n.title, &n.body, &picture, &data).await;
rpc!(
ctx,
"Notif.desktop",
n.key.clone(),
chrono::Utc::now().timestamp(),
n.title.clone(),
n.body.clone(),
picture.clone(),
n.actions.clone(),
data
);
let first = n.key.split('|').next().unwrap_or("").to_string();
let current = ctx.st(|st| st.notifs_key.clone()).flatten();
let (first_count, key_count) = ctx
.st(|st| {
let c = st.notifs.shift_remove(&first).unwrap_or(0) + 1;
st.notifs.insert(first.clone(), c);
let mut k = 0;
if first != n.key {
k = st.notifs.shift_remove(&n.key).unwrap_or(0) + 1;
st.notifs.insert(n.key.clone(), k);
}
(c, k)
})
.unwrap_or((0, 0));
// Do not notify what is currently displayed
if current.as_deref() == Some(n.key.as_str()) {
return;
}
if first == "chat" {
rpc!(ctx, "Notif.counter", first.clone(), unreads(ctx).await);
} else {
rpc!(ctx, "Notif.counter", first.clone(), first_count);
}
if let Some(call) = n.rpc_call {
rpc!(ctx, call);
}
if first != n.key {
rpc!(ctx, "Notif.counter", n.key.clone(), key_count);
}
if !n.title.is_empty() {
let html = ctx.render(
"Notif/_notif.html",
json!({ "title": n.title, "body": n.body, "picture": picture, "url": n.url }),
);
rpc!(ctx, "Notif.snackbar", html, n.time);
}
super::presence::refresh_shortcuts(ctx).await;
}
pub async fn clear(ctx: &Ctx, key: &str) {
let removed = ctx
.st(|st| {
let counter = st.notifs.shift_remove(key)?;
let first = key.split('|').next().unwrap_or("").to_string();
let mut first_left = None;
if let Some(v) = st.notifs.get_mut(&first) {
*v -= counter;
first_left = Some((first.clone(), (*v).max(0)));
if *v <= 0 {
st.notifs.shift_remove(&first);
}
}
Some(first_left)
})
.flatten();
if let Some(first_left) = removed {
rpc!(ctx, "Notif.counter", key, 0);
rpc!(ctx, "Notif.clear", key);
if let Some((first, left)) = first_left {
rpc!(ctx, "Notif.counter", first, left);
}
if let Some(jid) = key.strip_prefix("chat|") {
rpc!(ctx, "Shortcuts.clear", jid);
}
}
}
pub fn current(ctx: &Ctx) -> Option<String> {
ctx.st(|st| st.notifs_key.clone()).flatten()
}
/// Is the conversation currently displayed and focused?
pub fn is_current(ctx: &Ctx, jid: &str) -> bool {
current(ctx).is_some_and(|k| k == format!("chat|{jid}"))
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxClear" => clear(ctx, &p.str(0)).await,
"ajaxGet" => {
let chat = p.v(0).is_none() || p.b(0);
let mut notifs: serde_json::Map<String, Value> = ctx
.st(|st| st.notifs.iter().map(|(k, v)| (k.clone(), json!(v))).collect())
.unwrap_or_default();
if chat {
notifs.insert("chat".into(), json!(unreads(ctx).await));
}
rpc!(ctx, "Notif.refresh", Value::Object(notifs));
}
"ajaxCurrent" => {
let key = p.str(0);
if key.contains('|') {
clear(ctx, &key).await;
}
let was_blurred = ctx.st(|st| st.notifs_key.as_deref() == Some("blurred")).unwrap_or(false);
ctx.st(|st| st.notifs_key = Some(key.clone()));
if was_blurred {
if let Some(jid) = key.strip_prefix("chat|") {
super::chat::on_focus_back(ctx, jid).await;
}
}
}
"ajaxRegisterPushSubscrition" => {
let (endpoint, auth, p256dh) = (p.str(0), p.str(1), p.str(2));
if endpoint.starts_with("https://") {
let _ = db::push_subscription_save(ctx.db(), &ctx.jid(), &endpoint, &p256dh, &auth).await;
}
}
"ajaxHttpTouchPushSubscription" => {
let _ = db::push_subscription_touch(ctx.db(), &p.str(0)).await;
}
"ajaxHttpRequest" => {
super::dialog(ctx, &ctx.render("Notif/_notif_request.html", json!({})), false, false);
}
"ajaxRequestGranted" => {
rpc!(
ctx,
"Notif.desktop",
chrono::Utc::now().timestamp(),
ctx.t("notification.request_title", &[]),
ctx.t("notification.request_granted", &[]),
Value::Null,
Value::Null,
Value::Null,
Value::Null,
Value::Null,
true
);
}
"ajaxRequestDenied" => ctx.toast(&ctx.t("notification.request_denied", &[])),
_ => return false,
}
true
}
pub async fn on_chat_counter(ctx: &Ctx) {
let n = unreads(ctx).await;
rpc!(ctx, "Notif.counter", "chat", n);
rpc!(ctx, "MovimUtils.setDataItem", "#chatcounter", "counter", n);
rpc!(ctx, "MovimUtils.setDataItem", "#chatheadercounter", "counter", n);
rpc!(ctx, "BottomNavigation.setChatNotification", json!({ "chat": n }));
}
+181
View File
@@ -0,0 +1,181 @@
//! Notifications widget: contact (subscription) requests and roster management.
use serde_json::json;
use super::{contact_view, notif, P};
use crate::{app::Ctx, proto, rpc, utils};
pub fn build(_ctx: &Ctx) -> String {
String::new()
}
pub async fn refresh_counter(ctx: &Ctx) {
let count = ctx.st(|st| st.subscribe_requests.len()).unwrap_or(0);
rpc!(ctx, "Notifications.setCounters", if count > 0 { json!(count) } else { json!("") });
}
pub async fn on_subscribe_request(ctx: &Ctx, jid: &str) {
// Already subscribed: accept automatically (pre-approval)
let approved = ctx
.st(|st| st.roster.get(jid).is_some_and(|r| r.subscription == "to" || r.subscription == "both"))
.unwrap_or(false);
if approved {
ctx.send(proto::presence_to(jid, "subscribed"));
return;
}
ctx.st(|st| st.subscribe_requests.insert(jid.to_string()));
let contact = contact_view(ctx, jid).await;
notif::append(
ctx,
notif::Notification {
key: format!("invite|{jid}"),
title: contact.truename.clone(),
body: ctx.t("invitations.wants_to_talk", &[&contact.truename]),
url: ctx.route("chat", &[jid]),
picture: Some(contact.picture),
time: 4,
actions: json!([]),
data: json!({}),
rpc_call: None,
},
)
.await;
refresh_counter(ctx).await;
}
async fn remove_invitation(ctx: &Ctx, jid: &str) {
ctx.st(|st| st.subscribe_requests.remove(jid));
notif::clear(ctx, &format!("invite|{jid}")).await;
rpc!(ctx, "MovimTpl.remove", format!("#invitation-{}", utils::cleanup_id(jid)));
refresh_counter(ctx).await;
}
async fn prepare(ctx: &Ctx) -> String {
let requests: Vec<String> = ctx.st(|st| st.subscribe_requests.iter().cloned().collect()).unwrap_or_default();
let mut req_views = vec![];
for jid in &requests {
let c = contact_view(ctx, jid).await;
req_views.push(json!({ "jid": jid, "truename": c.truename, "picture": c.picture }));
}
let pending: Vec<(String, Option<String>)> = ctx
.st(|st| {
st.roster
.values()
.filter(|r| r.subscription == "none")
.map(|r| (r.jid.clone(), r.ask.clone()))
.collect()
})
.unwrap_or_default();
let mut subs = vec![];
for (jid, ask) in pending {
let c = contact_view(ctx, &jid).await;
subs.push(json!({ "jid": jid, "truename": c.truename, "picture": c.picture, "ask": ask }));
}
ctx.render("Notifications/_notifications.html", json!({ "requests": req_views, "subscriptions": subs }))
}
fn groups(ctx: &Ctx) -> Vec<String> {
let mut g: Vec<String> = ctx
.st(|st| st.roster.values().flat_map(|r| r.groups.clone()).collect())
.unwrap_or_default();
g.sort();
g.dedup();
g
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxRequest" => {
super::drawer(ctx, "notifications", &prepare(ctx).await, false, false);
refresh_counter(ctx).await;
}
"ajaxSetCounter" => refresh_counter(ctx).await,
"ajaxAddAsk" | "ajaxEditContact" => {
let jid = p.str(0);
let contact = if jid.is_empty() { super::ContactView::default() } else { contact_view(ctx, &jid).await };
let (alias, group) = ctx
.st(|st| {
st.roster
.get(&jid)
.map(|r| (r.name.clone().unwrap_or_default(), r.groups.first().cloned().unwrap_or_default()))
})
.flatten()
.unwrap_or_else(|| (contact.truename.clone(), String::new()));
let html = ctx.render(
"Notifications/_notifications_add.html",
json!({ "contact": contact, "alias": alias, "group": group, "groups": groups(ctx), "edit": func == "ajaxEditContact" }),
);
super::dialog(ctx, &html, false, false);
}
"ajaxAdd" => {
let jid = p.form(0, "searchjid").unwrap_or_default().trim().to_lowercase();
if !utils::validate_bare_jid(&jid) || jid == ctx.jid() {
ctx.toast(&ctx.t("roster.jid_error", &[]));
return true;
}
let alias = p.form(0, "alias").filter(|a| !a.trim().is_empty());
let group: Vec<String> = p.form(0, "group").filter(|g| !g.trim().is_empty()).into_iter().collect();
let Some(x) = ctx.xmpp() else { return true };
let in_roster = ctx.st(|st| st.roster.get(&jid).cloned()).flatten();
match x.iq(proto::roster_set(&jid, alias.as_deref(), &group, false)).await {
Ok(_) => {
let needs_subscribe = in_roster.as_ref().is_none_or(|r| r.subscription == "none" || r.subscription == "from");
if needs_subscribe {
ctx.send(proto::presence_to(&jid, "subscribe"));
}
ctx.toast(&ctx.t(if in_roster.is_some() { "roster.updated" } else { "roster.added" }, &[]));
}
Err(e) => ctx.toast(&e.to_string()),
}
rpc!(ctx, "Dialog_ajaxClear");
}
"ajaxDeleteContact" => {
let jid = p.str(0);
if utils::validate_jid(&jid) {
super::dialog(ctx, &ctx.render("Notifications/_notifications_delete.html", json!({ "jid": jid })), false, false);
}
}
"ajaxDelete" => {
let jid = p.str(0);
if let Some(x) = ctx.xmpp() {
let _ = x.iq(proto::roster_set(&jid, None, &[], true)).await;
ctx.toast(&ctx.t("roster.deleted", &[]));
}
}
"ajaxAccept" => {
let jid = p.str(0);
if !utils::validate_bare_jid(&jid) {
return true;
}
let roster = ctx.st(|st| st.roster.get(&jid).cloned()).flatten();
if roster.is_none() {
if let Some(x) = ctx.xmpp() {
let _ = x.iq(proto::roster_set(&jid, None, &[], false)).await;
}
}
if roster.as_ref().is_none_or(|r| r.subscription == "none" || r.subscription == "from") {
ctx.send(proto::presence_to(&jid, "subscribe"));
}
ctx.send(proto::presence_to(&jid, "subscribed"));
remove_invitation(ctx, &jid).await;
}
"ajaxRefuse" => {
let jid = p.str(0);
if !utils::validate_jid(&jid) {
return true;
}
let in_roster = ctx.st(|st| st.roster.contains_key(&jid)).unwrap_or(false);
if in_roster {
if let Some(x) = ctx.xmpp() {
let _ = x.iq(proto::roster_set(&jid, None, &[], true)).await;
}
}
ctx.send(proto::presence_to(&jid, "unsubscribed"));
remove_invitation(ctx, &jid).await;
}
_ => {}
}
true
}
+318
View File
@@ -0,0 +1,318 @@
//! Presence widget (own status, menu, logout) and the session start sequence.
//! Also BottomNavigation and Shortcuts that display the same information.
use serde_json::json;
use super::{contact_view, notif, P};
use crate::{app::Ctx, db, proto, rpc, xmpp};
fn own_presence_key(ctx: &Ctx) -> String {
ctx.st(|st| match st.own_show.as_deref() {
Some("away") => "away",
Some("dnd") => "dnd",
Some("xa") => "xa",
_ => "online",
})
.unwrap_or("online")
.to_string()
}
pub fn build(ctx: &Ctx) -> String {
let picture = ctx.placeholder_url(&ctx.jid());
ctx.render("Presence/presence.html", json!({ "picture": picture }))
}
pub fn build_bottom(ctx: &Ctx, page: &str) -> String {
let picture = ctx.placeholder_url(&ctx.jid());
ctx.render("BottomNavigation/bottomnavigation.html", json!({ "page": page, "picture": picture }))
}
pub async fn build_shortcuts(ctx: &Ctx, page: &str) -> String {
let counter = if ctx.is_logged() { notif::unreads(ctx).await } else { 0 };
ctx.render("Shortcuts/shortcuts.html", json!({ "page": page, "counter": counter }))
}
async fn prepare_presence(ctx: &Ctx) -> String {
let me = contact_view(ctx, &ctx.jid()).await;
ctx.render("Presence/_presence.html", json!({ "contact": me, "presence": own_presence_key(ctx) }))
}
async fn prepare_me_bottom(ctx: &Ctx) -> String {
let me = contact_view(ctx, &ctx.jid()).await;
ctx.render(
"BottomNavigation/_bottomnavigation_me.html",
json!({ "picture": me.picture, "presence": own_presence_key(ctx) }),
)
}
pub async fn on_my_presence(ctx: &Ctx) {
rpc!(ctx, "MovimTpl.fill", "#presence_widget", prepare_presence(ctx).await);
rpc!(ctx, "MovimTpl.fill", "#bottomnavigation_me", prepare_me_bottom(ctx).await);
}
pub async fn refresh_shortcuts(ctx: &Ctx) {
let me = ctx.jid();
let keys: Vec<(String, i64)> = ctx
.st(|st| st.notifs.iter().rev().filter_map(|(k, v)| k.strip_prefix("chat|").map(|j| (j.to_string(), *v))).collect())
.unwrap_or_default();
let mut items = vec![];
for (jid, _) in keys.into_iter().take(3) {
let conference = ctx.st(|st| st.conferences.get(&jid).cloned()).flatten();
match conference {
Some(c) => items.push(json!({
"jid": jid, "muc": true, "name": c.title(), "picture": super::rooms::room_picture(ctx, &c), "counter": 0,
})),
None => {
let counter = db::unreads_count(ctx.db(), &me, &jid, false).await.unwrap_or(0);
if counter > 0 {
let c = contact_view(ctx, &jid).await;
items.push(json!({ "jid": jid, "muc": false, "name": c.truename, "picture": c.picture, "counter": counter }));
}
}
}
}
if !items.is_empty() {
rpc!(ctx, "MovimTpl.fill", "#shortcuts_widget", ctx.render("Shortcuts/_shortcuts.html", json!({ "shortcuts": items })));
}
}
pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
if !ctx.is_logged() {
return false;
}
match (widget, func) {
("Presence", "ajaxAskLogout") => {
super::dialog(ctx, &ctx.render("Presence/_presence_logout.html", json!({})), false, false);
}
("Presence", "ajaxLogout") => {
rpc!(ctx, "Presence.clearQuick");
let _ = db::encrypted_passwords_clear(ctx.db(), &ctx.jid()).await;
logout(ctx).await;
}
("Presence", "ajaxHttpGetPresence") => {
rpc!(ctx, "MovimTpl.fill", "#presence_widget", prepare_presence(ctx).await);
}
("Presence", "ajaxHttpMenu") => {
let me = contact_view(ctx, &ctx.jid()).await;
let presences: Vec<_> = [("online", "presence.online", "check_circle"), ("away", "presence.away", "schedule"), ("dnd", "presence.dnd", "do_not_disturb_on"), ("xa", "presence.xa", "bedtime")]
.iter()
.map(|(v, k, icon)| json!({ "value": v, "label": ctx.t(k, &[]), "icon": icon }))
.collect();
let html = ctx.render(
"Presence/_presence_menu.html",
json!({ "contact": me, "presence": own_presence_key(ctx), "presences": presences }),
);
super::drawer(ctx, "menu", &html, false, true);
}
("Presence", "ajaxSetPresence") => {
let value = p.str(0);
let show = match value.as_str() {
"away" | "dnd" | "xa" => Some(value.clone()),
_ => None,
};
ctx.st(|st| st.own_show = show);
send_presence(ctx, None).await;
on_my_presence(ctx).await;
}
("Presence", "ajaxProfileRefresh") => {
let me = ctx.jid();
crate::pep::refresh_profile(ctx, &me).await;
}
("Presence", "ajaxBookmarksGet") => super::rooms::fetch_bookmarks(ctx).await,
("Presence", "ajaxServerCapsGet") | ("Presence", "ajaxServerDisco") => discover_server(ctx).await,
("Presence", _) => {}
("BottomNavigation", "ajaxHttpRefresh") => {
rpc!(ctx, "BottomNavigation.setChatNotification", json!({ "chat": notif::unreads(ctx).await }));
rpc!(ctx, "MovimTpl.fill", "#bottomnavigation_me", prepare_me_bottom(ctx).await);
}
("Navigation", "ajaxHttpRefresh") => {
rpc!(ctx, "MovimUtils.setDataItem", "#chatcounter", "counter", notif::unreads(ctx).await);
}
("Shortcuts", "ajaxGet") => refresh_shortcuts(ctx).await,
_ => return false,
}
true
}
pub async fn send_presence(ctx: &Ctx, to: Option<&str>) {
let (show, status) = ctx.st(|st| (st.own_show.clone(), st.own_status.clone())).unwrap_or((None, None));
let show = show.or_else(|| Some("chat".into()));
let mut p = proto::presence(show.as_deref(), status.as_deref(), 0);
if let Some(to) = to {
use crate::xmpp::ElementExt;
p.set_a("to", to);
}
ctx.send(p);
ctx.st(|st| st.session_down = false);
// Refresh our presence in the joined rooms
if to.is_none() {
for room in ctx.st(|st| st.joined_rooms()).unwrap_or_default() {
if let Some(nick) = super::rooms::my_nick(ctx, &room) {
let mut p = proto::presence(show.as_deref(), status.as_deref(), 0);
use crate::xmpp::ElementExt;
p.set_a("to", format!("{room}/{nick}"));
if let Some(muji) = super::visio::own_muji(ctx, &room) {
p.append_child(muji);
}
ctx.send(p);
}
}
}
}
/// No browser tab is connected anymore: away with XEP-0319 idle
pub async fn send_away(ctx: &Ctx) {
let status = ctx.st(|st| st.own_status.clone()).flatten();
let mut p = proto::presence(Some("away"), status.as_deref(), 0);
let since = (chrono::Utc::now() - chrono::Duration::seconds(10)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
p.append_child(xmpp::el("idle", proto::ns::IDLE).a_since(since));
ctx.send(p);
ctx.st(|st| st.session_down = true);
}
trait SinceExt {
fn a_since(self, since: String) -> minidom::Element;
}
impl SinceExt for minidom::element::ElementBuilder {
fn a_since(self, since: String) -> minidom::Element {
use crate::xmpp::BuilderExt;
self.a("since", since).build()
}
}
pub async fn logout(ctx: &Ctx) {
ctx.st(|st| st.logging_out = true);
if let Some(x) = ctx.xmpp() {
super::visio::terminate_current(ctx).await;
let unavailable = xmpp::el("presence", xmpp::NS_CLIENT).build();
let mut unavailable = unavailable;
use crate::xmpp::ElementExt;
unavailable.set_a("type", "unavailable");
x.send(unavailable);
x.end();
} else if let Some(s) = &ctx.session {
// Not connected: just forget the session
s.close_clients();
ctx.app.hub.remove(&s.sid);
}
}
/// Everything done after a successful authentication, like `Presence::start()`
pub async fn start_session(ctx: &Ctx, conn: xmpp::Connection) {
let Some(session) = ctx.session.clone() else { return };
let x = conn.handle.clone();
session.set_xmpp(Some(conn.handle.clone()));
{
let mut st = session.st.lock().unwrap();
st.logged = true;
st.resource = x.jid.resource().to_string();
}
crate::handler::spawn(ctx.app.clone(), session.clone(), conn.incoming);
let ws = ctx.to_ws();
rpc!(ws, "Notif.inhibit", 15);
// Roster first, so the interface is complete on the first display
if let Ok(res) = x.iq(proto::roster_get()).await {
if let Some(q) = res.get_child("query", proto::ns::ROSTER) {
for item in q.children().filter(|c| c.name() == "item") {
crate::handler::roster_item(&ws, item).await;
}
}
}
// Carbons, blocklist and server capabilities
x.iq_spawn(proto::carbons_enable());
if let Ok(res) = x.iq(proto::blocklist_get()).await {
if let Some(list) = res.get_child("blocklist", proto::ns::BLOCKING) {
let items: Vec<String> = list
.children()
.filter(|c| c.name() == "item")
.filter_map(|c| c.attr("jid").map(String::from))
.collect();
ws.st(|st| st.blocked = items.into_iter().collect());
}
}
send_presence(&ws, None).await;
let ctx2 = ws.clone();
tokio::spawn(async move {
discover_server(&ctx2).await;
super::rooms::fetch_bookmarks(&ctx2).await;
let me = ctx2.jid();
crate::pep::refresh_profile(&ctx2, &me).await;
crate::mam::catch_up(&ctx2).await;
super::chatomemo::refresh_own_devices(&ctx2).await;
});
}
/// Disco the server and the account: features, HTTP upload service, external services
pub async fn discover_server(ctx: &Ctx) {
let Some(x) = ctx.xmpp() else { return };
let me = ctx.jid();
let host = me.split('@').nth(1).unwrap_or("").to_string();
if let Ok(res) = x.iq(proto::disco_info(&host, None)).await {
let features = disco_features(&res);
ctx.st(|st| st.server_features = features.into_iter().collect());
}
if let Ok(res) = x.iq(proto::disco_info(&me, None)).await {
let features = disco_features(&res);
ctx.st(|st| st.account_features = features.into_iter().collect());
}
if let Ok(res) = x.iq(proto::disco_items(&host, None)).await {
let items: Vec<String> = res
.get_child("query", proto::ns::DISCO_ITEMS)
.map(|q| q.children().filter_map(|i| i.attr("jid").map(String::from)).collect())
.unwrap_or_default();
for item in items {
let Ok(info) = x.iq(proto::disco_info(&item, None)).await else { continue };
let features = disco_features(&info);
if features.iter().any(|f| f == proto::ns::UPLOAD) {
let max = max_file_size(&info);
ctx.st(|st| st.upload = Some(crate::session::UploadService { jid: item.clone(), max_size: max }));
}
let is_muc = info
.get_child("query", proto::ns::DISCO_INFO)
.is_some_and(|q| q.children().any(|i| i.name() == "identity" && i.attr("category") == Some("conference") && i.attr("type") == Some("text")));
if is_muc {
ctx.st(|st| st.caps.insert(format!("muc_service:{item}"), (None, vec![])));
}
}
}
}
pub fn disco_features(res: &minidom::Element) -> Vec<String> {
res.get_child("query", proto::ns::DISCO_INFO)
.map(|q| {
q.children()
.filter(|c| c.name() == "feature")
.filter_map(|c| c.attr("var").map(String::from))
.collect()
})
.unwrap_or_default()
}
fn max_file_size(res: &minidom::Element) -> Option<u64> {
let q = res.get_child("query", proto::ns::DISCO_INFO)?;
for x in q.children().filter(|c| c.name() == "x" && c.ns() == proto::ns::DATA) {
let is_upload = x.children().any(|f| {
f.attr("var") == Some("FORM_TYPE") && f.children().any(|v| v.text() == proto::ns::UPLOAD)
});
if is_upload {
for f in x.children().filter(|f| f.attr("var") == Some("max-file-size")) {
if let Some(v) = f.children().next() {
return v.text().parse().ok();
}
}
}
}
None
}
+300
View File
@@ -0,0 +1,300 @@
//! Preview widget (picture viewer), the picture proxy and URL embeds (OpenGraph).
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, net::IpAddr, sync::{LazyLock, Mutex}};
use super::P;
use crate::{app::{App, Ctx}, rpc, utils};
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxHttpShow" => {
let url = p.str(0);
if !(url.starts_with("http://") || url.starts_with("https://")) {
return true;
}
let thumb = format!(
"{}picture?url={}",
ctx.base_uri(),
percent_encoding::utf8_percent_encode(&url, percent_encoding::NON_ALPHANUMERIC)
);
let html = ctx.render("Preview/_preview.html", json!({ "raw_url": url, "thumb": thumb, "messageid": p.s(1) }));
rpc!(ctx, "Preview.fill", html);
}
"ajaxHttpGallery" => {}
"ajaxHttpHide" => rpc!(ctx, "MovimTpl.fill", "#preview", ""),
"ajaxCopyNotify" => ctx.toast(&ctx.t("preview.link_copied", &[])),
_ => return false,
}
true
}
/// Refuse to fetch local or private network resources (SSRF). Returns the
/// address to connect to, so that a second DNS answer cannot differ.
async fn public_addr(url: &reqwest::Url) -> Option<Option<std::net::SocketAddr>> {
if !matches!(url.scheme(), "http" | "https") {
return None;
}
if ALLOW_PRIVATE.load(std::sync::atomic::Ordering::Relaxed) {
return Some(None);
}
let host = url.host_str()?;
let port = url.port_or_known_default().unwrap_or(443);
let addrs: Vec<_> = tokio::net::lookup_host((host, port)).await.ok()?.collect();
if addrs.is_empty() {
return None;
}
for a in &addrs {
let private = match a.ip() {
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64,
IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80
|| v6.to_ipv4_mapped().is_some_and(|v4| v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified())
}
};
if private {
return None;
}
}
Some(Some(addrs[0]))
}
/// GET a public URL, following up to 3 redirects, each of them checked
async fn safe_get(url: &str) -> Option<(reqwest::Url, reqwest::Response)> {
let mut url = reqwest::Url::parse(url.trim()).ok()?;
for _ in 0..4 {
let addr = public_addr(&url).await?;
let mut builder = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0 Movim")
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10));
if let (Some(addr), Some(host)) = (addr, url.host_str()) {
builder = builder.resolve(host, addr);
}
let res = builder.build().ok()?.get(url.clone()).send().await.ok()?;
if res.status().is_redirection() {
let location = res.headers().get("location")?.to_str().ok()?;
url = url.join(location).ok()?;
continue;
}
if !res.status().is_success() {
return None;
}
return Some((url, res));
}
None
}
/// Read at most `max` bytes of a response
async fn read_limited(mut res: reqwest::Response, max: usize) -> Vec<u8> {
let mut out = Vec::new();
while let Ok(Some(chunk)) = res.chunk().await {
out.extend_from_slice(&chunk);
if out.len() >= max {
out.truncate(max);
break;
}
}
out
}
pub static ALLOW_PRIVATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static PICTURE_CACHE: LazyLock<Mutex<HashMap<String, (String, Vec<u8>)>>> = LazyLock::new(Default::default);
/// Fetch a remote picture (resized when large) for the picture proxy
pub async fn fetch_picture(app: &App, url: &str) -> Option<(String, Vec<u8>)> {
if let Some(c) = PICTURE_CACHE.lock().unwrap().get(url) {
return Some(c.clone());
}
let _ = app;
let (_, res) = safe_get(url).await?;
let mime = res.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
if !mime.starts_with("image/") {
return None;
}
if res.content_length().is_some_and(|l| l > 20 * 1024 * 1024) {
return None;
}
let bytes = read_limited(res, 20 * 1024 * 1024 + 1).await;
if bytes.len() > 20 * 1024 * 1024 {
return None;
}
// Resize large pictures to 1920px
let out = match image::load_from_memory(&bytes) {
Ok(img) if img.width() > 1920 || img.height() > 1920 => {
let resized = img.resize(1920, 1920, image::imageops::FilterType::Triangle);
let mut buf = std::io::Cursor::new(Vec::new());
match resized.write_to(&mut buf, image::ImageFormat::Jpeg) {
Ok(_) => ("image/jpeg".to_string(), buf.into_inner()),
Err(_) => (mime, bytes.to_vec()),
}
}
_ => (mime, bytes.to_vec()),
};
let mut cache = PICTURE_CACHE.lock().unwrap();
if cache.len() > 200 {
cache.clear();
}
cache.insert(url.to_string(), out.clone());
Some(out)
}
static META_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)<meta\s+[^>]*?(?:property|name)\s*=\s*["']([^"']+)["'][^>]*?content\s*=\s*["']([^"']*)["']|<meta\s+[^>]*?content\s*=\s*["']([^"']*)["'][^>]*?(?:property|name)\s*=\s*["']([^"']+)["']"#).unwrap()
});
static TITLE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap());
static ICON_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)<link\s+[^>]*?rel\s*=\s*["'](?:shortcut )?icon["'][^>]*?href\s*=\s*["']([^"']+)["']|<link\s+[^>]*?href\s*=\s*["']([^"']+)["'][^>]*?rel\s*=\s*["'](?:shortcut )?icon["']"#).unwrap()
});
/// What we know about a URL (Movim's `Url`), cached in the database
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UrlInfo {
pub url: String,
/// image, video, audio, link
#[serde(rename = "type")]
pub type_: String,
pub content_type: String,
pub content_length: i64,
pub title: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
pub provider_name: Option<String>,
pub provider_icon: Option<String>,
pub author_name: Option<String>,
}
fn url_hash(url: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(url.as_bytes()))
}
/// Cached information about a URL, without fetching it
pub async fn cached(app: &App, url: &str) -> Option<UrlInfo> {
let data = crate::db::url_get(&app.db, &url_hash(url)).await.ok().flatten()?;
serde_json::from_str(&data).ok()
}
/// Fetch (once) the OpenGraph information of a URL
pub async fn resolve(app: &App, url: &str) -> Option<UrlInfo> {
if let Some(info) = cached(app, url).await {
return Some(info);
}
let info = fetch_info(url).await?;
if let Ok(data) = serde_json::to_string(&info) {
let _ = crate::db::url_save(&app.db, &url_hash(url), url, &data).await;
}
Some(info)
}
async fn fetch_info(url: &str) -> Option<UrlInfo> {
let (final_url, res) = safe_get(url).await?;
let host = final_url.host_str().unwrap_or("").trim_start_matches("www.").to_string();
let mime = res.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").split(';').next().unwrap_or("").trim().to_lowercase();
let length = res.content_length().map(|l| l as i64).unwrap_or(0);
let name = final_url.path_segments().and_then(|mut s| s.next_back()).filter(|n| !n.is_empty()).map(|n| {
percent_encoding::percent_decode_str(n).decode_utf8_lossy().to_string()
});
let mut info = UrlInfo { url: url.to_string(), content_type: mime.clone(), content_length: length, provider_name: Some(host), ..Default::default() };
for media in ["image", "video", "audio"] {
if mime.starts_with(&format!("{media}/")) {
info.type_ = media.into();
info.title = name;
if media == "image" {
info.image = Some(url.to_string());
}
return Some(info);
}
}
if !mime.contains("html") {
return None;
}
let body = read_limited(res, 512 * 1024).await;
let body = String::from_utf8_lossy(&body);
let head = match body.to_lowercase().find("</head>") {
Some(i) => &body[..i],
None => &body[..],
};
let mut meta: HashMap<String, String> = HashMap::new();
for c in META_RE.captures_iter(head) {
let (k, v) = match (c.get(1), c.get(2), c.get(3), c.get(4)) {
(Some(k), Some(v), _, _) => (k.as_str(), v.as_str()),
(_, _, Some(v), Some(k)) => (k.as_str(), v.as_str()),
_ => continue,
};
let v = utils::html_decode(v.trim());
if !v.is_empty() {
meta.entry(k.to_lowercase()).or_insert(v);
}
}
let get = |keys: &[&str]| keys.iter().find_map(|k| meta.get(*k).cloned());
info.type_ = "link".into();
info.title = get(&["og:title", "twitter:title"]).or_else(|| TITLE_RE.captures(head).map(|c| utils::html_decode(c[1].trim()))).filter(|t| !t.is_empty());
info.description = get(&["og:description", "twitter:description", "description"]).map(|d| d.chars().take(300).collect());
info.image = get(&["og:image", "og:image:url", "twitter:image", "twitter:image:src"]).and_then(|i| final_url.join(&i).ok()).map(|u| u.to_string());
if let Some(site) = get(&["og:site_name", "application-name"]) {
info.provider_name = Some(site);
}
info.author_name = get(&["author", "article:author"]).filter(|a| !a.starts_with("http"));
info.provider_icon = ICON_RE
.captures(head)
.and_then(|c| c.get(1).or(c.get(2)))
.and_then(|h| final_url.join(&utils::html_decode(h.as_str())).ok())
.or_else(|| final_url.join("/favicon.ico").ok())
.map(|u| u.to_string());
info.title.as_ref()?;
Some(info)
}
/// Our picture proxy, for the remote pictures displayed in the cards
pub fn protect_picture(ctx: &Ctx, url: &str) -> String {
format!("{}picture?url={}", ctx.base_uri(), percent_encoding::utf8_percent_encode(url, percent_encoding::NON_ALPHANUMERIC))
}
/// The card of a URL in a message (Movim's `_chat_embed`)
pub fn card_html(ctx: &Ctx, info: &UrlInfo) -> String {
ctx.render(
"Chat/_chat_embed.html",
json!({
"url": info,
"image": info.image.as_deref().map(|i| protect_picture(ctx, i)),
"icon": info.provider_icon.as_deref().map(|i| protect_picture(ctx, i)),
"color": utils::string_to_color(&info.url),
"size": (info.content_length > 0).then(|| utils::human_size(info.content_length, &[
ctx.t("filesize.byte", &[]),
ctx.t("filesize.kilobyte", &[]),
ctx.t("filesize.megabyte", &[]),
ctx.t("filesize.gigabyte", &[]),
ctx.t("filesize.terabyte", &[]),
])),
}),
)
}
/// An OpenGraph card for a URL, displayed above the composer
pub async fn embed_html(ctx: &Ctx, url: &str) -> Option<String> {
let info = resolve(&ctx.app, url).await?;
let host = reqwest::Url::parse(&info.url).ok().and_then(|u| u.host_str().map(String::from)).unwrap_or_default();
Some(ctx.render(
"Preview/_embed.html",
json!({
"url": info.url,
"host": host,
"title": info.title.clone().or_else(|| Some(info.url.clone())),
"description": info.description.as_deref().map(|d| d.chars().take(200).collect::<String>()),
"image": info.image.as_deref().map(|i| protect_picture(ctx, i)),
}),
))
}
+137
View File
@@ -0,0 +1,137 @@
//! Account creation with XEP-0077 In-Band Registration (Subscribe and Register widgets).
use minidom::Element;
use serde_json::json;
use super::P;
use crate::{
app::Ctx,
forms,
proto::ns,
rpc, utils,
xmpp::{self, child_text, el, RegistrationStream},
};
static STREAMS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, (RegistrationStream, String, Option<Element>)>>> =
std::sync::LazyLock::new(Default::default);
pub async fn build(ctx: &Ctx) -> String {
let mut servers = vec![ctx.app.cfg.default_domain.clone()];
for d in &ctx.app.cfg.xmpp_whitelist {
if !servers.contains(d) {
servers.push(d.clone());
}
}
ctx.render("Subscribe/subscribe.html", json!({ "servers": servers }))
}
pub fn build_register(ctx: &Ctx, host: &str) -> String {
ctx.render("Register/register.html", json!({ "host": host }))
}
/// Legacy XEP-0077 fields as a data form
fn legacy_form(query: &Element) -> String {
let mut x = el("x", ns::DATA);
if let Some(i) = child_text(query, "instructions") {
x = x.append(el("instructions", ns::DATA).append(i).build());
}
for c in query.children() {
let (type_, label) = match c.name() {
"username" => ("text-single", "Username"),
"password" => ("text-private", "Password"),
"email" => ("text-single", "Email"),
"name" | "nick" => ("text-single", "Name"),
_ => continue,
};
x = x.append(
el("field", ns::DATA)
.attr(minidom::rxml::NcName::try_from("var").unwrap(), c.name())
.attr(minidom::rxml::NcName::try_from("type").unwrap(), type_)
.attr(minidom::rxml::NcName::try_from("label").unwrap(), label)
.build(),
);
}
forms::to_html(&x.build())
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
let Some(session) = ctx.session.clone() else { return false };
match func {
"ajaxGetForm" => {
let host = p.str(0);
if !utils::validate_domain(&host) || ctx.app.cfg.disable_registration {
return true;
}
let stream = match xmpp::registration_stream(&host, ctx.app.cfg.xmpp_plaintext.as_deref()).await {
Ok(s) => s,
Err(e) => {
ctx.toast(&format!("{}: {e}", ctx.t("error.service_unavailable", &[])));
return true;
}
};
let q = xmpp::iq("get", Some(&host), el("query", ns::REGISTER).build());
match stream.iq(q).await {
Ok(res) => {
let Some(query) = res.get_child("query", ns::REGISTER) else { return true };
let x = query.get_child("x", ns::DATA).cloned();
if let Some(url) = query.get_child("x", ns::OOB).and_then(|o| child_text(o, "url")) {
rpc!(ctx, "MovimUtils.redirect", url);
return true;
}
let html = match &x {
Some(x) => forms::to_html(x),
None => legacy_form(query),
};
STREAMS.lock().unwrap().insert(session.sid.clone(), (stream, host, x));
rpc!(ctx, "MovimTpl.fill", "#subscription_form", ctx.render("Register/_register_form.html", json!({ "formh": html })));
}
Err(e) => ctx.toast(&format!("{}: {e}", ctx.t("error.service_unavailable", &[]))),
}
}
"ajaxRegister" => {
let Some(data) = p.v(0).cloned() else { return true };
if let (Some(a), Some(b)) = (p.form(0, "re_password"), p.form(0, "password")) {
if a != b {
ctx.toast(&ctx.t("account.password_not_same", &[]));
return true;
}
}
let entry = STREAMS.lock().unwrap().get(&session.sid).cloned();
let Some((stream, host, original)) = entry else { return true };
let query = match &original {
Some(x) => el("query", ns::REGISTER).append(forms::submit_from_json(&data, Some(x))).build(),
None => {
let mut q = el("query", ns::REGISTER);
for f in ["username", "password", "email", "name", "nick"] {
if let Some(v) = p.form(0, f) {
q = q.append(el(f, ns::REGISTER).append(v).build());
}
}
q.build()
}
};
match stream.iq(xmpp::iq("set", Some(&host), query)).await {
Ok(_) => {
STREAMS.lock().unwrap().remove(&session.sid);
rpc!(ctx, "MovimTpl.fill", "#subscribe", ctx.render("Register/_register_registered.html", json!({})));
}
Err(e) => {
let msg = e.to_string();
let key = if msg.contains("conflict") {
"error.conflict"
} else if msg.contains("not-acceptable") {
"error.not_acceptable"
} else if msg.contains("forbidden") || msg.contains("not-allowed") {
"error.forbidden"
} else {
"error.service_unavailable"
};
let t = ctx.t(key, &[]);
ctx.toast(if t == key { &msg } else { &t });
}
}
}
_ => return false,
}
true
}
+1255
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
//! Search widget: the roster and "start a chat" drawer. SendTo is handled here too.
use serde_json::json;
use super::{contact_views, P};
use crate::{app::Ctx, rpc, utils};
pub fn build(_ctx: &Ctx) -> String {
String::new()
}
pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
match (widget, func) {
("Search", "ajaxRequest") => {
let html = ctx.render("Search/_search.html", json!({ "chatroomactions": p.b(0) }));
super::drawer(ctx, "search", &html, true, false);
rpc!(ctx, "Search.init", false);
}
("Search", "ajaxHttpInitRoster") => {
rpc!(ctx, "MovimTpl.fill", "#roster", prepare_roster(ctx).await);
rpc!(ctx, "Search.searchCurrent");
}
("Search", "ajaxSearch") => {
let key = p.str(0).replace("xmpp:", "").trim().to_string();
let key = key.split('?').next().unwrap_or("").to_string();
let me = ctx.jid();
let html = if utils::validate_bare_jid(&key) && key.contains('@') && key != me {
let picture = ctx.placeholder_url(&key);
ctx.render("Search/_search_results.html", json!({ "jid": key, "picture": picture }))
} else {
String::new()
};
rpc!(ctx, "MovimTpl.fill", "#results", html);
rpc!(ctx, "Search.searchClear");
}
("Search", "ajaxChat") => chat(ctx, &p.str(0), p.b(1)).await,
("SendTo", _) => {}
_ => return false,
}
true
}
pub async fn chat(ctx: &Ctx, jid: &str, muc: bool) {
if !utils::validate_jid(jid) {
return;
}
if muc {
rpc!(ctx, "MovimUtils.reload", ctx.route("chat", &[jid, "room"]));
} else {
super::chats::open(ctx, jid, false, true).await;
rpc!(ctx, "MovimUtils.reload", ctx.route("chat", &[jid]));
}
}
async fn prepare_roster(ctx: &Ctx) -> String {
let jids: Vec<String> = ctx.st(|st| st.roster.keys().cloned().collect()).unwrap_or_default();
let views = contact_views(ctx, &jids).await;
let mut contacts: Vec<_> = views.into_values().collect();
// Online contacts first, then by name
contacts.sort_by(|a, b| {
a.presence.is_none().cmp(&b.presence.is_none()).then(a.truename.to_lowercase().cmp(&b.truename.to_lowercase()))
});
let list: Vec<_> = contacts
.into_iter()
.map(|c| {
let (audio, video) = super::visio::call_targets(ctx, &c.jid);
let presence_text = c.presence.as_deref().map(|p| ctx.t(&format!("presence.{}", if p == "server_error" { "error" } else { p }), &[]));
json!({
"jid": c.jid,
"truename": c.truename,
"picture": c.picture,
"presence": c.presence,
"presence_text": presence_text,
"status": c.status,
"groups": c.groups,
"subscription": c.subscription,
"call_audio": audio,
"call_video": video,
})
})
.collect();
ctx.render("Search/_search_roster.html", json!({ "contacts": list }))
}
/// The roster changed: refresh the roster list if displayed
pub async fn on_roster_changed(ctx: &Ctx) {
rpc!(ctx, "MovimTpl.fill", "#roster", prepare_roster(ctx).await);
}
+55
View File
@@ -0,0 +1,55 @@
//! Stickers widget, used here for the emoji picker (reactions and insertion).
use serde_json::json;
use super::P;
use crate::{app::Ctx, db, utils};
pub fn build(_ctx: &Ctx) -> String {
String::new()
}
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxReaction" => {
let mid = p.i(0);
let mut suggested = vec![];
if let Some(mid) = mid {
let me = ctx.jid();
if let Ok(Some(m)) = db::message_by_mid(ctx.db(), &me, mid).await {
let reactions = db::reactions_for(ctx.db(), &[mid]).await.unwrap_or_default();
let mut emojis: Vec<String> = vec![];
// Emojis of the message body
let body = m.body.unwrap_or_default();
for e in body.split_whitespace().filter(|w| utils::is_single_emoji(w)) {
if !emojis.contains(&e.to_string()) {
emojis.push(e.to_string());
}
}
for r in reactions {
if !emojis.contains(&r.emoji) {
emojis.push(r.emoji);
}
}
for e in emojis {
let html = utils::replace_emojis(&e, &ctx.base_uri(), true);
if let Some((url, _)) = html.single {
suggested.push(json!({ "emoji": e, "url": url }));
}
}
}
}
let html = ctx.render("Stickers/_stickers_emojis.html", json!({ "mid": mid, "suggested": suggested }));
super::drawer(ctx, "emojis", &html, true, true);
crate::rpc!(ctx, "Stickers.setEmojisEvent", mid);
}
"ajaxShow" => {
// Sticker packs are not supported: show the emojis instead
let html = ctx.render("Stickers/_stickers_emojis.html", json!({ "mid": null, "suggested": [] }));
super::drawer(ctx, "emojis", &html, true, true);
crate::rpc!(ctx, "Stickers.setEmojisEvent", serde_json::Value::Null);
}
_ => return false,
}
true
}
+182
View File
@@ -0,0 +1,182 @@
//! XEP-0363 HTTP File Upload: the Upload widget asks for a slot, the browser
//! sends the file to `/upload/{id}` and the server forwards it to the slot.
//! Dictaphone, Snap and Draw reuse the same mechanism.
use axum::{
extract::{Multipart, Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde_json::json;
use std::sync::Arc;
use super::P;
use crate::{
app::{App, Ctx},
proto::{self, ns},
rpc,
session::Upload,
utils,
};
pub fn build(_ctx: &Ctx) -> String {
String::new()
}
pub async fn call(ctx: &Ctx, widget: &str, func: &str, p: &P<'_>) -> bool {
match (widget, func) {
("Upload", "ajaxGetPanel") => {
let service = ctx.st(|st| st.upload.clone()).flatten();
let Some(service) = service else {
ctx.toast(&ctx.t("upload.not_available", &[]));
return true;
};
let units = [
ctx.t("filesize.byte", &[]),
ctx.t("filesize.kilobyte", &[]),
ctx.t("filesize.megabyte", &[]),
ctx.t("filesize.gigabyte", &[]),
ctx.t("filesize.terabyte", &[]),
];
let html = ctx.render(
"Upload/_upload.html",
json!({
"max_size": service.max_size,
"max_size_human": service.max_size.map(|s| utils::human_size(s as i64, &units)),
}),
);
super::dialog(ctx, &html, false, false);
rpc!(ctx, "Upload.attachEvents");
}
("Upload", "ajaxPrepare") | ("Upload", "ajaxSend") => {
let file = p.v(0).cloned().unwrap_or_default();
let name = file.get("name").and_then(|v| v.as_str()).unwrap_or("file").to_string();
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
let type_ = file
.get("type")
.and_then(|v| v.as_str())
.filter(|t| t.contains('/'))
.unwrap_or("application/octet-stream")
.to_string();
request_slot(ctx, &name, size, &type_).await;
}
("Upload", "ajaxFailed") | ("Upload", "ajaxHttpError") => ctx.toast(&ctx.t("upload.error_failed", &[])),
("Upload", _) => {}
("Dictaphone", "ajaxHttpGet") => {
if ctx.st(|st| st.upload.is_some()).unwrap_or(false) {
rpc!(ctx, "MovimTpl.fill", "#dictaphone", ctx.render("Dictaphone/_dictaphone.html", json!({})));
rpc!(ctx, "Dictaphone.init");
}
}
("Draw", "ajaxHttpGet") => {
rpc!(ctx, "MovimTpl.fill", "#draw", ctx.render("Draw/_draw.html", json!({})));
rpc!(ctx, "Draw.init");
}
("Draw", "ajaxHttpClear") => rpc!(ctx, "MovimTpl.fill", "#draw", ""),
_ => return false,
}
true
}
async fn request_slot(ctx: &Ctx, name: &str, size: i64, type_: &str) {
let Some(service) = ctx.st(|st| st.upload.clone()).flatten() else { return };
let Some(x) = ctx.xmpp() else { return };
if service.max_size.is_some_and(|m| size as u64 > m) {
ctx.toast(&ctx.t("upload.error_filesize", &[]));
return;
}
match x.iq(proto::upload_request(&service.jid, name, size, type_)).await {
Ok(res) => {
let Some(slot) = res.get_child("slot", ns::UPLOAD) else { return };
let put = slot.get_child("put", ns::UPLOAD);
let get = slot.get_child("get", ns::UPLOAD).and_then(|g| g.attr("url")).map(String::from);
let (Some(put), Some(get)) = (put, get) else { return };
let put_url = put.attr("url").unwrap_or("").to_string();
let headers: Vec<(String, String)> = put
.children()
.filter(|h| h.name() == "header")
.filter_map(|h| {
let n = h.attr("name")?;
// Only the headers allowed by XEP-0363
matches!(n.to_lowercase().as_str(), "authorization" | "cookie" | "expires").then(|| (n.to_string(), h.text()))
})
.collect();
let id = utils::generate_uuid();
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 },
)
});
rpc!(ctx, "Upload.request", ctx.route("upload", &[&id]), id);
}
Err(e) => {
let msg = e.to_string();
let key = if msg.contains("not-acceptable") || msg.contains("file-too-large") {
"upload.error_filesize"
} else if msg.contains("resource-constraint") {
"upload.error_resource_constraint"
} else if msg.contains("not-allowed") || msg.contains("forbidden") {
"upload.error_not_allowed"
} else {
"upload.error_failed"
};
ctx.toast(&ctx.t(key, &[]));
}
}
}
/// `POST /upload/{id}`: forward the file to the HTTP Upload slot
pub async fn http_upload(
State(app): State<Arc<App>>,
Path(id): Path<String>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Response {
let Some(session) = crate::web::cookie_sid(&headers).and_then(|sid| app.hub.get(&sid)).filter(|s| s.is_logged()) else {
return StatusCode::FORBIDDEN.into_response();
};
let Some(upload) = session.st.lock().unwrap().uploads.get(&id).cloned() else {
return StatusCode::NOT_FOUND.into_response();
};
let mut data = None;
while let Ok(Some(field)) = multipart.next_field().await {
if field.name() == Some(id.as_str()) {
data = field.bytes().await.ok();
break;
}
}
let Some(data) = data.filter(|d| !d.is_empty()) else {
return StatusCode::BAD_REQUEST.into_response();
};
let ctx = Ctx::ws(app.clone(), session.clone());
rpc!(ctx, "Upload.setProgress", "cloud_upload", ctx.t("upload.upload_xmpp", &[]));
let mut req = app
.http
.put(&upload.put)
.timeout(std::time::Duration::from_secs(300))
.header("Content-Type", upload.type_.clone())
.header("Content-Length", data.len().to_string());
for (k, v) in &upload.headers {
req = req.header(k, v);
}
match req.body(data).send().await {
Ok(res) if res.status().is_success() => {
session.st.lock().unwrap().uploads.entry(id).and_modify(|u| u.uploaded = true);
StatusCode::OK.into_response()
}
Ok(res) => {
tracing::info!("upload refused: {}", res.status());
StatusCode::NOT_ACCEPTABLE.into_response()
}
Err(e) => {
tracing::info!("upload failed: {e}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
+795
View File
@@ -0,0 +1,795 @@
//! Visio widget: 1:1 audio/video calls with Jingle (XEP-0166/0167/0176) and
//! Jingle Message Initiation (XEP-0353), compatible with Conversations and
//! Dino. WebRTC runs in the browser, SDPs are converted to Jingle here.
use minidom::Element;
use serde_json::{json, Value};
use std::time::Instant;
use super::{contact_view, notif, P};
use crate::{
app::Ctx,
db,
handler::Origin,
jingle::{self, SdpToJingle, NS_INFO, NS_JINGLE},
proto::ns,
rpc,
session::Call,
utils,
xmpp::{self, el, BuilderExt, NS_CLIENT},
};
pub fn build(ctx: &Ctx) -> String {
ctx.render("Visio/visio.html", json!({}))
}
// ---------------------------------------------------------------- Current call
fn start(ctx: &Ctx, jid: &str, id: &str) {
ctx.st(|st| {
if st.call.as_ref().is_none_or(|c| c.id != id) {
st.call = Some(Call { jid: jid.to_string(), id: id.to_string(), ..Default::default() });
}
});
}
fn answer(ctx: &Ctx, jid: &str, id: &str) {
ctx.st(|st| {
let c = st.call.get_or_insert_with(|| Call { jid: jid.to_string(), id: id.to_string(), ..Default::default() });
c.jid = jid.to_string();
if c.started.is_none() {
c.started = Some(Instant::now());
}
});
}
/// Stop the call if it is the current one, returns its duration when answered
fn stop(ctx: &Ctx, jid: &str, id: &str) -> Option<Option<u64>> {
ctx.st(|st| {
let current = st.call.as_ref()?;
if current.id != id && utils::bare_jid(&current.jid) != utils::bare_jid(jid) {
return None;
}
let duration = current.started.map(|s| s.elapsed().as_secs());
st.call = None;
Some(duration)
})
.flatten()
}
fn has_id(ctx: &Ctx, id: &str) -> bool {
ctx.st(|st| st.call.as_ref().is_some_and(|c| c.id == id)).unwrap_or(false)
}
fn is_answered(ctx: &Ctx) -> bool {
ctx.st(|st| st.call.as_ref().is_some_and(|c| c.started.is_some())).unwrap_or(false)
}
/// Full JIDs able to receive an audio and a video call
pub fn call_targets(ctx: &Ctx, jid: &str) -> (Option<String>, Option<String>) {
ctx.st(|st| {
let mut audio = None;
let mut video = None;
if let Some(resources) = st.presences.get(jid) {
for p in resources.values().filter(|p| p.value < 5) {
let Some(ver) = &p.ver else { continue };
let Some((_, features)) = st.caps.get(ver) else { continue };
let full = format!("{jid}/{}", p.resource);
if features.iter().any(|f| f == "urn:xmpp:jingle:apps:rtp:audio") && audio.is_none() {
audio = Some(full.clone());
}
if features.iter().any(|f| f == "urn:xmpp:jingle:apps:rtp:video") && video.is_none() {
video = Some(full);
}
}
}
(audio, video)
})
.unwrap_or((None, None))
}
pub fn has_call(ctx: &Ctx) -> bool {
ctx.st(|st| st.call.is_some()).unwrap_or(false)
}
fn muji_room(ctx: &Ctx) -> Option<String> {
ctx.st(|st| st.call.as_ref().and_then(|c| c.muji.clone())).flatten()
}
pub fn is_in_call(ctx: &Ctx, jid: &str) -> bool {
ctx.st(|st| st.call.as_ref().is_some_and(|c| utils::bare_jid(&c.jid) == jid)).unwrap_or(false)
}
pub fn current_call_jid(ctx: &Ctx) -> Option<String> {
ctx.st(|st| st.call.as_ref().filter(|c| c.started.is_some()).map(|c| utils::bare_jid(&c.jid).to_string())).flatten()
}
/// XEP-0115: resolve the features of a client version string, once
pub async fn resolve_caps(ctx: &Ctx, from: &str, node: &str, ver: &str) {
let known = ctx.st(|st| st.caps.contains_key(ver)).unwrap_or(true);
if known {
return;
}
ctx.st(|st| st.caps.insert(ver.to_string(), (None, vec![])));
let Some(x) = ctx.xmpp() else { return };
let ctx = ctx.clone();
let (from, node, ver) = (from.to_string(), node.to_string(), ver.to_string());
tokio::spawn(async move {
let q = crate::proto::disco_info(&from, Some(&format!("{node}#{ver}")));
if let Ok(res) = x.iq(q).await {
let features = super::presence::disco_features(&res);
let name = res
.get_child("query", crate::proto::ns::DISCO_INFO)
.and_then(|q| q.children().find(|c| c.name() == "identity"))
.and_then(|i| i.attr("name"))
.map(String::from);
ctx.st(|st| st.caps.insert(ver.clone(), (name, features)));
let bare = utils::bare_jid(&from).to_string();
if notif::is_current(&ctx, &bare) {
super::chat::get_header(&ctx, &bare, false).await;
}
}
});
}
// ---------------------------------------------------------------- Event messages in the discussion
async fn event_message(ctx: &Ctx, from: &str, thread: &str, type_: &str, duration: Option<u64>) {
let me = ctx.jid();
let bare = utils::bare_jid(from).to_string();
let (jidfrom, jidto) = if type_ == "jingle_outgoing" || type_ == "jingle_retract" && is_mine_retract(ctx, thread) {
(me.clone(), bare.clone())
} else {
(bare.clone(), me.clone())
};
let subject = duration.map(|d| {
if d >= 3600 {
ctx.t("chat.jingle_hours", &[&(d / 3600).to_string(), &((d % 3600) / 60).to_string()])
} else if d >= 60 {
ctx.t("chat.jingle_minutes", &[&(d / 60).to_string(), &(d % 60).to_string()])
} else {
ctx.t("chat.jingle_seconds", &[&d.to_string()])
}
});
let m = db::Message {
user_id: me.clone(),
id: format!("m_{}", utils::generate_uuid()),
jidfrom,
jidto,
type_: type_.into(),
thread: Some(thread.to_string()),
subject,
body: Some(String::new()),
seen: true,
published: utils::now_sql(),
..Default::default()
};
if let Ok(mid) = db::message_save(ctx.db(), &m).await {
if let Ok(Some(saved)) = db::message_by_mid(ctx.db(), &me, mid).await {
super::chat::on_message(ctx, &saved, &Origin::Mam { result_id: String::new() }).await;
super::chats::on_message(ctx, &saved, &Origin::Live).await;
}
}
}
fn is_mine_retract(_ctx: &Ctx, _thread: &str) -> bool {
false
}
// ---------------------------------------------------------------- Stanzas
fn jm(to: &str, name: &str, id: &str) -> minidom::element::ElementBuilder {
let _ = to;
el(name, ns::JINGLE_MESSAGE).a("id", id)
}
fn jm_message(to: &str, child: Element) -> Element {
el("message", NS_CLIENT)
.a("to", to)
.a("type", "chat")
.a("id", utils::generate_uuid())
.append(child)
.append(el("store", ns::HINTS).build())
.build()
}
fn reason(name: &str, text: Option<&str>) -> Element {
let mut r = el("reason", NS_JINGLE).append(el(name, NS_JINGLE).build());
if let Some(t) = text {
r = r.append(el("text", NS_JINGLE).append(t).build());
}
r.build()
}
fn send_jingle(ctx: &Ctx, to: &str, jingle: Element) {
if let Some(x) = ctx.xmpp() {
x.iq_spawn(xmpp::iq("set", Some(to), jingle));
}
}
fn ice_pwd(ctx: &Ctx) -> Option<String> {
ctx.st(|st| st.caps.get("ice_pwd").and_then(|(p, _)| p.clone())).flatten()
}
fn remember_pwd(ctx: &Ctx, pwd: Option<String>) {
if let Some(p) = pwd {
ctx.st(|st| st.caps.insert("ice_pwd".into(), (Some(p), vec![])));
}
}
fn sdp_param(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Object(o)) => o.get("sdp").and_then(|s| s.as_str()).unwrap_or("").to_string(),
_ => String::new(),
}
}
fn convert(ctx: &Ctx, sdp: &str, sid: &str, responder: Option<&str>, action: &str, mid: Option<&str>, ufrag: Option<&str>) -> Element {
let me = ctx.xmpp().map(|x| x.jid.to_string()).unwrap_or_else(|| ctx.jid());
let pwd = ice_pwd(ctx);
let (jingle, found) = SdpToJingle {
initiator: &me,
sdp,
sid,
responder,
action: Some(action),
mid,
ufrag,
ice_pwd: pwd,
}
.generate();
remember_pwd(ctx, found);
jingle
}
// ---------------------------------------------------------------- Calls from the browser
pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
match func {
"ajaxClear" => rpc!(ctx, "MovimVisio.clear"),
"ajaxCheckStatus" => {
let (id, jid) = (p.s(0), p.s(1));
if let (Some(id), Some(jid)) = (id, jid) {
if has_id(ctx, &id) && is_in_call(ctx, utils::bare_jid(&jid)) {
terminate_current(ctx).await;
}
}
}
"ajaxGetLobby" => get_lobby(ctx, &p.str(0), p.b(1), p.b(2), p.s(3)).await,
"ajaxPropose" => {
let (to, id, video) = (p.str(0), p.str(1), p.b(2));
event_message(ctx, &to, &id, "jingle_outgoing", None).await;
start(ctx, &to, &id);
let mut propose = jm(&to, "propose", &id);
if video {
propose = propose.append(el("description", jingle::NS_RTP).a("media", "video").build());
}
propose = propose.append(el("description", jingle::NS_RTP).a("media", "audio").build());
ctx.send(jm_message(utils::bare_jid(&to), propose.build()));
}
"ajaxProceed" => {
let (to, id) = (p.str(0), p.str(1));
start(ctx, &to, &id);
answer(ctx, &to, &id);
rpc!(ctx, "Notif.incomingCallAnswer");
ctx.send(jm_message(&to, jm(&to, "proceed", &id).build()));
}
"ajaxReject" => {
let (to, id) = (p.str(0), p.str(1));
stop(ctx, &to, &id);
rpc!(ctx, "Notif.incomingCallAnswer");
ctx.send(jm_message(utils::bare_jid(&to), jm(&to, "reject", &id).append(reason("busy", Some("Rejected"))).build()));
event_message(ctx, &to, &id, "jingle_reject", None).await;
}
"ajaxMute" | "ajaxUnmute" => {
let (to, id, name) = (p.str(0), p.str(1), p.str(2));
let tag = if func == "ajaxMute" { "mute" } else { "unmute" };
let jingle = el("jingle", NS_JINGLE)
.a("action", "session-info")
.a("sid", id)
.append(el(tag, NS_INFO).a("name", name.trim_start_matches("mid")).build())
.build();
send_jingle(ctx, &to, jingle);
}
"ajaxSessionInitiate" => {
let (to, sdp, id) = (p.str(0), sdp_param(p.v(1)), p.str(2));
let mut jingle = convert(ctx, &sdp, &id, Some(&to), "session-initiate", None, None);
if let Some(room) = p.s(3).filter(|r| !r.is_empty()) {
jingle = jingle::with_muji_room(jingle, &room);
}
send_jingle(ctx, &to, jingle);
}
"ajaxSessionAccept" => {
let (to, id, sdp) = (p.str(0), p.str(1), sdp_param(p.v(2)));
let jingle = convert(ctx, &sdp, &id, Some(&to), "session-accept", None, None);
send_jingle(ctx, &to, jingle);
}
"ajaxContentAccept" => {
let (to, id, sdp) = (p.str(0), p.str(1), sdp_param(p.v(2)));
let jingle = convert(ctx, &sdp, &id, Some(&to), "content-accept", None, None);
send_jingle(ctx, &to, jingle);
}
"ajaxContentAdd" | "ajaxContentRemove" => {
let (to, sdp, id) = (p.str(0), sdp_param(p.v(1)), p.str(2));
let mids: Vec<String> = p.v(3).and_then(|v| v.as_array()).map(|a| a.iter().map(|m| m.as_str().map(String::from).unwrap_or_else(|| m.to_string())).collect()).unwrap_or_default();
let action = if func == "ajaxContentAdd" { "content-add" } else { "content-remove" };
let jingle = convert(ctx, &jingle::filter_sdp_media(&sdp, &mids), &id, None, action, None, None);
send_jingle(ctx, &to, jingle);
}
"ajaxContentModify" => {
let (to, sdp, id) = (p.str(0), sdp_param(p.v(1)), p.str(2));
let jingle = convert(ctx, &sdp, &id, None, "content-modify", None, None);
send_jingle(ctx, &to, jingle);
}
"ajaxCandidate" => {
let (to, id) = (p.str(0), p.str(1));
let Some(c) = p.v(2) else { return true };
let candidate = c.get("candidate").and_then(|v| v.as_str()).unwrap_or("");
if candidate.is_empty() {
return true;
}
let mid = c.get("sdpMid").map(|m| m.as_str().map(String::from).unwrap_or_else(|| m.to_string()));
let ufrag = c.get("usernameFragment").and_then(|v| v.as_str()).map(String::from);
let jingle = convert(ctx, &format!("a={candidate}"), &id, Some(&to), "transport-info", mid.as_deref(), ufrag.as_deref());
send_jingle(ctx, &to, jingle);
}
"ajaxTerminate" => {
let (to, sid) = (p.str(0), p.str(1));
let r = p.s(2).unwrap_or_else(|| "success".into());
let jingle = el("jingle", NS_JINGLE).a("action", "session-terminate").a("sid", sid).append(reason(&r, None)).build();
send_jingle(ctx, &to, jingle);
}
"ajaxRemoteGoodbye" => {
let call = ctx.st(|st| st.call.clone()).flatten();
if let Some(c) = call {
let contact = contact_view(ctx, utils::bare_jid(&c.jid)).await;
super::dialog(
ctx,
&ctx.render("Visio/_visio_remote_goodbye.html", json!({ "contact": contact, "jid": c.jid, "sid": c.id })),
false,
false,
);
}
}
"ajaxGoodbye" => {
let r = p.s(2).unwrap_or_else(|| "success".into());
goodbye(ctx, &p.str(0), &p.str(1), &r).await;
}
"ajaxResolveServices" => resolve_services(ctx).await,
"ajaxHttpPrepareInfo" => {
let to = p.str(0);
let bare = utils::bare_jid(&to).to_string();
if p.b(1) {
let conference = super::rooms::conference_view(ctx, &bare).await;
rpc!(ctx, "MovimTpl.fill", "#visio_contact", ctx.render("Visio/_visio_conference_info.html", json!({ "conference": conference })));
} else {
let contact = contact_view(ctx, &bare).await;
rpc!(ctx, "MovimTpl.fill", "#visio_contact", ctx.render("Visio/_visio_contact_info.html", json!({ "contact": contact })));
}
rpc!(ctx, "MovimVisio.moveToChat", bare);
}
"ajaxHttpGetStates" => {
let keys = ["calling", "ringing", "in_call", "failed", "connecting", "ended", "declined", "no_participants_left"];
let states: serde_json::Map<String, Value> = keys.iter().map(|k| (k.to_string(), json!(ctx.t(&format!("visio.{k}"), &[])))).collect();
rpc!(ctx, "MovimVisio.setStates", Value::Object(states));
}
"ajaxHttpPin" => ctx.toast(&ctx.t("visio.pinned", &[&p.str(0)])),
"ajaxHttpUnpin" => ctx.toast(&ctx.t("visio.unpinned", &[])),
// Muji (XEP-0272) group calls: a mesh of 1:1 Jingle sessions between the participants
"ajaxGetMujiLobby" => muji_lobby(ctx, &p.str(0), p.b(1), p.b(2), p.s(3)).await,
"ajaxJoinMuji" => {
let to = p.str(0);
muji_lobby(ctx, &to, false, p.b(1), Some(to.clone())).await;
}
"ajaxMujiPrepare" => muji_prepare(ctx, &p.str(0), p.b(1)).await,
"ajaxMujiPublish" => muji_publish(ctx, &p.str(0), &sdp_param(p.v(1)), p.b(2)).await,
"ajaxLeaveMuji" => leave_muji(ctx, &p.str(0)).await,
// SFU conferences (Movim's own extension) are not supported
"ajaxGetSFULobby" | "ajaxUpgradeCurrentCallToSFU" => {}
_ => return false,
}
true
}
async fn get_lobby(ctx: &Ctx, jid: &str, calling: bool, video: bool, id: Option<String>) {
let bare = utils::bare_jid(jid).to_string();
let contact = contact_view(ctx, &bare).await;
let html = ctx.render(
"Visio/_visio_lobby.html",
json!({ "contact": contact, "calling": calling, "withvideo": video, "id": id, "fullJid": jid }),
);
super::dialog(ctx, &html, false, true);
rpc!(ctx, "MovimVisio.getUserMedia", video);
if !calling {
rpc!(ctx, "Notif.incomingCall");
notif::append(
ctx,
notif::Notification {
key: "call".into(),
title: format!("📞 {}", contact.truename),
body: ctx.t("visio.calling", &[]),
url: String::new(),
picture: Some(contact.picture.clone()),
time: 5,
actions: json!([
{ "title": ctx.t("button.reply", &[]), "action": "call" },
{ "title": ctx.t("button.refuse", &[]), "action": "call_reject" }
]),
data: json!({ "jid": bare, "fullJid": jid, "callId": id }),
rpc_call: None,
},
)
.await;
}
}
// ---------------------------------------------------------------- Muji
fn is_connected_room(ctx: &Ctx, room: &str) -> bool {
ctx.st(|st| st.conferences.get(room).is_some_and(|c| c.connected)).unwrap_or(false)
}
async fn muji_lobby(ctx: &Ctx, room: &str, calling: bool, video: bool, id: Option<String>) {
if !is_connected_room(ctx, room) {
return;
}
let conference = super::rooms::conference_view(ctx, room).await;
let html = ctx.render(
"Visio/_visio_lobby.html",
json!({ "conference": conference, "calling": calling, "withvideo": video, "id": id }),
);
super::dialog(ctx, &html, false, true);
rpc!(ctx, "MovimVisio.getUserMedia", video);
}
/// Our presence in the room, with or without a <muji/> element
fn send_muji_presence(ctx: &Ctx, room: &str, muji: Option<Element>) {
let Some(nick) = super::rooms::my_nick(ctx, room) else { return };
let (show, status) = ctx.st(|st| (st.own_show.clone(), st.own_status.clone())).unwrap_or((None, None));
let mut p = crate::proto::presence(show.as_deref(), status.as_deref(), 0);
xmpp::ElementExt::set_a(&mut p, "to", &format!("{room}/{nick}"));
if let Some(m) = muji {
p.append_child(m);
}
ctx.send(p);
}
/// Our <muji/> element while in a group call in this room, for the presences we send
pub fn own_muji(ctx: &Ctx, room: &str) -> Option<Element> {
ctx.st(|st| {
let c = st.call.as_ref().filter(|c| c.muji.as_deref() == Some(room))?;
c.muji_xml.as_deref().and_then(|x| x.parse::<Element>().ok())
})
.flatten()
}
/// First step: tell the room that we are preparing to join
async fn muji_prepare(ctx: &Ctx, room: &str, video: bool) {
if !is_connected_room(ctx, room) || has_call(ctx) {
return;
}
ctx.st(|st| {
st.call = Some(Call {
jid: room.to_string(),
id: room.to_string(),
started: Some(Instant::now()),
muji: Some(room.to_string()),
muji_preparing: Some(video),
muji_xml: None,
})
});
let preparing = el("muji", jingle::NS_MUJI).append(el("preparing", jingle::NS_MUJI).build()).build();
ctx.st(|st| {
if let Some(c) = st.call.as_mut() {
c.muji_xml = Some(String::from(&preparing));
}
});
send_muji_presence(ctx, room, Some(preparing));
super::chat::get_header(ctx, room, true).await;
}
/// Publish our medias in the room. On the first publication, start a session
/// with each participant already there.
async fn muji_publish(ctx: &Ctx, room: &str, sdp: &str, init: bool) {
if muji_room(ctx).as_deref() != Some(room) || sdp.is_empty() {
return;
}
let me = ctx.xmpp().map(|x| x.jid.to_string()).unwrap_or_else(|| ctx.jid());
let (jingle, _) = SdpToJingle { initiator: &me, sdp, sid: room, responder: None, action: None, mid: None, ufrag: None, ice_pwd: None }.generate();
let muji = jingle::to_muji(&jingle);
ctx.st(|st| {
if let Some(c) = st.call.as_mut() {
c.muji_xml = Some(String::from(&muji));
}
});
send_muji_presence(ctx, room, Some(muji));
if init {
let me = ctx.jid();
let participants: Vec<_> = ctx
.st(|st| {
st.muc_presences
.get(room)
.map(|o| o.values().filter(|p| p.has_muji() && p.jid.as_deref() != Some(me.as_str())).cloned().collect())
})
.flatten()
.unwrap_or_default();
for p in participants {
init_session(ctx, &p).await;
}
rpc!(ctx, "MovimJingles.startCalls", room);
}
}
/// Prepare a session with a participant, the newcomer initiates it
async fn init_session(ctx: &Ctx, p: &crate::session::MucPresence) {
let Some(full) = &p.full_jid else { return };
let bare = utils::bare_jid(full).to_string();
let contact = contact_view(ctx, &bare).await;
let name = if contact.truename == bare { p.nick.clone() } else { contact.truename.clone() };
rpc!(ctx, "MovimJingles.initSession", bare, full.clone(), Value::Null, name, contact.picture.clone());
}
async fn leave_muji(ctx: &Ctx, room: &str) {
if muji_room(ctx).as_deref() != Some(room) {
return;
}
ctx.st(|st| st.call = None);
send_muji_presence(ctx, room, None);
rpc!(ctx, "MovimJingles.terminateAll");
super::chat::get_header(ctx, room, true).await;
}
/// A presence with a <muji/> element in a room, or one that lost it
pub async fn on_muji_presence(ctx: &Ctx, room: &str, p: &crate::session::MucPresence, previous: Option<&crate::session::MucPresence>, is_self: bool) {
if muji_room(ctx).as_deref() != Some(room) {
return;
}
if is_self {
// Our "preparing" presence came back: open the call
if p.muji.as_deref().is_some_and(|m| m.contains("preparing")) {
let video = ctx.st(|st| st.call.as_mut().and_then(|c| c.muji_preparing.take())).flatten();
if let Some(video) = video {
rpc!(ctx, "MovimVisio.init", room, room, room, video, true);
}
}
return;
}
if p.jid.as_deref() == Some(ctx.jid().as_str()) {
return;
}
if p.has_muji() {
init_session(ctx, p).await;
} else if previous.is_some_and(|o| o.has_muji()) {
if let Some(j) = previous.and_then(|o| o.jid.clone()) {
rpc!(ctx, "MovimJingles.onTerminate", j);
}
}
}
/// We left the room (kicked, banned…) during its group call
pub async fn on_muji_room_left(ctx: &Ctx, room: &str) {
if muji_room(ctx).as_deref() == Some(room) {
ctx.st(|st| st.call = None);
rpc!(ctx, "MovimJingles.terminateAll");
}
}
async fn goodbye(ctx: &Ctx, to: &str, sid: &str, r: &str) {
if is_answered(ctx) {
ctx.send(jm_message(utils::bare_jid(to), jm(to, "finish", sid).append(reason(r, Some("Success"))).build()));
let duration = stop(ctx, to, sid).flatten();
event_message(ctx, to, sid, "jingle_end", duration).await;
} else if has_id(ctx, sid) {
ctx.send(jm_message(utils::bare_jid(to), jm(to, "retract", sid).append(reason("cancel", Some("Retracted"))).build()));
stop(ctx, to, sid);
event_message(ctx, to, sid, "jingle_retract", None).await;
}
ctx.toast(&ctx.t("visio.ended", &[]));
rpc!(ctx, "MovimJingles.terminateAll", r);
}
/// XEP-0215 STUN/TURN servers, Google STUN servers as fallback like Movim
async fn resolve_services(ctx: &Ctx) {
let host = ctx.jid().split('@').nth(1).unwrap_or("").to_string();
let mut services: Vec<Value> = vec![];
if let Some(x) = ctx.xmpp() {
let q = xmpp::iq("get", Some(&host), el("services", ns::EXTERNAL_SERVICES).build());
if let Ok(res) = x.iq(q).await {
let (mut stun, mut turn) = (false, false);
if let Some(list) = res.get_child("services", ns::EXTERNAL_SERVICES) {
for s in list.children().filter(|c| c.name() == "service") {
let type_ = s.attr("type").unwrap_or("");
if !(type_ == "stun" || type_ == "turn" || type_ == "turns") {
continue;
}
if (type_ == "stun" && stun) || (type_ != "stun" && turn) {
continue;
}
if type_ == "stun" {
stun = true;
} else {
turn = true;
}
let mut url = format!("{type_}:{}", s.attr("host").unwrap_or(""));
if let Some(port) = s.attr("port") {
url.push(':');
url.push_str(port);
}
if let Some(t) = s.attr("transport").filter(|_| type_ != "stun") {
url.push_str(&format!("?transport={t}"));
}
let mut item = json!({ "urls": url });
if let (Some(u), Some(p)) = (s.attr("username"), s.attr("password")) {
item["username"] = json!(u);
item["credential"] = json!(p);
}
services.push(item);
}
}
}
}
if services.is_empty() {
services.push(json!({ "urls": ["stun:stun.l.google.com:19305", "stun:stun1.l.google.com:19305"] }));
}
rpc!(ctx, "MovimVisio.setServices", services);
}
// ---------------------------------------------------------------- Incoming
/// XEP-0353 messages
pub async fn on_jingle_message(ctx: &Ctx, m: &Element, jm: &Element, origin: &Origin) {
let from = m.attr("from").unwrap_or("").to_string();
let id = jm.attr("id").unwrap_or("").to_string();
let me = ctx.jid();
// Archived call messages are not replayed
if matches!(origin, Origin::Mam { .. }) {
return;
}
// Our own other clients
if let Origin::Carbon { sent } = origin {
if *sent || utils::bare_jid(&from) == me {
// Another of our clients answered or rejected: stop ringing here
if matches!(jm.name(), "proceed" | "reject" | "accept") && has_id(ctx, &id) {
stop(ctx, &from, &id);
rpc!(ctx, "Notif.incomingCallAnswer");
rpc!(ctx, "Dialog_ajaxClear");
rpc!(ctx, "MovimVisio.clear");
}
return;
}
}
match jm.name() {
"propose" => {
let busy = ctx.st(|st| st.call.is_some()).unwrap_or(false);
if busy {
ctx.send(jm_message(&from, el("reject", ns::JINGLE_MESSAGE).a("id", id.clone()).append(reason("busy", Some("Busy"))).build()));
return;
}
let video = jm.children().any(|d| d.name() == "description" && d.attr("media") == Some("video"));
event_message(ctx, &from, &id, "jingle_incoming", None).await;
start(ctx, &from, &id);
ctx.send(jm_message(&from, el("ringing", ns::JINGLE_MESSAGE).a("id", id.clone()).build()));
get_lobby(ctx, &from, false, video, Some(id)).await;
}
"ringing" => {}
"proceed" => {
answer(ctx, &from, &id);
rpc!(ctx, "MovimJingles.onProceed", utils::bare_jid(&from), from.clone(), id);
}
"accept" => {
answer(ctx, &from, &id);
rpc!(ctx, "Notif.incomingCallAnswer");
rpc!(ctx, "Dialog_ajaxClear");
}
"retract" | "reject" | "finish" => {
if !has_id(ctx, &id) {
return;
}
let duration = stop(ctx, &from, &id).flatten();
let type_ = match jm.name() {
"retract" => "jingle_retract",
"reject" => "jingle_reject",
_ => "jingle_finish",
};
event_message(ctx, &from, &id, type_, duration).await;
terminated(ctx, &from).await;
}
_ => {}
}
}
async fn terminated(ctx: &Ctx, from: &str) {
rpc!(ctx, "MovimVisio.clear");
rpc!(ctx, "Notif.incomingCallAnswer");
rpc!(ctx, "Dialog_ajaxClear");
ctx.toast(&ctx.t("visio.ended", &[]));
rpc!(ctx, "MovimJingles.onTerminate", utils::bare_jid(from));
}
/// Jingle IQs (session negotiation)
pub async fn on_jingle_iq(ctx: &Ctx, iq: &Element, j: &Element) {
let from = iq.attr("from").unwrap_or("").to_string();
let bare = utils::bare_jid(&from).to_string();
let action = j.attr("action").unwrap_or("");
let sid = j.attr("sid").unwrap_or("").to_string();
ctx.send(xmpp::result_for(iq, None));
match action {
"session-initiate" => {
let sdp = jingle::jingle_to_sdp(j);
rpc!(ctx, "MovimJingles.onInitiateSDP", bare, sdp.sdp, sdp.sid);
}
"session-accept" | "content-accept" => {
let sdp = jingle::jingle_to_sdp(j);
rpc!(ctx, "MovimJingles.onAcceptSDP", bare, sdp.sdp, sdp.sid);
}
"transport-info" => {
let sdp = jingle::jingle_to_sdp(j);
rpc!(ctx, "MovimJingles.onCandidate", bare, sdp.sdp, sdp.name.clone(), sdp.name, sdp.sid);
}
"session-info" => {
if let Some(m) = j.get_child("mute", NS_INFO) {
rpc!(ctx, "MovimJingles.onMute", bare, format!("mid{}", m.attr("name").unwrap_or("")));
}
if let Some(m) = j.get_child("unmute", NS_INFO) {
rpc!(ctx, "MovimJingles.onUnmute", bare, format!("mid{}", m.attr("name").unwrap_or("")));
}
}
"content-add" | "content-remove" => {
let sdp = jingle::jingle_to_sdp(j);
let mids: Vec<String> = j.children().filter(|c| c.name() == "content").filter_map(|c| c.attr("name").map(String::from)).collect();
let f = if action == "content-add" { "MovimJingles.onContentAdd" } else { "MovimJingles.onContentRemove" };
rpc!(ctx, f, bare, sdp.sdp, mids);
}
"content-modify" => {
let sdp = jingle::jingle_to_sdp(j);
rpc!(ctx, "MovimJingles.onContentModify", bare, sdp.sdp);
}
"session-terminate" => {
// A participant leaves the group call
if muji_room(ctx).is_some() && !has_id(ctx, &sid) {
rpc!(ctx, "MovimJingles.onTerminate", bare);
return;
}
if has_id(ctx, &sid) {
let duration = stop(ctx, &from, &sid).flatten();
event_message(ctx, &from, &sid, "jingle_end", duration).await;
}
terminated(ctx, &from).await;
}
_ => {}
}
}
/// The XMPP connection is lost during a call
pub async fn on_disconnect(ctx: &Ctx) {
ctx.st(|st| st.call = None);
}
/// An answered call (or a group call) left behind by a closed or reloaded tab.
/// A ringing incoming call is kept: the tab may be opened to answer it.
pub async fn end_orphan_call(ctx: &Ctx) {
if is_answered(ctx) {
terminate_current(ctx).await;
}
}
/// Logout during a call, or no browser tab left
pub async fn terminate_current(ctx: &Ctx) {
let call = ctx.st(|st| st.call.clone()).flatten();
if let Some(room) = call.as_ref().and_then(|c| c.muji.clone()) {
leave_muji(ctx, &room).await;
return;
}
if let Some(c) = call {
let jingle = el("jingle", NS_JINGLE).a("action", "session-terminate").a("sid", c.id.clone()).append(reason("gone", None)).build();
send_jingle(ctx, &c.jid, jingle);
goodbye(ctx, &c.jid, &c.id, "gone").await;
}
}