diff --git a/build-release.sh b/build-release.sh index 8bb7cbc..7df0d33 100755 --- a/build-release.sh +++ b/build-release.sh @@ -7,11 +7,12 @@ # # Copy it to the server, then: tar xzf movim-*.tar.gz && cd movim-* && ./install.sh # -# Overridable: DEBIAN (default bookworm: Debian 12, also runs on 13 trixie), -# RUST_IMAGE (default rust:1-$DEBIAN), DOCKER (default docker; podman works). +# Overridable: DEBIAN (default trixie: Debian 13 and newer; bookworm for 12), +# RUST_IMAGE (default rust:1-$DEBIAN), DOCKER (default docker; podman works), +# REBUILD (yes/no; asked when a binary exists, default yes without a terminal). set -eu -DEBIAN=${DEBIAN:-bookworm} +DEBIAN=${DEBIAN:-trixie} RUST_IMAGE=${RUST_IMAGE:-rust:1-$DEBIAN} DOCKER=${DOCKER:-docker} @@ -19,8 +20,6 @@ die() { echo "error: $*" >&2; exit 1; } cd "$(dirname "$0")" [ -f Cargo.toml ] || die "run this from the movim-rs repository" -command -v "$DOCKER" >/dev/null || die "$DOCKER not found" -"$DOCKER" info >/dev/null 2>&1 || die "cannot reach $DOCKER; start it (sudo systemctl start docker) and make sure you may use it (docker group)" version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1) name="movim-$version-$DEBIAN" @@ -29,20 +28,39 @@ name="movim-$version-$DEBIAN" cache="target/docker-$DEBIAN" mkdir -p "$cache/cargo" -echo "==> Building $name in $RUST_IMAGE" -"$DOCKER" run --rm \ - --user "$(id -u):$(id -g)" \ - -v "$PWD:/src" -w /src \ - -e CARGO_HOME="/src/$cache/cargo" \ - -e CARGO_TARGET_DIR="/src/$cache" \ - "$RUST_IMAGE" \ - sh -c 'rustc --version && cargo build --release --locked' +bin="$cache/release/movim" +rebuild=${REBUILD:-} +if [ -z "$rebuild" ] && [ -x "$bin" ]; then + if [ -t 0 ]; then + printf 'A binary exists (built %s). Rebuild? [Y/n] ' "$(date -r "$bin" '+%Y-%m-%d %H:%M')" + read -r answer || answer=n + case $answer in [nN]*) rebuild=no ;; *) rebuild=yes ;; esac + else + rebuild=yes + fi +fi + +if [ "$rebuild" = no ]; then + [ -x "$bin" ] || die "REBUILD=no but there is no binary at $bin" + echo "==> Reusing $bin" +else + command -v "$DOCKER" >/dev/null || die "$DOCKER not found" + "$DOCKER" info >/dev/null 2>&1 || die "cannot reach $DOCKER; start it (sudo systemctl start docker) and make sure you may use it (docker group)" + echo "==> Building $name in $RUST_IMAGE" + "$DOCKER" run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PWD:/src" -w /src \ + -e CARGO_HOME="/src/$cache/cargo" \ + -e CARGO_TARGET_DIR="/src/$cache" \ + "$RUST_IMAGE" \ + sh -c 'rustc --version && cargo build --release --locked' +fi echo "==> Packing dist/$name.tar.gz" stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT mkdir "$stage/$name" -cp "$cache/release/movim" install.sh "$stage/$name/" +cp "$bin" install.sh "$stage/$name/" cp -R frontend templates "$stage/$name/" mkdir -p dist tar -C "$stage" -czf "dist/$name.tar.gz" "$name" diff --git a/frontend/locales/extra.ini b/frontend/locales/extra.ini index 2f81548..84e4b36 100644 --- a/frontend/locales/extra.ini +++ b/frontend/locales/extra.ini @@ -21,3 +21,7 @@ this_device = This device [omemo] this_device = This device + +[chat] +restore_history = Restore the history from the server +history_restored = The history will be fetched again from the server diff --git a/install.sh b/install.sh index a0d1d04..ef74696 100755 --- a/install.sh +++ b/install.sh @@ -9,8 +9,8 @@ # # Overridable: MOVIM_USER (default movim, created if missing), PREFIX # (default /opt/movim), DATA_DIR (default /var/lib/movim), CONF_DIR -# (default /etc/movim), XMPP_DOMAIN and WEB_HOST (asked when run in a -# terminal and the configuration does not exist yet). +# (default /etc/movim), XMPP_DOMAIN, WEB_HOST and MOVIM_PORT (asked when run +# in a terminal and the configuration does not exist yet). set -eu MOVIM_USER=${MOVIM_USER:-movim} @@ -19,19 +19,47 @@ DATA_DIR=${DATA_DIR:-/var/lib/movim} CONF_DIR=${CONF_DIR:-/etc/movim} XMPP_DOMAIN=${XMPP_DOMAIN:-} WEB_HOST=${WEB_HOST:-} +MOVIM_PORT=${MOVIM_PORT:-} UNIT=/etc/systemd/system/movim.service MIN_RUSTC=1.94 die() { echo "error: $*" >&2; exit 1; } +# Validators print why a value is refused. valid_host() { - printf '%s' "$1" | grep -Eq '^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$' + printf '%s' "$1" | grep -Eq '^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$' && return 0 + echo " not a valid domain name: $1" + return 1 } -# ask VAR "question" default: prompt on the terminal until a valid hostname. +port_in_use() { + command -v ss >/dev/null && [ -n "$(ss -Htln "sport = :$1")" ] +} + +# The service has no capabilities, so no privileged ports. +valid_port() { + case $1 in ''|*[!0-9]*) echo " not a port number: $1"; return 1 ;; esac + if [ "$1" -lt 1024 ] || [ "$1" -gt 65535 ]; then + echo " port must be between 1024 and 65535" + return 1 + fi + if port_in_use "$1"; then + echo " port $1 is already used by another service (ss -tlnp)" + return 1 + fi +} + +free_port() { + p=$1 + while port_in_use "$p"; do p=$((p + 1)); done + echo "$p" +} + +# ask VAR "question" default [validator]: prompt on the terminal until valid. ask() { + check=${4:-valid_host} eval "current=\${$1}" - [ -z "$current" ] || { valid_host "$current" || die "$1=$current is not a valid domain name"; return 0; } + [ -z "$current" ] || { "$check" "$current" >&2 || die "invalid $1"; return 0; } if [ ! -t 0 ]; then echo " no terminal, $1 defaults to $3 (set $1=... to choose)" eval "$1=\$3" @@ -41,11 +69,10 @@ ask() { printf '%s [%s]: ' "$2" "$3" read -r answer || die "aborted" answer=${answer:-$3} - if valid_host "$answer"; then + if "$check" "$answer"; then eval "$1=\$answer" return 0 fi - echo " not a valid domain name: $answer" done } @@ -71,6 +98,7 @@ fi # --- Questions (before the build, so nobody has to wait for it) ----------- if [ ! -e "$CONF_DIR/movim.env" ]; then ask XMPP_DOMAIN "XMPP domain users log in with (MOVIM_DOMAIN)" example.org + ask MOVIM_PORT "Local port for Movim (behind nginx)" "$(free_port 8080)" valid_port fi if [ -d /etc/nginx/sites-available ] && [ ! -e /etc/nginx/sites-available/movim ]; then default_web=movim.${XMPP_DOMAIN:-example.org} @@ -147,7 +175,7 @@ if [ ! -e "$CONF_DIR/movim.env" ]; then MOVIM_ROOT=$PREFIX MOVIM_DATA_DIR=$DATA_DIR # Listen on localhost only, behind a TLS reverse proxy (WebSocket on /ws/). -MOVIM_BIND=127.0.0.1:8080 +MOVIM_BIND=127.0.0.1:$MOVIM_PORT # XMPP domain proposed on the login page. MOVIM_DOMAIN=$XMPP_DOMAIN # Comma separated XMPP domains allowed to log in (empty: all). @@ -176,9 +204,7 @@ if [ -d /etc/nginx/sites-available ]; then if [ ! -e "$nginx_site" ]; then echo " writing $nginx_site" sudo tee "$nginx_site" >/dev/null < Result { .await?) } -pub async fn messages_clear(db: &Db, user: &str, jid: &str) -> Result<()> { - sqlx::query("DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)") +/// Clear the local history of a conversation. The archive is marked as fully +/// known and the clear date is kept, so the cleared messages are not fetched +/// again from MAM or MUC history (`archive_key`: jid, or `muc:` + room). +pub async fn messages_clear(db: &Db, user: &str, jid: &str, archive_key: &str) -> Result<()> { + let mut tx = db.begin().await?; + for sql in [ + "DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", + "DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))", + "DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)", + ] { + sqlx::query(sql).bind(user).bind(jid).bind(jid).execute(&mut *tx).await?; + } + sqlx::query("INSERT OR REPLACE INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, NULL)") .bind(user) - .bind(jid) - .bind(jid) - .execute(db) + .bind(archive_key) + .execute(&mut *tx) .await?; - sqlx::query("DELETE FROM mam_earliests WHERE user_id = ? AND jid = ?").bind(user).bind(jid).execute(db).await?; + sqlx::query("INSERT OR REPLACE INTO history_clears (user_id, jid, cleared_at) VALUES (?, ?, ?)") + .bind(user) + .bind(archive_key) + .bind(crate::utils::now_sql()) + .execute(&mut *tx) + .await?; + tx.commit().await?; Ok(()) } +/// Undo `messages_clear`: the cleared messages can be fetched again from MAM +pub async fn history_restore(db: &Db, user: &str, archive_key: &str) -> Result<()> { + let mut tx = db.begin().await?; + for sql in [ + "DELETE FROM history_clears WHERE user_id = ? AND jid = ?", + "DELETE FROM mam_earliests WHERE user_id = ? AND jid = ?", + ] { + sqlx::query(sql).bind(user).bind(archive_key).execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(()) +} + +/// When the history of a conversation was last cleared (`archive_key` as above) +pub async fn history_cleared_at(db: &Db, user: &str, archive_key: &str) -> Result> { + Ok(sqlx::query_scalar("SELECT cleared_at FROM history_clears WHERE user_id = ? AND jid = ?") + .bind(user) + .bind(archive_key) + .fetch_optional(db) + .await?) +} + +/// Latest clear of a 1:1 conversation +pub async fn latest_chat_clear(db: &Db, user: &str) -> Result> { + Ok(sqlx::query_scalar("SELECT MAX(cleared_at) FROM history_clears WHERE user_id = ? AND jid NOT LIKE 'muc:%'") + .bind(user) + .fetch_one(db) + .await?) +} + pub async fn unreads_count(db: &Db, user: &str, jid: &str, muc: bool) -> Result { let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE }; let types_sql = types.iter().map(|t| format!("'{t}'")).collect::>().join(","); @@ -1032,6 +1085,7 @@ pub async fn user_delete_data(db: &Db, user: &str) -> Result<()> { "DELETE FROM messages WHERE user_id = ?", "DELETE FROM open_chats WHERE user_id = ?", "DELETE FROM mam_earliests WHERE user_id = ?", + "DELETE FROM history_clears WHERE user_id = ?", "DELETE FROM omemo_states WHERE user_id = ?", "DELETE FROM encrypted_passwords WHERE user_id = ?", "DELETE FROM push_subscriptions WHERE user_id = ?", @@ -1083,3 +1137,48 @@ pub async fn push_subscription_toggle(db: &Db, user: &str, id: i64, enabled: boo .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn clear_history_is_remembered() { + // A file: every pool connection to sqlite::memory: gets its own database + let path = std::env::temp_dir().join(format!("movim-test-{}.sqlite", std::process::id())); + let db = connect(&format!("sqlite://{}?mode=rwc", path.display())).await.unwrap(); + let (me, bob) = ("me@example.org", "bob@example.org"); + let m = Message { + user_id: me.into(), + id: "m1".into(), + jidfrom: bob.into(), + jidto: me.into(), + type_: "chat".into(), + body: Some("hi".into()), + published: "2026-01-01 10:00:00".into(), + ..Default::default() + }; + let mid = message_save(&db, &m).await.unwrap(); + reactions_replace(&db, mid, bob, &["👍".into()]).await.unwrap(); + assert!(!mam_earliest_exists(&db, me, bob).await.unwrap()); + + messages_clear(&db, me, bob, bob).await.unwrap(); + + assert_eq!(messages_count(&db, me, bob).await.unwrap(), 0); + assert!(reactions_for(&db, &[mid]).await.unwrap().is_empty()); + // No MAM refetch when the chat is opened again, catch-up starts after the clear + assert!(mam_earliest_exists(&db, me, bob).await.unwrap()); + let cleared = history_cleared_at(&db, me, bob).await.unwrap().unwrap(); + assert!(cleared.as_str() > "2026-01-01 10:00:00"); + assert_eq!(latest_chat_clear(&db, me).await.unwrap(), Some(cleared)); + assert_eq!(history_cleared_at(&db, me, "muc:room@conference.example.org").await.unwrap(), None); + + history_restore(&db, me, bob).await.unwrap(); + assert!(!mam_earliest_exists(&db, me, bob).await.unwrap()); + assert_eq!(history_cleared_at(&db, me, bob).await.unwrap(), None); + db.close().await; + for ext in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{ext}", path.display())); + } + } +} diff --git a/src/mam.rs b/src/mam.rs index 6f59571..2236a71 100644 --- a/src/mam.rs +++ b/src/mam.rs @@ -46,6 +46,9 @@ async fn query(ctx: &Ctx, q: MamQuery<'_>) -> Option { pub async fn catch_up(ctx: &Ctx) { let me = ctx.jid(); let latest = db::latest_message_date(ctx.db(), &me).await.ok().flatten(); + // Never before a history clear: the cleared messages would come back. + let cleared = db::latest_chat_clear(ctx.db(), &me).await.ok().flatten(); + let latest = latest.max(cleared); match latest { Some(latest) => { @@ -154,7 +157,8 @@ pub async fn fetch_history(ctx: &Ctx, jid: &str, muc: bool) -> usize { pub async fn catch_up_room(ctx: &Ctx, room: &str) { let me = ctx.jid(); let latest = db::latest_muc_message_date(ctx.db(), &me, room).await.ok().flatten(); - let Some(latest) = latest else { + let cleared = db::history_cleared_at(ctx.db(), &me, &format!("muc:{room}")).await.ok().flatten(); + let Some(latest) = latest.max(cleared) else { fetch_history(ctx, room, true).await; return; }; diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index 45408fc..aa8fc6c 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -70,8 +70,29 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { "ajaxClearHistoryConfirm" => { let jid = p.str(0); if utils::validate_jid(&jid) { - let _ = db::messages_clear(ctx.db(), &ctx.jid(), &jid).await; let muc = ctx.st(|st| st.conferences.contains_key(&jid)).unwrap_or(false); + let archive_key = if muc { format!("muc:{jid}") } else { jid.clone() }; + if let Err(e) = db::messages_clear(ctx.db(), &ctx.jid(), &jid, &archive_key).await { + tracing::warn!("clear history of {jid}: {e}"); + } + if muc { + get_room(ctx, &jid, true).await; + } else { + get(ctx, Some(&jid), false).await; + } + } + } + "ajaxRestoreHistory" => { + let jid = p.str(0); + if utils::validate_jid(&jid) { + let muc = ctx.st(|st| st.conferences.contains_key(&jid)).unwrap_or(false); + let archive_key = if muc { format!("muc:{jid}") } else { jid.clone() }; + if let Err(e) = db::history_restore(ctx.db(), &ctx.jid(), &archive_key).await { + tracing::warn!("restore history of {jid}: {e}"); + return true; + } + ctx.toast(&ctx.t("chat.history_restored", &[])); + // Reopening fetches from MAM, scrolling up fetches older pages if muc { get_room(ctx, &jid, true).await; } else { @@ -105,11 +126,20 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool { pub async fn prepare_header(ctx: &Ctx, jid: &str, muc: bool) -> String { let counter = notif::unreads(ctx).await; + let archive_key = if muc { format!("muc:{jid}") } else { jid.to_string() }; + let cleared = db::history_cleared_at(ctx.db(), &ctx.jid(), &archive_key).await.ok().flatten().is_some(); if muc { let conference = rooms::conference_view(ctx, jid).await; ctx.render( "Chat/_chat_header.html", - json!({ "jid": jid, "muc": true, "counter": counter, "conference": conference, "in_call": super::visio::has_call(ctx) }), + json!({ + "jid": jid, + "muc": true, + "counter": counter, + "conference": conference, + "in_call": super::visio::has_call(ctx), + "cleared": cleared, + }), ) } else { let contact = contact_view(ctx, jid).await; @@ -129,6 +159,7 @@ pub async fn prepare_header(ctx: &Ctx, jid: &str, muc: bool) -> String { "call_video": video, "in_call": in_call, "omemo": omemo, + "cleared": cleared, }), ) } diff --git a/src/widgets/contactactions.rs b/src/widgets/contactactions.rs index 875fd36..3058f4e 100644 --- a/src/widgets/contactactions.rs +++ b/src/widgets/contactactions.rs @@ -6,7 +6,7 @@ use minidom::Element; use serde_json::{json, Value}; use super::{contact_view, P}; -use crate::{app::Ctx, proto, rpc, utils, xmpp}; +use crate::{app::Ctx, db, proto, rpc, utils, xmpp}; pub fn fingerprint(b64: &str) -> String { let Ok(bytes) = B64.decode(b64) else { return String::new() }; @@ -46,6 +46,7 @@ pub async fn get_drawer(ctx: &Ctx, jid: &str) { let (audio, video) = super::visio::call_targets(ctx, jid); let omemo = ctx.me().is_some_and(|u| u.omemoenabled) && jid != me; + let cleared = db::history_cleared_at(ctx.db(), &me, jid).await.ok().flatten().is_some(); let html = ctx.render( "ContactActions/_contactactions_drawer.html", @@ -57,6 +58,7 @@ pub async fn get_drawer(ctx: &Ctx, jid: &str) { "call_audio": audio, "call_video": video, "omemo": omemo, + "cleared": cleared, }), ); super::drawer(ctx, "contact_drawer", &html, false, false); diff --git a/src/widgets/rooms.rs b/src/widgets/rooms.rs index 4f12b12..9d54e32 100644 --- a/src/widgets/rooms.rs +++ b/src/widgets/rooms.rs @@ -494,10 +494,10 @@ pub async fn join(ctx: &Ctx, room: &str, nick: Option<&str>) { // History comes from MAM proto::muc_join(room, &nick, None, Some(0)) } else { - let since = db::latest_muc_message_date(ctx.db(), &ctx.jid(), room) - .await - .ok() - .flatten() + let latest = db::latest_muc_message_date(ctx.db(), &ctx.jid(), room).await.ok().flatten(); + let cleared = db::history_cleared_at(ctx.db(), &ctx.jid(), &format!("muc:{room}")).await.ok().flatten(); + let since = latest + .max(cleared) .and_then(|d| crate::dates::parse(&d)) .map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); match since { diff --git a/src/widgets_registry.rs b/src/widgets_registry.rs index 5ad8ac3..43f6419 100644 --- a/src/widgets_registry.rs +++ b/src/widgets_registry.rs @@ -142,6 +142,7 @@ pub static WIDGETS: &[WidgetDef] = &[ ("ajaxDisplayed", &["jid", "id"]), ("ajaxClearHistory", &["jid"]), ("ajaxClearHistoryConfirm", &["jid"]), + ("ajaxRestoreHistory", &["jid"]), ("ajaxHttpGetPresences", &["room"]), ("ajaxClearAndGetMessages", &["jid", "muc"]), ("ajaxHttpGetExplore", &["page"]), diff --git a/templates/Chat/_chat_header.html b/templates/Chat/_chat_header.html index 6249069..01da07e 100644 --- a/templates/Chat/_chat_header.html +++ b/templates/Chat/_chat_header.html @@ -173,6 +173,16 @@

{{ __('chat.clear_history') }}

+ {% if cleared %} +
  • + + history + +
    +

    {{ __('chat.restore_history') }}

    +
    +
  • + {% endif %}
  • @@ -272,6 +282,16 @@

    {{ __('chat.clear_history') }}

  • + {% if cleared %} +
  • + + history + +
    +

    {{ __('chat.restore_history') }}

    +
    +
  • + {% endif %}
    {% if contact.blocked %}
  • diff --git a/templates/ContactActions/_contactactions_drawer.html b/templates/ContactActions/_contactactions_drawer.html index 83f6c4d..f9caf2d 100644 --- a/templates/ContactActions/_contactactions_drawer.html +++ b/templates/ContactActions/_contactactions_drawer.html @@ -133,6 +133,16 @@

    {{ __('chat.clear_history') }}

  • + {% if cleared %} +
  • + + history + +
    +

    {{ __('chat.restore_history') }}

    +
    +
  • + {% endif %} {% endif %}