init
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)),
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user