optional HTTP headers. If provided, they are used when downloading media (for example authenticated media bearer tokens).
+
+
msg.access_token
+ string
+
+
optional Matrix access token. Used as a bearer token if msg.headers.Authorization is not present.
Outputs
@@ -74,4 +84,4 @@
MIME Types - description of msg.contentType format
-
\ No newline at end of file
+
diff --git a/src/matrix-crypt-file.js b/src/matrix-crypt-file.js
index 779a1c8..801a55c 100644
--- a/src/matrix-crypt-file.js
+++ b/src/matrix-crypt-file.js
@@ -9,7 +9,7 @@ module.exports = function(RED) {
this.name = n.name;
node.on("input", async function (msg) {
- const { got } = await import('got');
+ const got = (await import('got')).default;
if(!msg.type) {
node.error('msg.type is required.', msg);
@@ -32,7 +32,9 @@ module.exports = function(RED) {
}
try{
- let buffer = await got(msg.url).buffer();
+ const requestOptions = getRequestOptions(msg);
+
+ let buffer = await downloadBufferWithFallback(got, msg.url, requestOptions);
msg.payload = Buffer.from(await decryptAttachment(buffer, msg.content.file));
// handle thumbnail decryption if necessary
@@ -41,13 +43,14 @@ module.exports = function(RED) {
&& msg.thumbnail_url
&& msg.content.info.thumbnail_file
) {
- let thumb_buffer = await got(msg.thumbnail_url).buffer();
+ let thumb_buffer = await downloadBufferWithFallback(got, msg.thumbnail_url, requestOptions);
msg.thumbnail_payload = Buffer.from(await decryptAttachment(thumb_buffer, msg.content.info.thumbnail_file));
}
} catch(error){
node.error(error);
msg.error = error;
node.send([null, msg]);
+ return;
}
msg.filename = msg.content.filename || msg.content.body;
@@ -57,12 +60,58 @@ module.exports = function(RED) {
}
RED.nodes.registerType("matrix-decrypt-file", MatrixDecryptFile);
+ function getRequestOptions(msg) {
+ const headers = { ...(msg.headers || {}) };
+ if (!headers.Authorization && msg.access_token) {
+ headers.Authorization = `Bearer ${msg.access_token}`;
+ }
+
+ return Object.keys(headers).length ? { headers } : {};
+ }
+
+ function getMediaEndpointFallbackUrl(url) {
+ if (typeof url !== "string") {
+ return null;
+ }
+
+ if (url.includes("/_matrix/media/v3/download/")) {
+ return url.replace("/_matrix/media/v3/download/", "/_matrix/client/v1/media/download/");
+ }
+
+ if (url.includes("/_matrix/client/v1/media/download/")) {
+ return url.replace("/_matrix/client/v1/media/download/", "/_matrix/media/v3/download/");
+ }
+
+ if (url.includes("/_matrix/media/v3/thumbnail/")) {
+ return url.replace("/_matrix/media/v3/thumbnail/", "/_matrix/client/v1/media/thumbnail/");
+ }
+
+ if (url.includes("/_matrix/client/v1/media/thumbnail/")) {
+ return url.replace("/_matrix/client/v1/media/thumbnail/", "/_matrix/media/v3/thumbnail/");
+ }
+
+ return null;
+ }
+
+ async function downloadBufferWithFallback(got, url, requestOptions) {
+ try {
+ return await got(url, requestOptions).buffer();
+ } catch (error) {
+ const fallbackUrl = getMediaEndpointFallbackUrl(url);
+ if (error?.response?.statusCode === 404 && fallbackUrl && fallbackUrl !== url) {
+ return await got(fallbackUrl, requestOptions).buffer();
+ }
+
+ throw error;
+ }
+ }
+
function atob(a) {
- return new Buffer.from(a, 'base64').toString('binary');
+ return Buffer.from(a, 'base64').toString('binary');
}
function btoa(b) {
- return new Buffer.from(b).toString('base64');
+ return Buffer.from(b).toString('base64');
}
// the following was taken & modified from https://github.com/matrix-org/browser-encrypt-attachment/blob/master/index.js
@@ -200,4 +249,4 @@ module.exports = function(RED) {
}
return uint8Array;
}
-}
\ No newline at end of file
+}
diff --git a/src/matrix-crypto-store.js b/src/matrix-crypto-store.js
new file mode 100644
index 0000000..55577d3
--- /dev/null
+++ b/src/matrix-crypto-store.js
@@ -0,0 +1,248 @@
+/**
+ * Persistence helpers for the matrix-js-sdk Rust crypto store in Node.js.
+ *
+ * matrix-js-sdk v37+ removed the legacy (libolm) crypto stack. The Rust crypto
+ * replacement persists its state (device identity, Olm/megolm sessions, etc.)
+ * to IndexedDB, which does not exist in Node.js. We provide an in-memory
+ * IndexedDB via `fake-indexeddb` and snapshot the databases to/from disk so the
+ * crypto state survives Node-RED restarts.
+ *
+ * The `indexeddbshim` package (which can persist to disk directly) is not used
+ * because it is incompatible with the Rust crypto store migrations
+ * (see matrix-org/matrix-sdk-crypto-wasm#195). `fake-indexeddb` is spec
+ * compliant, so snapshotting it through the public IndexedDB API is reliable.
+ */
+const fs = require('fs-extra');
+const v8 = require('v8');
+
+let shimInstalled = false;
+
+// Mirrors matrix-js-sdk's localStorage-crypto-store.js: olm sessions live under
+// "crypto.sessions/" and migration batches are capped at 50.
+const E2E_PREFIX = 'crypto.';
+const SESSION_KEY_PREFIX = E2E_PREFIX + 'sessions/';
+const SESSION_BATCH_SIZE = 50;
+const OLM_BATCH_PATCH_MARKER = Symbol.for('node-red-contrib-matrix-chat.olmSessionBatchPatched');
+
+/**
+ * Install the in-memory IndexedDB shim onto globalThis. Idempotent. Must be
+ * called before MatrixClient.initRustCrypto().
+ */
+function ensureIndexedDBShim() {
+ if (shimInstalled || globalThis.indexedDB) {
+ shimInstalled = true;
+ return;
+ }
+ // `fake-indexeddb/auto` assigns indexedDB / IDBKeyRange / etc. onto globalThis.
+ require('fake-indexeddb/auto');
+ shimInstalled = true;
+}
+
+function reqAsync(req) {
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function txDone(tx) {
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
+ });
+}
+
+/**
+ * Restore previously snapshotted IndexedDB databases from `filePath` into the
+ * in-memory store. No-op if the snapshot does not exist. Databases that are
+ * already present in memory (e.g. after a Node-RED redeploy that kept the
+ * process alive) are left untouched so the live state is not clobbered.
+ *
+ * Must be called before MatrixClient.initRustCrypto().
+ *
+ * @returns {Promise} true if at least one database was restored.
+ */
+async function restoreCryptoStore(filePath) {
+ ensureIndexedDBShim();
+
+ if (!filePath || !fs.pathExistsSync(filePath)) {
+ return false;
+ }
+
+ let databases;
+ try {
+ databases = v8.deserialize(fs.readFileSync(filePath));
+ } catch (e) {
+ // Corrupt/unreadable snapshot - start fresh rather than crash.
+ return false;
+ }
+ if (!Array.isArray(databases) || !databases.length) {
+ return false;
+ }
+
+ const existing = new Set((await indexedDB.databases()).map((d) => d.name));
+ let restored = 0;
+
+ for (const dbSpec of databases) {
+ if (existing.has(dbSpec.name)) {
+ continue; // already live in memory - don't overwrite
+ }
+
+ const openReq = indexedDB.open(dbSpec.name, dbSpec.version);
+ openReq.onupgradeneeded = () => {
+ const db = openReq.result;
+ for (const store of dbSpec.stores) {
+ if (db.objectStoreNames.contains(store.name)) {
+ continue;
+ }
+ const os = db.createObjectStore(store.name, {
+ keyPath: store.keyPath || undefined,
+ autoIncrement: store.autoIncrement,
+ });
+ for (const ix of store.indexes) {
+ os.createIndex(ix.name, ix.keyPath, { unique: ix.unique, multiEntry: ix.multiEntry });
+ }
+ }
+ };
+ const db = await reqAsync(openReq);
+
+ for (const store of dbSpec.stores) {
+ if (!store.values.length) {
+ continue;
+ }
+ const tx = db.transaction(store.name, 'readwrite');
+ const os = tx.objectStore(store.name);
+ for (let i = 0; i < store.values.length; i++) {
+ if (store.keyPath) {
+ os.put(store.values[i]);
+ } else {
+ os.put(store.values[i], store.keys[i]);
+ }
+ }
+ await txDone(tx);
+ }
+ db.close();
+ restored++;
+ }
+
+ return restored > 0;
+}
+
+/**
+ * Snapshot IndexedDB databases to `filePath`. If `dbNamePrefix` is given only
+ * databases whose name starts with it are written, so multiple Matrix accounts
+ * sharing one process do not snapshot each other's data.
+ *
+ * The write is atomic (temp file + rename). Values are serialized with the V8
+ * serializer so typed arrays / Maps inside the crypto store survive intact.
+ *
+ * @returns {Promise} true if a snapshot file was written.
+ */
+async function snapshotCryptoStore(filePath, dbNamePrefix) {
+ if (!filePath || !globalThis.indexedDB || typeof indexedDB.databases !== 'function') {
+ return false;
+ }
+
+ let dbList = await indexedDB.databases();
+ if (dbNamePrefix) {
+ dbList = dbList.filter((d) => typeof d.name === 'string' && d.name.startsWith(dbNamePrefix));
+ }
+
+ const out = [];
+ for (const { name, version } of dbList) {
+ const db = await reqAsync(indexedDB.open(name, version));
+ const stores = [];
+ for (const storeName of Array.from(db.objectStoreNames)) {
+ const tx = db.transaction(storeName, 'readonly');
+ const os = tx.objectStore(storeName);
+ const indexes = Array.from(os.indexNames).map((n) => {
+ const ix = os.index(n);
+ return { name: n, keyPath: ix.keyPath, unique: ix.unique, multiEntry: ix.multiEntry };
+ });
+ stores.push({
+ name: storeName,
+ keyPath: os.keyPath,
+ autoIncrement: os.autoIncrement,
+ indexes,
+ values: await reqAsync(os.getAll()),
+ keys: await reqAsync(os.getAllKeys()),
+ });
+ }
+ db.close();
+ out.push({ name, version, stores });
+ }
+
+ const tmp = `${filePath}.tmp`;
+ fs.writeFileSync(tmp, v8.serialize(out));
+ fs.renameSync(tmp, filePath);
+ return true;
+}
+
+/**
+ * Patch a LocalStorageCryptoStore instance so its getEndToEndSessionsBatch()
+ * returns olm sessions with deviceKey/sessionId attached.
+ *
+ * Why: matrix-js-sdk's LocalStorageCryptoStore stores olm sessions as
+ * `{ session, lastReceivedMessageTs }` with the curve25519 deviceKey encoded
+ * only in the localStorage key ("crypto.sessions/"). On read,
+ * getEndToEndSessionsBatch() returns the bare session value without injecting
+ * the deviceKey or sessionId, so initRustCrypto()'s libolm-to-rust migration
+ * crashes at PickledSession.senderKey = session.deviceKey (undefined). The
+ * IndexedDB backend stores those fields in the record and is unaffected.
+ *
+ * Idempotent and safe to call on a store with no legacy sessions.
+ */
+function patchLocalStorageCryptoStoreForRustMigration(cryptoStore) {
+ if (!cryptoStore || cryptoStore[OLM_BATCH_PATCH_MARKER]) {
+ return cryptoStore;
+ }
+ const store = cryptoStore.store;
+ if (!store || typeof store.length !== 'number' || typeof store.key !== 'function') {
+ return cryptoStore;
+ }
+ cryptoStore.getEndToEndSessionsBatch = async function() {
+ const result = [];
+ for (let i = 0; i < store.length; i++) {
+ const key = store.key(i);
+ if (!key || !key.startsWith(SESSION_KEY_PREFIX)) {
+ continue;
+ }
+ const deviceKey = key.slice(SESSION_KEY_PREFIX.length);
+ let sessions;
+ try {
+ const raw = store.getItem(key);
+ sessions = raw ? JSON.parse(raw) : null;
+ } catch (e) {
+ sessions = null;
+ }
+ if (!sessions || typeof sessions !== 'object') {
+ continue;
+ }
+ for (const [sessionId, val] of Object.entries(sessions)) {
+ if (val === null || val === undefined) {
+ continue;
+ }
+ // Mirrors LocalStorageCryptoStore._getEndToEndSessions: very old
+ // entries were stored as bare base64 pickle strings.
+ const sessionInfo = (typeof val === 'string')
+ ? { session: val, lastReceivedMessageTs: 0 }
+ : val;
+ result.push({ ...sessionInfo, deviceKey, sessionId });
+ if (result.length >= SESSION_BATCH_SIZE) {
+ return result;
+ }
+ }
+ }
+ return result.length === 0 ? null : result;
+ };
+ cryptoStore[OLM_BATCH_PATCH_MARKER] = true;
+ return cryptoStore;
+}
+
+module.exports = {
+ ensureIndexedDBShim,
+ restoreCryptoStore,
+ snapshotCryptoStore,
+ patchLocalStorageCryptoStoreForRustMigration,
+};
diff --git a/src/matrix-get-user.js b/src/matrix-get-user.js
index 9afa8fc..0f8b595 100644
--- a/src/matrix-get-user.js
+++ b/src/matrix-get-user.js
@@ -106,14 +106,14 @@ module.exports = function(RED) {
let user2 = {};
try {
- let profileInfo = node.server.matrixClient.getProfileInfo(userId);
- if(Object.keys(profileInfo).length > 0) {
+ let profileInfo = await node.server.matrixClient.getProfileInfo(userId);
+ if(profileInfo && Object.keys(profileInfo).length > 0) {
user2.displayName = profileInfo.displayname;
user2.avatarUrl = profileInfo.avatar_url;
}
- let presence = node.server.matrixClient.getPresence(userId);
- if(Object.keys(presence).length > 0) {
+ let presence = await node.server.matrixClient.getPresence(userId);
+ if(presence && Object.keys(presence).length > 0) {
user2.currentlyActive = presence.currently_active;
user2.lastActiveAgo = presence.last_active_ago;
user2.presenceStatusMsg = presence.presence_status_msg;
diff --git a/src/matrix-invite-room.js b/src/matrix-invite-room.js
index 61c7589..22a7f92 100644
--- a/src/matrix-invite-room.js
+++ b/src/matrix-invite-room.js
@@ -54,9 +54,10 @@ module.exports = function(RED) {
return;
}
- // we need the status code, so set onlydata to false for this request
+ // invite(roomId, userId, opts|reason) - the SDK no longer accepts a
+ // callback argument, so the reason is passed as the 3rd parameter.
node.server.matrixClient
- .invite(msg.topic, msg.userId, undefined, msg.reason || undefined)
+ .invite(msg.topic, msg.userId, msg.reason || undefined)
.then(function(e){
msg.payload = e;
node.send([msg, null]);
diff --git a/src/matrix-markdown.js b/src/matrix-markdown.js
new file mode 100644
index 0000000..e833042
--- /dev/null
+++ b/src/matrix-markdown.js
@@ -0,0 +1,385 @@
+// Markdown -> HTML converter for matrix messages.
+//
+// Ported from matrix-react-sdk's `src/Markdown.ts` (now living at
+// element-hq/element-web `apps/web/src/Markdown.ts`) so the HTML this module
+// generates lines up with what Element produces for the same markdown source.
+//
+// Keep this in sync with element-web's Markdown.ts when noticeable changes
+// land there. Source of truth:
+// https://github.com/element-hq/element-web/blob/develop/apps/web/src/Markdown.ts
+//
+// Copyright 2024 New Vector Ltd.
+// Copyright 2021 The Matrix.org Foundation C.I.C.
+// Copyright 2016 OpenMarket Ltd
+//
+// SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+
+const commonmark = require("commonmark");
+const escape = require("lodash.escape");
+const linkify = require("linkifyjs");
+
+const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "s", "u", "br", "br/"];
+
+// These types of node are definitely text
+const TEXT_NODES = ["text", "softbreak", "linebreak", "paragraph", "document"];
+
+function isAllowedHtmlTag(node) {
+ if (!node.literal) {
+ return false;
+ }
+
+ if (node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
+ return true;
+ }
+
+ // Regex won't work for tags with attrs, but the tags we allow
+ // shouldn't really have any anyway.
+ const matches = /^<\/?(.*)>$/.exec(node.literal);
+ if (matches && matches.length == 2) {
+ const tag = matches[1];
+ return ALLOWED_HTML_TAGS.indexOf(tag) > -1;
+ }
+
+ return false;
+}
+
+/*
+ * Returns true if the parse output containing the node
+ * comprises multiple block level elements (ie. lines),
+ * or false if it is only a single line.
+ */
+function isMultiLine(node) {
+ let par = node;
+ while (par.parent) {
+ par = par.parent;
+ }
+ return par.firstChild != par.lastChild;
+}
+
+function getTextUntilEndOrLinebreak(node) {
+ let currentNode = node;
+ let text = "";
+ while (currentNode && currentNode.type !== "softbreak" && currentNode.type !== "linebreak") {
+ const { literal, type } = currentNode;
+ if (type === "text" && literal) {
+ let n = 0;
+ let char = literal[n];
+ while (char !== " " && char !== null && n <= literal.length) {
+ if (char === " ") {
+ break;
+ }
+ if (char) {
+ text += char;
+ }
+ n += 1;
+ char = literal[n];
+ }
+ if (char === " ") {
+ break;
+ }
+ }
+ currentNode = currentNode.next;
+ }
+ return text;
+}
+
+const formattingChangesByNodeType = {
+ emph: "_",
+ strong: "__",
+};
+
+/**
+ * Returns the literal of a node and all child nodes.
+ */
+const innerNodeLiteral = (node) => {
+ let literal = "";
+
+ const walker = node.walker();
+ let step;
+
+ while ((step = walker.next())) {
+ const currentNode = step.node;
+ const currentNodeLiteral = currentNode.literal;
+ if (step.entering && currentNode.type === "text" && currentNodeLiteral) {
+ literal += currentNodeLiteral;
+ }
+ }
+
+ return literal;
+};
+
+const emptyItemWithNoSiblings = (node) => {
+ return !node.prev && !node.next && !node.firstChild;
+};
+
+/**
+ * Class that wraps commonmark, adding the ability to see whether
+ * a given message actually uses any markdown syntax or whether
+ * it's plain text.
+ */
+class Markdown {
+ constructor(input) {
+ this.input = input;
+
+ const parser = new commonmark.Parser();
+ this.parsed = parser.parse(this.input);
+ this.parsed = this.repairLinks(this.parsed);
+ }
+
+ /**
+ * This method is modifying the parsed AST in such a way that links are always
+ * properly linkified instead of sometimes being wrongly emphasised in case
+ * if you were to write a link like the example below:
+ * https://my_weird-link_domain.domain.com
+ * ^ this link would be parsed to something like this:
+ * https://myweird-linkdomain.domain.com
+ * This method makes it so the link gets properly modified to a version where it is
+ * not emphasised until it actually ends.
+ * See: https://github.com/vector-im/element-web/issues/4674
+ */
+ repairLinks(parsed) {
+ const walker = parsed.walker();
+ let event = null;
+ let text = "";
+ let isInPara = false;
+ let previousNode = null;
+ let shouldUnlinkFormattingNode = false;
+ while ((event = walker.next())) {
+ const { node } = event;
+ if (node.type === "paragraph") {
+ isInPara = !!event.entering;
+ }
+ if (isInPara) {
+ // Clear saved string when line ends
+ if (
+ node.type === "softbreak" ||
+ node.type === "linebreak" ||
+ // Also start calculating the text from the beginning on any spaces
+ (node.type === "text" && node.literal === " ")
+ ) {
+ text = "";
+ continue;
+ }
+
+ // Break up text nodes on spaces, so that we don't shoot past them without resetting
+ if (node.type === "text" && node.literal) {
+ const [thisPart, ...nextParts] = node.literal.split(/( )/);
+ node.literal = thisPart;
+ text += thisPart;
+
+ // Add the remaining parts as siblings
+ nextParts.reverse().forEach((part) => {
+ if (part) {
+ const nextNode = new commonmark.Node("text");
+ nextNode.literal = part;
+ node.insertAfter(nextNode);
+ // Make the iterator aware of the newly inserted node
+ walker.resumeAt(nextNode, true);
+ }
+ });
+ }
+
+ // We should not do this if previous node was not a textnode, as we can't combine it then.
+ if (
+ (node.type === "emph" || node.type === "strong") &&
+ previousNode && previousNode.type === "text"
+ ) {
+ if (event.entering) {
+ const foundLinks = linkify.find(text);
+ for (const { value } of foundLinks) {
+ if (node && node.firstChild && node.firstChild.literal) {
+ /**
+ * NOTE: This technically should unlink the emph node and create LINK nodes instead, adding all the next elements as siblings
+ * but this solution seems to work well and is hopefully slightly easier to understand too
+ */
+ const format = formattingChangesByNodeType[node.type];
+ const nonEmphasizedText = `${format}${innerNodeLiteral(node)}${format}`;
+ const f = getTextUntilEndOrLinebreak(node);
+ const newText = value + nonEmphasizedText + f;
+ const newLinks = linkify.find(newText);
+ // Should always find only one link here, if it finds more it means that the algorithm is broken
+ if (newLinks.length === 1) {
+ const emphasisTextNode = new commonmark.Node("text");
+ emphasisTextNode.literal = nonEmphasizedText;
+ previousNode.insertAfter(emphasisTextNode);
+ node.firstChild.literal = "";
+ event = node.walker().next();
+ if (event) {
+ // Remove `em` opening and closing nodes
+ node.unlink();
+ previousNode.insertAfter(event.node);
+ shouldUnlinkFormattingNode = true;
+ }
+ } else {
+ console.warn(
+ "matrix-chat markdown: link escaping found too many links for text:",
+ text,
+ "modified:",
+ newText,
+ );
+ }
+ }
+ }
+ } else {
+ if (shouldUnlinkFormattingNode) {
+ node.unlink();
+ shouldUnlinkFormattingNode = false;
+ }
+ }
+ }
+ }
+ previousNode = node;
+ }
+ return parsed;
+ }
+
+ isPlainText() {
+ const walker = this.parsed.walker();
+ let ev;
+
+ while ((ev = walker.next())) {
+ const node = ev.node;
+
+ if (TEXT_NODES.indexOf(node.type) > -1) {
+ // definitely text
+ continue;
+ } else if (node.type == "list" || node.type == "item") {
+ // Special handling for inputs like `+`, `*`, `-` and `2021.` which
+ // would otherwise be treated as a list of a single empty item.
+ // See https://github.com/vector-im/element-web/issues/7631
+ if (
+ node.type == "list" &&
+ node.firstChild &&
+ emptyItemWithNoSiblings(node.firstChild)
+ ) {
+ // A list with a single empty item is treated as plain text.
+ continue;
+ }
+
+ if (node.type == "item" && emptyItemWithNoSiblings(node)) {
+ // An empty list item with no sibling items is treated as plain text.
+ continue;
+ }
+
+ // Everything else is actual lists and therefore not plaintext.
+ return false;
+ } else if (node.type == "html_inline" || node.type == "html_block") {
+ // if it's an allowed html tag, we need to render it and therefore
+ // we will need to use HTML. If it's not allowed, it's not HTML since
+ // we'll just be treating it as text.
+ if (isAllowedHtmlTag(node)) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ toHTML({ externalLinks = false } = {}) {
+ const renderer = new commonmark.HtmlRenderer({
+ safe: false,
+
+ // Set soft breaks to hard HTML breaks: commonmark
+ // puts softbreaks in for multiple lines in a blockquote,
+ // so if these are just newline characters then the
+ // block quote ends up all on one line
+ // (https://github.com/vector-im/element-web/issues/3154)
+ softbreak: " ",
+ });
+
+ // Trying to strip out the wrapping causes a lot more complication
+ // than it's worth, i think. For instance, this code will go and strip
+ // out any tag (no matter where it is in the tree) which doesn't
+ // contain \n's.
+ // On the flip side, s are quite opionated and restricted on where
+ // you can nest them.
+ //
+ // Let's try sending with s anyway for now, though.
+ const realParagraph = renderer.paragraph;
+ renderer.paragraph = function (node, entering) {
+ // If there is only one top level node, just return the
+ // bare text: it's a single line of text and so should be
+ // 'inline', rather than unnecessarily wrapped in its own
+ // p tag. If, however, we have multiple nodes, each gets
+ // its own p tag to keep them as separate paragraphs.
+ // However, if it's a blockquote, adds a p tag anyway
+ // in order to avoid deviation to commonmark and unexpected
+ // results when parsing the formatted HTML.
+ if ((node.parent && node.parent.type === "block_quote") || isMultiLine(node)) {
+ realParagraph.call(this, node, entering);
+ }
+ };
+
+ renderer.link = function (node, entering) {
+ const attrs = this.attrs(node);
+ if (entering && node.destination) {
+ attrs.push(["href", this.esc(node.destination)]);
+ if (node.title) {
+ attrs.push(["title", this.esc(node.title)]);
+ }
+ // Modified link behaviour to treat them all as external and
+ // thus opening in a new tab.
+ if (externalLinks) {
+ attrs.push(["target", "_blank"]);
+ attrs.push(["rel", "noreferrer noopener"]);
+ }
+ this.tag("a", attrs);
+ } else {
+ this.tag("/a");
+ }
+ };
+
+ renderer.html_inline = function (node) {
+ if (node.literal) {
+ if (isAllowedHtmlTag(node)) {
+ this.lit(node.literal);
+ } else {
+ this.lit(escape(node.literal));
+ }
+ }
+ };
+
+ renderer.html_block = function (node) {
+ renderer.html_inline(node);
+ };
+
+ return renderer.render(this.parsed);
+ }
+
+ /*
+ * Render the markdown message to plain text. That is, essentially
+ * just remove any backslashes escaping what would otherwise be
+ * markdown syntax
+ * (to fix https://github.com/vector-im/element-web/issues/2870).
+ *
+ * N.B. this does **NOT** render arbitrary MD to plain text - only MD
+ * which has no formatting. Otherwise it emits HTML(!).
+ */
+ toPlaintext() {
+ const renderer = new commonmark.HtmlRenderer({ safe: false });
+
+ renderer.paragraph = function (node, entering) {
+ // as with toHTML, only append lines to paragraphs if there are
+ // multiple paragraphs
+ if (isMultiLine(node)) {
+ if (!entering && node.next) {
+ this.lit("\n\n");
+ }
+ }
+ };
+
+ renderer.html_block = function (node) {
+ if (node.literal) this.lit(node.literal);
+ if (isMultiLine(node) && node.next) this.lit("\n\n");
+ };
+
+ // We inhibit the default escape function as we escape the entire output string to correctly handle backslashes
+ renderer.esc = (input) => input;
+
+ return escape(renderer.render(this.parsed));
+ }
+}
+
+module.exports = { Markdown };
diff --git a/src/matrix-receive.html b/src/matrix-receive.html
index 3274c4a..6c9c8c2 100644
--- a/src/matrix-receive.html
+++ b/src/matrix-receive.html
@@ -227,6 +227,11 @@
msg.content object
the message's content object
+
+
+
msg.headers object | null
+
for media events, includes auth headers (for example Authorization: Bearer ...) used by authed media endpoints.
+
msg.type == 'm.text'
@@ -352,10 +357,37 @@
msg.type == 'm.location'
+
+ The structured location fields are surfaced at the top level of
+ msg so the message can be wired straight into a
+ matrix-send-location node to resend the location
+ without any field translation in between.
+
msg.geo_uri string
-
URI format of the geolocation
+
The RFC 5870 geo URI from the event (e.g. geo:48.85,2.35).
+
+
msg.latitude number
+
Latitude in decimal degrees, parsed from the geo URI.
+
+
msg.longitude number
+
Longitude in decimal degrees, parsed from the geo URI.
+
+
msg.altitude number
+
Metres above sea level, parsed from the geo URI. Only set when the geo URI includes an altitude component (geo:lat,lng,alt).
+
+
msg.description string
+
The location's label (e.g. Eiffel Tower). Only set when the sender included one.
+
+
msg.assetType string
+
"m.self" when the sender was sharing their own location, "m.pin" for a generic dropped pin. Defaults to "m.self" when the event does not carry an explicit asset type (per the Matrix spec).
+
+
msg.timestamp number
+
Milliseconds since the UNIX epoch when the location was correct (the event's m.ts field). Only set when the sender included a timestamp.
+
+
msg.payload string
+
The event's body — a human-readable text fallback for clients that cannot render the map snippet.
-
\ No newline at end of file
+
diff --git a/src/matrix-receive.js b/src/matrix-receive.js
index 77059c8..028b7f1 100644
--- a/src/matrix-receive.js
+++ b/src/matrix-receive.js
@@ -1,3 +1,16 @@
+// Parse an RFC 5870 geo URI into {latitude, longitude, altitude?}.
+// Returns null if the URI is missing or malformed.
+function parseGeoUri(uri) {
+ if (typeof uri !== "string" || uri.indexOf("geo:") !== 0) return null;
+ // strip any ";u=..." / ";crs=..." parameters
+ const body = uri.slice(4).split(";")[0];
+ const parts = body.split(",").map(function(s) { return parseFloat(s.trim()); });
+ if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) return null;
+ const result = { latitude: parts[0], longitude: parts[1] };
+ if (parts.length >= 3 && Number.isFinite(parts[2])) result.altitude = parts[2];
+ return result;
+}
+
module.exports = function(RED) {
function MatrixReceiveMessage(n) {
RED.nodes.createNode(this, n);
@@ -47,11 +60,31 @@ module.exports = function(RED) {
return;
}
+ const setAuthHeaders = () => {
+ const accessToken = node.server.matrixClient.getAccessToken?.();
+ if (accessToken) {
+ msg.headers = {
+ ...(msg.headers || {}),
+ Authorization: `Bearer ${accessToken}`,
+ };
+ }
+ };
+
const setUrls = (urlKey, encryptedKey) => {
const url = msg.encrypted ? msg.content[encryptedKey]?.url : msg.content[urlKey];
if (url) {
- msg.url = node.server.matrixClient.mxcUrlToHttp(url);
+ const authenticatedUrl = node.server.matrixClient.mxcUrlToHttp(
+ url,
+ undefined,
+ undefined,
+ undefined,
+ false,
+ true,
+ true,
+ );
+ msg.url = authenticatedUrl || node.server.matrixClient.mxcUrlToHttp(url);
msg.mxc_url = url;
+ setAuthHeaders();
}
};
@@ -59,8 +92,18 @@ module.exports = function(RED) {
const thumbnailFile = msg.content.info?.[infoKey];
const thumbnailUrl = thumbnailFile?.url;
if (thumbnailUrl) {
- msg.thumbnail_url = node.server.matrixClient.mxcUrlToHttp(thumbnailUrl);
+ const authenticatedThumbnailUrl = node.server.matrixClient.mxcUrlToHttp(
+ thumbnailUrl,
+ undefined,
+ undefined,
+ undefined,
+ false,
+ true,
+ true,
+ );
+ msg.thumbnail_url = authenticatedThumbnailUrl || node.server.matrixClient.mxcUrlToHttp(thumbnailUrl);
msg.thumbnail_mxc_url = thumbnailUrl;
+ setAuthHeaders();
}
};
@@ -116,11 +159,31 @@ module.exports = function(RED) {
setThumbnailUrls('thumbnail_file');
break;
- case 'm.location':
+ case 'm.location': {
if (!node.acceptLocations) return;
msg.geo_uri = msg.content.geo_uri;
msg.payload = msg.content.body;
+ // Surface the structured location fields at the top level
+ // so a `matrix-send-location` node wired straight to this
+ // output resends the same location. Both the stable
+ // (m.location / m.asset / m.ts) and the MSC3488-prefixed
+ // namespaces are checked, since Element currently emits
+ // the prefixed form even though the spec is stable.
+ const loc = msg.content["m.location"] || msg.content["org.matrix.msc3488.location"] || {};
+ const asset = msg.content["m.asset"] || msg.content["org.matrix.msc3488.asset"] || {};
+ let ts = msg.content["m.ts"];
+ if (typeof ts !== "number") ts = msg.content["org.matrix.msc3488.ts"];
+ const coords = parseGeoUri(loc.uri || msg.geo_uri);
+ if (coords) {
+ msg.latitude = coords.latitude;
+ msg.longitude = coords.longitude;
+ if (coords.altitude !== undefined) msg.altitude = coords.altitude;
+ }
+ if (loc.description) msg.description = loc.description;
+ msg.assetType = asset.type || "m.self";
+ if (typeof ts === "number") msg.timestamp = ts;
break;
+ }
case 'm.reaction':
if (!node.acceptReactions) return;
@@ -141,4 +204,4 @@ module.exports = function(RED) {
});
}
RED.nodes.registerType("matrix-receive", MatrixReceiveMessage);
-}
\ No newline at end of file
+}
diff --git a/src/matrix-send-location.html b/src/matrix-send-location.html
new file mode 100644
index 0000000..78b9034
--- /dev/null
+++ b/src/matrix-send-location.html
@@ -0,0 +1,263 @@
+
+
+
+
+
diff --git a/src/matrix-send-location.js b/src/matrix-send-location.js
new file mode 100644
index 0000000..3ca4530
--- /dev/null
+++ b/src/matrix-send-location.js
@@ -0,0 +1,202 @@
+// matrix-js-sdk's main entry does not re-export `makeLocationContent`, so we
+// deep-import the content-helpers module. The SDK has no `exports` field in
+// its package.json (verified against v41) so subpath imports are stable.
+const contentHelpersPromise = import("matrix-js-sdk/lib/content-helpers.js");
+
+module.exports = function(RED) {
+ const VALID_ASSET_TYPES = ["m.self", "m.pin"];
+
+ function MatrixSendLocation(n) {
+ RED.nodes.createNode(this, n);
+ let node = this;
+
+ this.name = n.name;
+ this.server = RED.nodes.getNode(n.server);
+ this.roomId = n.roomId;
+
+ // Dynamic inputs: each has a `*Type` (msg | flow | global | str | num)
+ // and a `*Value` (the property path or literal). Defaults preserve the
+ // documented per-message names so existing flows keep working.
+ this.latitudeType = n.latitudeType || "msg";
+ this.latitudeValue = n.latitudeValue || "latitude";
+ this.longitudeType = n.longitudeType || "msg";
+ this.longitudeValue = n.longitudeValue || "longitude";
+ this.altitudeType = n.altitudeType || "msg";
+ this.altitudeValue = n.altitudeValue || "altitude";
+ this.geoUriType = n.geoUriType || "msg";
+ this.geoUriValue = n.geoUriValue || "geo_uri";
+ this.descriptionType = n.descriptionType || "msg";
+ this.descriptionValue = n.descriptionValue || "description";
+ this.assetTypeType = n.assetTypeType || "msg";
+ this.assetTypeValue = n.assetTypeValue || "assetType";
+ this.timestampType = n.timestampType || "msg";
+ this.timestampValue = n.timestampValue || "timestamp";
+ this.textType = n.textType || "msg";
+ this.textValue = n.textValue || "payload";
+
+ if (!node.server) {
+ node.warn("No configuration node");
+ return;
+ }
+ node.server.register(node);
+
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
+ node.server.on("disconnected", function() {
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
+ });
+ node.server.on("connected", function() {
+ node.status({ fill: "green", shape: "ring", text: "connected" });
+ });
+
+ /**
+ * Resolve a typed-input pair to its runtime value.
+ * msg | flow | global - read the property path from that source.
+ * num - parse the configured literal as a number;
+ * empty string => undefined.
+ * str - the configured literal; empty string => undefined.
+ * bool - "true" => true, anything else => false.
+ *
+ * Returning `undefined` from this signals "not provided", which is
+ * how optional fields opt out.
+ */
+ function getToValue(msg, type, property) {
+ if (type === "msg") {
+ return RED.util.getMessageProperty(msg, property);
+ }
+ if (type === "flow" || type === "global") {
+ try {
+ return RED.util.evaluateNodeProperty(property, type, node, msg);
+ } catch (e) {
+ throw new Error("Invalid " + type + " value evaluation for '" + property + "'");
+ }
+ }
+ if (property === "" || property === undefined || property === null) {
+ return undefined;
+ }
+ if (type === "num") {
+ const n = Number(property);
+ return Number.isFinite(n) ? n : undefined;
+ }
+ if (type === "bool") {
+ return property === "true";
+ }
+ // str / default
+ return property;
+ }
+
+ function isEmpty(v) {
+ return v === undefined || v === null || v === "";
+ }
+
+ node.on("input", async function(msg) {
+ if (!node.server || !node.server.matrixClient) {
+ node.warn("No matrix server selected");
+ return;
+ }
+ if (!node.server.isConnected()) {
+ node.error("Matrix server connection is currently closed", msg);
+ node.send([null, msg]);
+ return;
+ }
+
+ msg.topic = node.roomId || msg.topic;
+ if (!msg.topic) {
+ node.error("Room must be specified in msg.topic or in configuration", msg);
+ return;
+ }
+
+ // Resolve every typed input up-front so a single bad config or
+ // flow/global lookup surfaces as one clear error.
+ let rawLat, rawLng, rawAlt, rawGeoUri, description, assetType, rawTimestamp, text;
+ try {
+ rawLat = getToValue(msg, node.latitudeType, node.latitudeValue);
+ rawLng = getToValue(msg, node.longitudeType, node.longitudeValue);
+ rawAlt = getToValue(msg, node.altitudeType, node.altitudeValue);
+ rawGeoUri = getToValue(msg, node.geoUriType, node.geoUriValue);
+ description = getToValue(msg, node.descriptionType, node.descriptionValue);
+ assetType = getToValue(msg, node.assetTypeType, node.assetTypeValue);
+ rawTimestamp = getToValue(msg, node.timestampType, node.timestampValue);
+ text = getToValue(msg, node.textType, node.textValue);
+ } catch (e) {
+ node.error(e.message, msg);
+ node.send([null, msg]);
+ return;
+ }
+
+ // Build the geo URI: prefer an explicit geo_uri when supplied;
+ // otherwise build geo:,[,] from numeric inputs.
+ let geoUri = isEmpty(rawGeoUri) ? null : String(rawGeoUri);
+ if (!geoUri) {
+ const lat = parseFloat(rawLat);
+ const lng = parseFloat(rawLng);
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+ node.error("Latitude and longitude (numbers) - or a geo_uri - are required", msg);
+ node.send([null, msg]);
+ return;
+ }
+ if (lat < -90 || lat > 90) {
+ node.error("Latitude (" + lat + ") is out of range; must be between -90 and 90", msg);
+ node.send([null, msg]);
+ return;
+ }
+ if (lng < -180 || lng > 180) {
+ node.error("Longitude (" + lng + ") is out of range; must be between -180 and 180", msg);
+ node.send([null, msg]);
+ return;
+ }
+ geoUri = "geo:" + lat + "," + lng;
+ if (!isEmpty(rawAlt)) {
+ const alt = parseFloat(rawAlt);
+ if (!Number.isFinite(alt)) {
+ node.error("Altitude must be a number when provided", msg);
+ node.send([null, msg]);
+ return;
+ }
+ geoUri = "geo:" + lat + "," + lng + "," + alt;
+ }
+ }
+
+ // Asset type defaults to m.self if the resolved value is empty.
+ if (isEmpty(assetType)) {
+ assetType = "m.self";
+ }
+ if (VALID_ASSET_TYPES.indexOf(assetType) === -1) {
+ node.error('Invalid asset type "' + assetType + '"; must be "m.self" or "m.pin"', msg);
+ node.send([null, msg]);
+ return;
+ }
+
+ // Timestamp the location was correct, in ms since the UNIX epoch.
+ let timestamp = Date.now();
+ if (!isEmpty(rawTimestamp)) {
+ const ts = Number(rawTimestamp);
+ if (Number.isFinite(ts)) {
+ timestamp = ts;
+ }
+ }
+
+ // makeLocationContent uses `undefined` to mean "generate a default".
+ const cleanDescription = isEmpty(description) ? undefined : String(description);
+ const cleanText = isEmpty(text) ? undefined : String(text);
+
+ try {
+ const { makeLocationContent } = await contentHelpersPromise;
+ const content = makeLocationContent(cleanText, geoUri, timestamp, cleanDescription, assetType);
+ const response = await node.server.matrixClient.sendMessage(msg.topic, content);
+ msg.eventId = response.event_id;
+ msg.payload = content;
+ node.send([msg, null]);
+ } catch (e) {
+ node.error("Error sending location: " + e, msg);
+ msg.error = e;
+ node.send([null, msg]);
+ }
+ });
+
+ node.on("close", function() {
+ node.server.deregister(node);
+ });
+ }
+
+ RED.nodes.registerType("matrix-send-location", MatrixSendLocation);
+};
diff --git a/src/matrix-send-message.html b/src/matrix-send-message.html
index b35781b..f445f07 100644
--- a/src/matrix-send-message.html
+++ b/src/matrix-send-message.html
@@ -99,6 +99,7 @@
@@ -140,7 +141,7 @@
msg.formatted_payload
string
-
the formatted HTML message (uses msg.payload if not defined). This only affects HTML messages.
+
the formatted HTML message (uses msg.payload if not defined). This only affects messages sent in HTML format — in Markdown mode the formatted body is generated from the markdown source.
msg.type
string | null
@@ -150,7 +151,35 @@
msg.format
string | null
-
This is only used and required when configured so on the node. Set to null for plain text and 'html' for HTML.
+
This is only used and required when configured so on the node. Set to null for plain text, 'markdown' for markdown (converted to HTML the same way Element does), or 'html' for HTML.
+
+
+
Message formats
+
+
Default (plaintext)
+
The payload is sent as-is as the message body.
+
+
Markdown
+
+ The payload is parsed as CommonMark markdown and converted to HTML
+ the same way Element does (using the same converter ported from
+ matrix-react-sdk). If the message turns out to contain
+ no markdown syntax it is sent as plain text; otherwise the original
+ markdown source becomes the message body and the
+ rendered HTML is sent as formatted_body, so clients
+ without HTML rendering still see a readable fallback.
+
+
+
HTML
+
+ The payload is sent as HTML. By default the same HTML is used for
+ both the plain-text and formatted versions; set
+ msg.formatted_payload if you want the
+ formatted_body to differ from msg.payload.
+
+
+
msg.format input
+
Set msg.format at runtime to one of the options above (null, 'markdown', or 'html').
Outputs
diff --git a/src/matrix-send-message.js b/src/matrix-send-message.js
index c954ca1..5cb300f 100644
--- a/src/matrix-send-message.js
+++ b/src/matrix-send-message.js
@@ -1,4 +1,5 @@
const sdkPromise = import("matrix-js-sdk");
+const { Markdown } = require("./matrix-markdown");
module.exports = function(RED) {
function MatrixSendImage(n) {
@@ -143,7 +144,28 @@ module.exports = function(RED) {
body: payload.toString()
};
- if (msgFormat === 'html') {
+ if (msgFormat === 'markdown') {
+ // Convert the markdown body to HTML using the same logic
+ // as Element (matrix-react-sdk's `Markdown` class).
+ //
+ // If the message contains any markdown syntax, send the
+ // rendered HTML as `formatted_body` and keep the original
+ // markdown source as `body` (matrix spec convention for
+ // formatted messages). If the message turns out to be
+ // plain text and contains backslash escapes, strip those
+ // from `body` and send no HTML; otherwise leave `body`
+ // as the original payload.
+ const source = payload.toString();
+ const md = new Markdown(source);
+ if (md.isPlainText()) {
+ if (source.indexOf("\\") > -1) {
+ content.body = md.toPlaintext();
+ }
+ } else {
+ content.format = "org.matrix.custom.html";
+ content.formatted_body = md.toHTML();
+ }
+ } else if (msgFormat === 'html') {
content.format = "org.matrix.custom.html";
content.formatted_body =
(typeof msg.formatted_payload !== 'undefined' && msg.formatted_payload)
diff --git a/src/matrix-server-config.html b/src/matrix-server-config.html
index 60db2f7..801e350 100644
--- a/src/matrix-server-config.html
+++ b/src/matrix-server-config.html
@@ -31,17 +31,765 @@
accessToken: { type: "password", required: true },
deviceId: { type: "text", required: false },
url: { type: "text", required: true },
+ password: { type: "password", required: false },
},
defaults: {
name: { value: null },
autoAcceptRoomInvites: { value: true },
enableE2ee: { type: "checkbox", value: true },
global: { type: "checkbox", value: true },
- allowUnknownDevices: { type: "checkbox", value: false }
+ allowUnknownDevices: { type: "checkbox", value: true }
},
icon: "matrix.png",
label: function() {
return this.name || undefined;
+ },
+ oneditprepare: function() {
+ const nodeId = this.id;
+
+ // --- Secure backup / cross-signing setup (modal dialog) ---
+ // The modal is built once and reused across editor sessions; the
+ // current node id is stored on the overlay so its handlers target
+ // whichever server config node is being edited.
+ if (!document.getElementById("matrix-sb-overlay")) {
+ $('').appendTo("head");
+
+ $('
'
+ + '
Secure Backup & Cross-signing'
+ + '×
'
+ + '
'
+ + ''
+ + '
'
+ + ''
+ + ''
+ + '
The recovery key created when secure backup / key storage was first set up on this account.
'
+ + ''
+ + '
'
+ + '
'
+ + '
Resetting creates new cross-signing keys and a new recovery key, replacing the existing ones. Other sessions that trusted the old identity will need to be re-verified.
'
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + '
'
+ + '
'
+ + ''
+ + ''
+ + '
').appendTo(document.body);
+
+ var sbEsc = function(s) { return $("
").text(s == null ? "" : String(s)).html(); };
+ var sbClose = function() { $("#matrix-sb-overlay").fadeOut(120); };
+ var sbId = function() { return $("#matrix-sb-overlay").data("matrixNodeId"); };
+ var sbBtns = function(disabled) { $("#matrix-sb-unlock-btn,#matrix-sb-reset-btn").prop("disabled", disabled); };
+ var sbCall = function(body) {
+ return $.ajax({
+ url: "matrix-chat/secure-backup", type: "POST",
+ contentType: "application/json", data: JSON.stringify(body),
+ });
+ };
+ var sbState = function(icon, color, html) {
+ $("#matrix-sb-state").html('' + html + '');
+ };
+ var sbResult = function(ok, text, key) {
+ var h = sbEsc(text);
+ if (key) { h += '
' + sbEsc(key) + '
'; }
+ $("#matrix-sb-result").removeClass("ok err").addClass(ok ? "ok" : "err").html(h).show();
+ };
+ var sbStatus = function() {
+ $("#matrix-sb-unlock,#matrix-sb-reset,#matrix-sb-result,#matrix-sb-reset-toggle").hide();
+ sbState("fa-spinner fa-spin", "#888", "Checking the account…");
+ sbCall({ id: sbId(), action: "status" }).done(function(data) {
+ if (data.result !== "ok") {
+ sbState("fa-exclamation-triangle", "#c9302c", "Could not check the account.");
+ sbResult(false, data.message || "Unknown error");
+ return;
+ }
+ if (data.crossSigningReady) {
+ sbState("fa-check-circle", "#3a9a4e", "Cross-signing is set up. The bot's device is cross-signed.");
+ $("#matrix-sb-reset-toggle").show();
+ } else if (data.secretStorageExists) {
+ sbState("fa-lock", "#d18a1b", "This account has an existing secure backup. Enter its recovery key to set up cross-signing for the bot.");
+ $("#matrix-sb-recoverykey").val("");
+ $("#matrix-sb-unlock,#matrix-sb-reset-toggle").show();
+ } else {
+ sbState("fa-shield", "#888", "No secure backup exists yet. Set one up to enable cross-signing.");
+ $("#matrix-sb-password").val("");
+ $("#matrix-sb-reset").show();
+ }
+ sbBtns(false);
+ }).fail(function() {
+ sbState("fa-exclamation-triangle", "#c9302c", "Request failed — is Node-RED still running?");
+ });
+ };
+
+ $("#matrix-sb-x,#matrix-sb-close").on("click", sbClose);
+ $("#matrix-sb-overlay").on("mousedown", function(e) { if (e.target === this) { sbClose(); } });
+ $(document).on("keydown.matrixsb", function(e) {
+ if (e.key === "Escape" && $("#matrix-sb-overlay").is(":visible")) { sbClose(); }
+ });
+ $("#matrix-sb-reset-toggle").on("click", function() {
+ $(this).hide();
+ $("#matrix-sb-password").val("");
+ $("#matrix-sb-reset").show();
+ });
+ $("#matrix-sb-unlock-btn").on("click", function() {
+ sbBtns(true);
+ sbState("fa-spinner fa-spin", "#888", "Unlocking secure backup…");
+ sbCall({ id: sbId(), action: "unlock", recoveryKey: $("#matrix-sb-recoverykey").val() })
+ .done(function(data) {
+ if (data.result !== "ok") {
+ sbState("fa-lock", "#d18a1b", "Enter the recovery key to set up cross-signing.");
+ sbResult(false, data.message); sbBtns(false); return;
+ }
+ $("#matrix-sb-unlock,#matrix-sb-reset,#matrix-sb-reset-toggle").hide();
+ sbState("fa-check-circle", "#3a9a4e", "Done.");
+ sbResult(true, data.message);
+ })
+ .fail(function() { sbResult(false, "Request failed — is Node-RED still running?"); sbBtns(false); });
+ });
+ $("#matrix-sb-reset-btn").on("click", function() {
+ sbBtns(true);
+ sbState("fa-spinner fa-spin", "#888", "Resetting cross-signing & secure backup…");
+ sbCall({ id: sbId(), action: "reset", password: $("#matrix-sb-password").val() })
+ .done(function(data) {
+ if (data.result !== "ok") {
+ sbState("fa-shield", "#d18a1b", "Enter the account password to reset.");
+ sbResult(false, data.message); sbBtns(false); return;
+ }
+ $("#matrix-sb-unlock,#matrix-sb-reset,#matrix-sb-reset-toggle").hide();
+ sbState("fa-check-circle", "#3a9a4e", "Reset complete.");
+ sbResult(true, data.message, data.recoveryKey);
+ })
+ .fail(function() { sbResult(false, "Request failed — is Node-RED still running?"); sbBtns(false); });
+ });
+
+ // expose the status loader so per-session click handlers can call it
+ $("#matrix-sb-overlay").data("sbStatusFn", sbStatus);
+ }
+
+ $("#matrix-secure-backup-btn").on("click", function() {
+ $("#matrix-sb-overlay").data("matrixNodeId", nodeId).fadeIn(120);
+ $("#matrix-sb-overlay").data("sbStatusFn")();
+ });
+
+ // --- Verification list (modal) ---
+ // Built once and reused; the node id is stored on the overlay.
+ if (!document.getElementById("matrix-vl-overlay")) {
+ $('').appendTo("head");
+
+ $('
'
+ + '
Device Verification'
+ + '×
'
+ + '
'
+ + '
'
+ + '
Pending verification requests — this list refreshes every 5 seconds. Click a request to verify it.
Verified session '
+ + 'This session is ready for secure messaging.
'
+ : '
Not verified '
+ + (isCurrent
+ ? 'This session is not cross-signed. Use the Set up secure backup & cross-signing button to verify it.'
+ : 'Verify this session to confirm it is trusted.')
+ + '
- Password is never saved and is only used to fetch an access token using the button below.
+ Optional. Used to fetch an access token with the button below, and — if you
+ enable cross-signing — as a fallback when the homeserver requires the account
+ password to upload signing keys. If set, it is stored (encrypted) with the node's
+ credentials. Leave blank if you only want to use an access token.
@@ -145,88 +896,73 @@
Allow sending messages to a room with unknown devices which have not been verified.
-
+
+
+
+
+
+ View the account's logged-in sessions, verify them, or remove ones you don't recognize.
+ The server configuration must be deployed and connected first.
+
diff --git a/src/matrix-server-config.js b/src/matrix-server-config.js
index f3acc44..88afc75 100644
--- a/src/matrix-server-config.js
+++ b/src/matrix-server-config.js
@@ -1,51 +1,75 @@
-global.Olm = require('olm');
+// matrix-js-sdk is an ES module; load it via dynamic import so this CommonJS
+// node keeps working. All SDK-dependent setup awaits this promise.
+const sdkPromise = import("matrix-js-sdk");
+// The crypto-api enums (CryptoEvent, VerificationPhase, ...) are not re-exported
+// from the package root, so they are imported from the crypto-api subpath.
+const cryptoApiPromise = import("matrix-js-sdk/lib/crypto-api/index.js");
+
const fs = require("fs-extra");
-let RelationType, sdk, LocalStorageCryptoStore, RoomEvent, RoomMemberEvent, HttpApiEvent, ClientEvent, MemoryStore;
-
-(async () => {
- const mod = await import("matrix-js-sdk");
- RelationType = mod.RelationType;
- // matrix-js-sdk doesn't export a default – the top-level export is the same object:
- sdk = mod;
-
- RoomEvent = mod.RoomEvent;
- RoomMemberEvent = mod.RoomMemberEvent;
- HttpApiEvent = mod.HttpApiEvent;
- ClientEvent = mod.ClientEvent;
- MemoryStore = mod.MemoryStore;
-
- // For LocalStorageCryptoStore, specify the file extension for Node 20+:
- const cmod = await import("matrix-js-sdk/lib/crypto/store/localStorage-crypto-store.js");
- LocalStorageCryptoStore = cmod.LocalStorageCryptoStore;
-})();
-
const { resolve } = require('path');
const { LocalStorage } = require('node-localstorage');
+const {
+ ensureIndexedDBShim,
+ restoreCryptoStore,
+ snapshotCryptoStore,
+ patchLocalStorageCryptoStoreForRustMigration,
+} = require('./matrix-crypto-store');
require("abort-controller/polyfill"); // polyfill abort-controller if we don't have it
if (!globalThis.fetch) {
// polyfill fetch if we don't have it
if (!globalThis.fetch) {
import('node-fetch').then(({ default: fetch, Headers, Request, Response }) => {
- Object.assign(globalThis, { fetch, Headers, Request, Response });
- });
+ Object.assign(globalThis, { fetch, Headers, Request, Response })
+ })
}
}
+/**
+ * Resolve the real homeserver base URL for a configured server name / URL.
+ *
+ * Uses matrix-js-sdk's built-in .well-known auto-discovery: given e.g.
+ * "https://example.org" it looks up https://example.org/.well-known/matrix/client
+ * and returns the homeserver it delegates to (e.g. https://matrix.example.org).
+ * If there is no .well-known delegation (or discovery fails), the original URL
+ * is returned unchanged, so explicitly-configured homeserver URLs still work.
+ */
+async function resolveHomeserverUrl(sdk, configuredUrl) {
+ if(!configuredUrl) {
+ return configuredUrl;
+ }
+ let domain;
+ try {
+ domain = new URL(configuredUrl).host;
+ } catch(e) {
+ // not a full URL - treat the value itself as a domain
+ domain = String(configuredUrl).replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
+ }
+ if(!domain) {
+ return configuredUrl;
+ }
+ try {
+ const discovery = await sdk.AutoDiscovery.findClientConfig(domain);
+ const homeserver = discovery['m.homeserver'];
+ if(homeserver && homeserver.state === sdk.AutoDiscovery.SUCCESS && homeserver.base_url) {
+ return homeserver.base_url;
+ }
+ } catch(e) {
+ // discovery failed unexpectedly - fall back to the configured URL
+ }
+ return configuredUrl;
+}
+
module.exports = function(RED) {
// disable logging if set to "off"
let loggingSettings = RED.settings.get('logging');
- if (
+ if(
typeof loggingSettings.console !== 'undefined' &&
typeof loggingSettings.console.level !== 'undefined' &&
['info','debug','trace'].indexOf(loggingSettings.console.level.toLowerCase()) >= 0
) {
import('matrix-js-sdk/lib/logger.js')
- .then(({ logger }) => {
- logger.disableAll();
- })
- .catch((err) => {
- console.error("Error loading logger module:", err);
- });
+ .then(({ logger }) => logger.disableAll())
+ .catch(() => { /* logger module path changed - ignore */ });
}
function MatrixFolderNameFromUserId(name) {
@@ -73,12 +97,26 @@ module.exports = function(RED) {
this.url = this.credentials.url;
this.autoAcceptRoomInvites = n.autoAcceptRoomInvites;
this.e2ee = n.enableE2ee || false;
+ // Whether to send encrypted messages to devices that have not been
+ // verified. Undefined (config saved before this option existed) keeps
+ // the long-standing behaviour of allowing unverified devices.
+ this.allowUnknownDevices = n.allowUnknownDevices;
+ // Optional account password (used by the login helper, and as fallback
+ // user-interactive auth when resetting secure backup / cross-signing).
+ this.botPassword = this.credentials.password || null;
this.globalAccess = n.global;
- this.allowUnknownDevices = n.allowUnknownDevices || false;
this.initializedAt = new Date();
node.initialSyncLimit = 25;
- // Keep track of all consumers of this node to catch errors
+ // Live device-verification state, shared with the matrix-verification
+ // and matrix-verification-action nodes. Keyed by verification id.
+ node.verificationRequests = new Map(); // id -> VerificationRequest
+ node.verificationSas = new Map(); // id -> ShowSasCallbacks
+ // Cached Secure Secret Storage (4S) key as [keyId, Uint8Array], set by
+ // the /matrix-chat/secure-backup admin endpoint once unlocked.
+ node._secretStorageKeyCache = null;
+
+ // Keep track of all consumers of this node to be able to catch errors
node.register = function(consumerNode) {
node.users[consumerNode.id] = consumerNode;
};
@@ -97,10 +135,17 @@ module.exports = function(RED) {
let retryStartTimeout = null;
+ // Rust crypto persistence (see ./matrix-crypto-store.js). Each Matrix
+ // account gets its own IndexedDB name prefix and on-disk snapshot so
+ // multiple server-config nodes never collide.
+ let cryptoDbPrefix = 'mxjssdk-' + MatrixFolderNameFromUserId(this.userId),
+ cryptoSnapshotPath = null,
+ cryptoSnapshotInterval = null;
+
if(!this.credentials.accessToken) {
- node.error("Matrix connection failed: missing access token in configuration.", {});
+ node.error("Matrix connection failed: missing access token in configuration.");
} else if(!this.url) {
- node.error("Matrix connection failed: missing server URL in configuration.", {});
+ node.error("Matrix connection failed: missing server URL in configuration.");
} else {
node.setConnected = async function(connected, cb) {
if (node.connected !== connected) {
@@ -118,7 +163,7 @@ module.exports = function(RED) {
device_id = this.matrixClient.getDeviceId();
if(!device_id && node.enableE2ee) {
- node.error("Failed to auto detect deviceId for this auth token. You will need to manually specify one. You may need to login to create a new deviceId.", {});
+ node.error("Failed to auto detect deviceId for this auth token. You will need to manually specify one. You may need to login to create a new deviceId.")
} else {
if(!stored_device_id || stored_device_id !== device_id) {
node.log(`Saving Device ID (old:${stored_device_id} new:${device_id})`);
@@ -135,15 +180,15 @@ module.exports = function(RED) {
node.matrixClient.setDeviceDetails(device_id, {
display_name: node.deviceLabel
}).then(
- function() {},
+ function(response) {},
function(error) {
- node.error("Failed to set device label: " + error, {});
+ node.error("Failed to set device label: " + error);
}
);
}
},
function(error) {
- node.error("Failed to fetch device: " + error, {});
+ node.error("Failed to fetch device: " + error);
}
);
}
@@ -162,24 +207,62 @@ module.exports = function(RED) {
};
node.setConnected(false);
- fs.ensureDirSync(storageDir); // create storage directory if it doesn't exist
- upgradeDirectoryIfNecessary(node, storageDir);
- node.matrixClient = sdk.createClient({
- baseUrl: this.url,
- accessToken: this.credentials.accessToken,
- cryptoStore: new LocalStorageCryptoStore(localStorage),
- store: new MemoryStore({
- localStorage: localStorage,
- }),
- userId: this.userId,
- deviceId: (this.deviceId || getStoredDeviceId(localStorage)) || undefined
- });
+ node.isConnected = function() {
+ return node.connected;
+ };
- node.debug(`hasLazyLoadMembersEnabled=${node.matrixClient.hasLazyLoadMembersEnabled()}`);
+ // Snapshot the Rust crypto store to disk so E2EE state survives
+ // restarts. No-op when E2EE is disabled.
+ async function persistCrypto() {
+ if(!cryptoSnapshotPath) {
+ return;
+ }
+ try {
+ await snapshotCryptoStore(cryptoSnapshotPath, cryptoDbPrefix);
+ } catch(e) {
+ node.error("Failed to persist Matrix crypto store: " + e);
+ }
+ }
- // set globally if configured to do so
- if(this.globalAccess) {
- this.context().global.set('matrixClient["'+this.userId+'"]', node.matrixClient);
+ // Discard all persisted crypto state for this account. Used when the
+ // device ID changes - the old crypto store belongs to a device that
+ // no longer exists and the Rust crypto stack refuses to load it.
+ async function discardCryptoStore() {
+ // remove the persisted Rust crypto snapshot
+ try {
+ if(cryptoSnapshotPath) {
+ fs.removeSync(cryptoSnapshotPath);
+ }
+ } catch(e) {
+ node.warn("Could not remove crypto snapshot: " + e);
+ }
+ // remove legacy (libolm) crypto data from local storage
+ try {
+ for(let i = localStorage.length - 1; i >= 0; i--) {
+ let key = localStorage.key(i);
+ if(key && key.indexOf('crypto') === 0) {
+ localStorage.removeItem(key);
+ }
+ }
+ } catch(e) {
+ node.warn("Could not clear legacy crypto store: " + e);
+ }
+ // drop any in-memory IndexedDB database for this account's crypto store
+ try {
+ if(globalThis.indexedDB && typeof indexedDB.databases === 'function') {
+ let dbs = await indexedDB.databases();
+ for(let db of dbs) {
+ if(db.name && db.name.indexOf(cryptoDbPrefix) === 0) {
+ await new Promise(function(resolve) {
+ let req = indexedDB.deleteDatabase(db.name);
+ req.onsuccess = req.onerror = req.onblocked = function(){ resolve(); };
+ });
+ }
+ }
+ }
+ } catch(e) {
+ node.warn("Could not clear in-memory crypto database: " + e);
+ }
}
function stopClient() {
@@ -191,284 +274,475 @@ module.exports = function(RED) {
if(retryStartTimeout) {
clearTimeout(retryStartTimeout);
}
+ if(cryptoSnapshotInterval) {
+ clearInterval(cryptoSnapshotInterval);
+ cryptoSnapshotInterval = null;
+ }
}
node.on('close', function(done) {
stopClient();
- if(node.globalAccess) {
- try {
- node.context().global.set('matrixClient["'+node.userId+'"]', undefined);
- } catch(e) {
- node.error(e.message, {});
- }
- }
- done();
- });
-
- node.isConnected = function() {
- return node.connected;
- };
-
- node.matrixClient.on(RoomEvent.Timeline, async function(event, room, toStartOfTimeline, removed, data) {
- if (toStartOfTimeline) {
- node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: paginated result");
- return;
- }
- if (!data || !data.liveEvent) {
- node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message");
- return;
- }
- if(node.initializedAt > event.getDate()) {
- node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message before init");
- return;
- }
-
- try {
- await node.matrixClient.decryptEventIfNeeded(event);
- } catch (error) {
- node.error(error, {});
- return;
- }
-
- const isDmRoom = (room) => {
- // Find out if this is a direct message room.
- let isDM = !!room.getDMInviter();
- const allMembers = room.currentState.getMembers();
- if (!isDM && allMembers.length <= 2) {
- // if not a DM, but there are 2 users only
- // double check DM (needed because getDMInviter works only if you were invited, not if you invite)
- // hence why we check for each member
- if (allMembers.some((m) => m.getDMInviter())) {
- return true;
+ persistCrypto().finally(function() {
+ if(node.globalAccess) {
+ try {
+ node.context().global.set('matrixClient["'+node.userId+'"]', undefined);
+ } catch(e){
+ node.error(e.message);
}
}
- return allMembers.length <= 2 && isDM;
+ done();
+ });
+ });
+
+ fs.ensureDirSync(storageDir); // create storage directory if it doesn't exist
+ upgradeDirectoryIfNecessary(node, storageDir);
+
+ if(node.e2ee) {
+ cryptoSnapshotPath = localStorageDir + '/rust-crypto-store.v8';
+ }
+
+ setupClient().catch(function(error) {
+ node.error(error);
+ });
+
+ async function setupClient() {
+ const sdk = await sdkPromise;
+ const {
+ RelationType, RoomEvent, RoomMemberEvent, HttpApiEvent, ClientEvent,
+ MemoryStore, LocalStorageCryptoStore,
+ } = sdk;
+ const {
+ CryptoEvent, VerificationRequestEvent, VerifierEvent, VerificationPhase,
+ } = await cryptoApiPromise;
+
+ // ---- Device verification ----------------------------------
+ // Surface a verification request (and every subsequent phase
+ // change) to the matrix-verification node as a "Verification.update"
+ // event. Live request objects are kept in node.verificationRequests
+ // so the matrix-verification-action node can act on them by id.
+ function buildVerificationMsg(request, sasShown) {
+ let phase = sasShown
+ ? 'sas'
+ : String(VerificationPhase[request.phase] || 'unknown').toLowerCase();
+ let msg = {
+ verificationId : request.transactionId,
+ phase : phase,
+ payload : phase,
+ userId : request.otherUserId,
+ deviceId : request.otherDeviceId || null,
+ topic : request.roomId || null,
+ isSelfVerification: request.isSelfVerification,
+ initiatedByMe : request.initiatedByMe,
+ };
+ // chosenMethod is null until a verification method is picked.
+ // (request.methods is intentionally not used - it is not
+ // implemented in the Rust crypto stack and always throws.)
+ try {
+ msg.chosenMethod = request.chosenMethod || null;
+ } catch(e) {
+ msg.chosenMethod = null;
+ }
+ let sas = node.verificationSas.get(request.transactionId);
+ if(sas && sas.sas) {
+ msg.sas = {
+ emoji : sas.sas.emoji || null,
+ decimal: sas.sas.decimal || null,
+ };
+ }
+ if(request.phase === VerificationPhase.Cancelled) {
+ msg.cancellationCode = request.cancellationCode || null;
+ }
+ return msg;
+ }
+
+ // Emit a verification update. Never lets an exception escape -
+ // this runs inside the SDK's synchronous event emission, where an
+ // uncaught throw would crash Node-RED.
+ function emitVerificationUpdate(request, sasShown) {
+ try {
+ node.emit("Verification.update", buildVerificationMsg(request, sasShown));
+ } catch(e) {
+ node.error("Failed to process verification update: " + e);
+ }
+ }
+
+ node.trackVerificationRequest = function(request) {
+ let id;
+ try { id = request.transactionId; } catch(e) { id = undefined; }
+ if(!id) {
+ // transactionId is only assigned once the first event is
+ // sent - wait for it before tracking.
+ const waitForId = function() {
+ let tid;
+ try { tid = request.transactionId; } catch(e) { tid = undefined; }
+ if(tid) {
+ request.off(VerificationRequestEvent.Change, waitForId);
+ node.trackVerificationRequest(request);
+ }
+ };
+ request.on(VerificationRequestEvent.Change, waitForId);
+ return;
+ }
+ if(node.verificationRequests.has(id)) {
+ return; // already tracked
+ }
+ node.verificationRequests.set(id, request);
+ request.__nrSeenAt = Date.now();
+
+ let verifierHooked = false;
+ const onChange = function() {
+ try {
+ // Once a verifier exists, hook its SAS event so the
+ // emoji/decimal can be surfaced to the flow.
+ const verifier = request.verifier;
+ if(verifier && !verifierHooked) {
+ verifierHooked = true;
+ verifier.on(VerifierEvent.ShowSas, function(sasCallbacks) {
+ node.verificationSas.set(id, sasCallbacks);
+ emitVerificationUpdate(request, true);
+ });
+ }
+ emitVerificationUpdate(request, false);
+ if(request.phase === VerificationPhase.Done || request.phase === VerificationPhase.Cancelled) {
+ request.off(VerificationRequestEvent.Change, onChange);
+ // Keep the finished request briefly so the config
+ // editor's verification list can still report the
+ // outcome; the /matrix-chat/verification "list"
+ // action sweeps entries older than 2 minutes.
+ if(!request.__nrEndedAt) {
+ request.__nrEndedAt = Date.now();
+ }
+ }
+ } catch(e) {
+ node.error("Verification request handler error: " + e);
+ }
+ };
+ request.on(VerificationRequestEvent.Change, onChange);
+ emitVerificationUpdate(request, false);
};
- let msg = {
- encrypted : event.isEncrypted(),
- redacted : event.isRedacted(),
- content : event.getContent(),
- type : (event.getContent()['msgtype'] || event.getType()) || null,
- payload : (event.getContent()['body'] || event.getContent()) || null,
- isDM : isDmRoom(room),
- isThread : event.getContent()?.['m.relates_to']?.rel_type === RelationType.Thread,
- mentions : event.getContent()["m.mentions"] || null,
- userId : event.getSender(),
- user : node.matrixClient.getUser(event.getSender()),
- topic : event.getRoomId(),
- eventId : event.getId(),
- event : event,
- };
+ // Resolve the real homeserver via .well-known discovery so a
+ // delegating domain (e.g. "example.org") works as the server URL.
+ const baseUrl = await resolveHomeserverUrl(sdk, node.url);
+ if(baseUrl !== node.url) {
+ node.log(`Discovered homeserver ${baseUrl} for ${node.url} via .well-known`);
+ }
- // remove keys from user property that start with an underscore
- Object.keys(msg.user).forEach(function (key) {
- if (/^_/.test(key)) {
- delete msg.user[key];
+ let clientOpts = {
+ baseUrl: baseUrl,
+ accessToken: node.credentials.accessToken,
+ store: new MemoryStore({
+ localStorage: localStorage,
+ }),
+ userId: node.userId,
+ deviceId: (node.deviceId || getStoredDeviceId(localStorage)) || undefined,
+ cryptoCallbacks: {
+ // Supplies the Secure Secret Storage (4S) key to the crypto
+ // stack once it has been unlocked via the secure-backup
+ // admin endpoint. Returns null when no key is available.
+ getSecretStorageKey: async function({ keys }) {
+ if(node._secretStorageKeyCache) {
+ const [cachedId, cachedKey] = node._secretStorageKeyCache;
+ if(keys[cachedId]) {
+ return [cachedId, cachedKey];
+ }
+ }
+ return null;
+ },
+ // Caches a newly created 4S key (e.g. after a reset).
+ cacheSecretStorageKey: function(keyId, keyInfo, key) {
+ node._secretStorageKeyCache = [keyId, key];
+ },
+ },
+ };
+ if(node.e2ee) {
+ // Provide the legacy (pre-v37 libolm) crypto store so that
+ // initRustCrypto() can perform a one-time migration of any
+ // existing crypto state into the Rust crypto store. Patch
+ // the store because matrix-js-sdk's LocalStorageCryptoStore
+ // omits deviceKey/sessionId from getEndToEndSessionsBatch(),
+ // which breaks the libolm->rust olm-session migration.
+ clientOpts.cryptoStore = patchLocalStorageCryptoStoreForRustMigration(
+ new LocalStorageCryptoStore(localStorage)
+ );
+ }
+ node.matrixClient = sdk.createClient(clientOpts);
+
+ node.debug(`hasLazyLoadMembersEnabled=${node.matrixClient.hasLazyLoadMembersEnabled()}`);
+
+ // set globally if configured to do so
+ if(node.globalAccess) {
+ node.context().global.set('matrixClient["'+node.userId+'"]', node.matrixClient);
+ }
+
+ node.matrixClient.on(RoomEvent.Timeline, async function(event, room, toStartOfTimeline, removed, data) {
+ if (toStartOfTimeline) {
+ node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: paginated result");
+ return; // ignore paginated results
+ }
+ if (!data || !data.liveEvent) {
+ node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message");
+ return; // ignore old message (we only want live events)
+ }
+ if(node.initializedAt > event.getDate()) {
+ node.log("Ignoring" + (event.isEncrypted() ? ' encrypted' : '') +" timeline event [" + (event.getContent()['msgtype'] || event.getType()) + "]: (" + room.name + ") " + event.getId() + " for reason: old message before init");
+ return; // skip events that occurred before our client initialized
+ }
+
+ try {
+ await node.matrixClient.decryptEventIfNeeded(event);
+ } catch (error) {
+ node.error(error);
+ return;
+ }
+
+ const isDmRoom = (room) => {
+ // Find out if this is a direct message room.
+ let isDM = !!room.getDMInviter();
+ const allMembers = room.currentState.getMembers();
+ if (!isDM && allMembers.length <= 2) {
+ // if not a DM, but there are 2 users only
+ // double check DM (needed because getDMInviter works only if you were invited, not if you invite)
+ // hence why we check for each member
+ if (allMembers.some((m) => m.getDMInviter())) {
+ return true;
+ }
+ }
+ return allMembers.length <= 2 && isDM;
+ };
+
+ let msg = {
+ encrypted : event.isEncrypted(),
+ redacted : event.isRedacted(),
+ content : event.getContent(),
+ type : (event.getContent()['msgtype'] || event.getType()) || null,
+ payload : (event.getContent()['body'] || event.getContent()) || null,
+ isDM : isDmRoom(room),
+ isThread : event.getContent()?.['m.relates_to']?.rel_type === RelationType.Thread,
+ mentions : event.getContent()["m.mentions"] || null,
+ userId : event.getSender(),
+ user : node.matrixClient.getUser(event.getSender()),
+ topic : event.getRoomId(),
+ eventId : event.getId(),
+ event : event,
+ };
+
+ // remove keys from user property that start with an underscore
+ Object.keys(msg.user).forEach(function (key) {
+ if (/^_/.test(key)) {
+ delete msg.user[key];
+ }
+ });
+
+ node.log(`Received ${msg.encrypted ? 'encrypted ' : ''}timeline event [${msg.type}]: (${room.name}) ${event.getSender()} :: ${msg.content.body} ${toStartOfTimeline ? ' [PAGINATED]' : ''}`);
+ node.emit("Room.timeline", event, room, toStartOfTimeline, removed, data, msg);
+ });
+
+ // handle auto-joining rooms
+ node.matrixClient.on(RoomMemberEvent.Membership, async function(event, member) {
+ if(node.initializedAt > event.getDate()) {
+ return; // skip events that occurred before our client initialized
+ }
+
+ if (member.membership === "invite" && member.userId === node.userId) {
+ node.log("Got invite to join room " + member.roomId);
+ if(node.autoAcceptRoomInvites) {
+ node.matrixClient.joinRoom(member.roomId).then(function() {
+ node.log("Automatically accepted invitation to join room " + member.roomId);
+ }).catch(function(e) {
+ node.warn("Cannot join room (could be from being kicked/banned) " + member.roomId + ": " + e);
+ });
+ }
+
+ let room = node.matrixClient.getRoom(event.getRoomId());
+ node.emit("Room.invite", {
+ type : 'm.room.member',
+ userId : event.getSender(),
+ topic : event.getRoomId(),
+ topicName : (room ? room.name : null) || null,
+ event : event,
+ eventId : event.getId(),
+ });
}
});
- node.log(`Received ${msg.encrypted ? 'encrypted ' : ''}timeline event [${msg.type}]: (${room.name}) ${event.getSender()} :: ${msg.content.body} ${toStartOfTimeline ? ' [PAGINATED]' : ''}`);
- node.emit("Room.timeline", event, room, toStartOfTimeline, removed, data, msg);
- });
-
- /**
- * Fires when we want to suggest to the user that they restore their megolm keys
- * from backup or by cross-signing the device.
- *
- * @event module:client~MatrixClient#"crypto.suggestKeyRestore"
- */
- // node.matrixClient.on("crypto.suggestKeyRestore", function(){
- //
- // });
-
- // node.matrixClient.on("RoomMember.typing", async function(event, member) {
- // let isTyping = member.typing;
- // let roomId = member.roomId;
- // });
-
- // node.matrixClient.on("RoomMember.powerLevel", async function(event, member) {
- // let newPowerLevel = member.powerLevel;
- // let newNormPowerLevel = member.powerLevelNorm;
- // let roomId = member.roomId;
- // });
-
- // node.matrixClient.on("RoomMember.name", async function(event, member) {
- // let newName = member.name;
- // let roomId = member.roomId;
- // });
-
- // handle auto-joining rooms
-
- node.matrixClient.on(RoomMemberEvent.Membership, async function(event, member) {
- if(node.initializedAt > event.getDate()) {
- return; // skip events that occurred before our client initialized
- }
-
- if (member.membership === "invite" && member.userId === node.userId) {
- node.log("Got invite to join room " + member.roomId);
- if(node.autoAcceptRoomInvites) {
- node.matrixClient.joinRoom(member.roomId).then(function() {
- node.log("Automatically accepted invitation to join room " + member.roomId);
- }).catch(function(e) {
- node.warn("Cannot join room (could be from being kicked/banned) " + member.roomId + ": " + e);
+ node.matrixClient.on(ClientEvent.Sync, async function(state, prevState, data) {
+ node.debug("SYNC [STATE=" + state + "] [PREVSTATE=" + prevState + "]");
+ if(prevState === null && state === "PREPARED" ) {
+ // Occurs when the initial sync is completed first time.
+ // This involves setting up filters and obtaining push rules.
+ node.setConnected(true, function(){
+ node.log("Matrix client connected");
+ });
+ } else if(prevState === null && state === "ERROR") {
+ // Occurs when the initial sync failed first time.
+ node.setConnected(false, function(){
+ node.error("Failed to connect to Matrix server");
+ });
+ } else if(prevState === "ERROR" && state === "PREPARED") {
+ // Occurs when the initial sync succeeds
+ // after previously failing.
+ node.setConnected(true, function(){
+ node.log("Matrix client connected");
+ });
+ } else if(prevState === "PREPARED" && state === "SYNCING") {
+ // Occurs immediately after transitioning to PREPARED.
+ // Starts listening for live updates rather than catching up.
+ node.setConnected(true, function(){
+ node.log("Matrix client connected");
+ });
+ } else if(prevState === "SYNCING" && state === "RECONNECTING") {
+ // Occurs when the live update fails.
+ node.setConnected(false, function(){
+ node.error("Connection to Matrix server lost");
+ });
+ } else if(prevState === "RECONNECTING" && state === "RECONNECTING") {
+ // Can occur if the update calls continue to fail,
+ // but the keepalive calls (to /versions) succeed.
+ node.setConnected(false, function(){
+ node.error("Connection to Matrix server lost");
+ });
+ } else if(prevState === "RECONNECTING" && state === "ERROR") {
+ // Occurs when the keepalive call also fails
+ node.setConnected(false, function(){
+ node.error("Connection to Matrix server lost");
+ });
+ } else if(prevState === "ERROR" && state === "SYNCING") {
+ // Occurs when the client has performed a
+ // live update after having previously failed.
+ node.setConnected(true, function(){
+ node.log("Matrix client connected");
+ });
+ } else if(prevState === "ERROR" && state === "ERROR") {
+ // Occurs when the client has failed to
+ // keepalive for a second time or more.
+ node.setConnected(false, function(){
+ node.error("Connection to Matrix server lost");
+ });
+ } else if(prevState === "SYNCING" && state === "SYNCING") {
+ // Occurs when the client has performed a live update.
+ // This is called after processing.
+ node.setConnected(true, function(){
+ node.log("Matrix client connected");
+ });
+ } else if(state === "STOPPED") {
+ // Occurs once the client has stopped syncing or
+ // trying to sync after stopClient has been called.
+ node.setConnected(false, function(){
+ node.error("Connection to Matrix server lost");
});
}
+ });
- let room = node.matrixClient.getRoom(event.getRoomId());
- node.emit("Room.invite", {
- type : 'm.room.member',
- userId : event.getSender(),
- topic : event.getRoomId(),
- topicName : (room ? room.name : null) || null,
- event : event,
- eventId : event.getId(),
- });
- }
- });
- node.matrixClient.on(ClientEvent.Sync, async function(state, prevState, data) {
- node.debug("SYNC [STATE=" + state + "] [PREVSTATE=" + prevState + "]");
- if(prevState === null && state === "PREPARED" ) {
- // Occurs when the initial sync is completed first time.
- // This involves setting up filters and obtaining push rules.
- node.setConnected(true, function(){
- node.log("Matrix client connected");
- });
- } else if(prevState === null && state === "ERROR") {
- // Occurs when the initial sync failed first time.
- node.setConnected(false, function(){
- node.error("Failed to connect to Matrix server", {});
- });
- } else if(prevState === "ERROR" && state === "PREPARED") {
- // Occurs when the initial sync succeeds
- // after previously failing.
- node.setConnected(true, function(){
- node.log("Matrix client connected");
- });
- } else if(prevState === "PREPARED" && state === "SYNCING") {
- // Occurs immediately after transitioning to PREPARED.
- // Starts listening for live updates rather than catching up.
- node.setConnected(true, function(){
- node.log("Matrix client connected");
- });
- } else if(prevState === "SYNCING" && state === "RECONNECTING") {
- // Occurs when the live update fails.
- node.setConnected(false, function(){
- node.error("Connection to Matrix server lost", {});
- });
- } else if(prevState === "RECONNECTING" && state === "RECONNECTING") {
- // Can occur if the update calls continue to fail,
- // but the keepalive calls (to /versions) succeed.
- node.setConnected(false, function(){
- node.error("Connection to Matrix server lost", {});
- });
- } else if(prevState === "RECONNECTING" && state === "ERROR") {
- // Occurs when the keepalive call also fails
- node.setConnected(false, function(){
- node.error("Connection to Matrix server lost", {});
- });
- } else if(prevState === "ERROR" && state === "SYNCING") {
- // Occurs when the client has performed a
- // live update after having previously failed.
- node.setConnected(true, function(){
- node.log("Matrix client connected");
- });
- } else if(prevState === "ERROR" && state === "ERROR") {
- // Occurs when the client has failed to
- // keepalive for a second time or more.
- node.setConnected(false, function(){
- node.error("Connection to Matrix server lost", {});
- });
- } else if(prevState === "SYNCING" && state === "SYNCING") {
- // Occurs when the client has performed a live update.
- // This is called after processing.
- node.setConnected(true, function(){
- node.log("Matrix client connected");
- });
- } else if(state === "STOPPED") {
- // Occurs once the client has stopped syncing or
- // trying to sync after stopClient has been called.
- node.setConnected(false, function(){
- node.error("Connection to Matrix server lost", {});
- });
- }
- });
+ node.matrixClient.on(HttpApiEvent.SessionLoggedOut, async function(errorObj){
+ // Example if user auth token incorrect:
+ // {
+ // errcode: 'M_UNKNOWN_TOKEN',
+ // data: {
+ // errcode: 'M_UNKNOWN_TOKEN',
+ // error: 'Invalid macaroon passed.',
+ // soft_logout: false
+ // },
+ // httpStatus: 401
+ // }
- node.matrixClient.on(HttpApiEvent.SessionLoggedOut, async function(errorObj){
- // Example if user auth token incorrect:
- // {
- // errcode: 'M_UNKNOWN_TOKEN',
- // data: {
- // errcode: 'M_UNKNOWN_TOKEN',
- // error: 'Invalid macaroon passed.',
- // soft_logout: false
- // },
- // httpStatus: 401
- // }
+ node.error("Authentication failure: " + errorObj);
+ stopClient();
+ });
- node.error("Authentication failure: " + errorObj, {});
- stopClient();
- });
-
- async function run() {
- try {
- if(node.e2ee){
- node.log("Initializing crypto...");
- await node.matrixClient.initCrypto();
- node.matrixClient.getCrypto().globalBlacklistUnverifiedDevices = false; // prevent errors from unverified devices
- node.matrixClient.getCrypto().globalErrorOnUnknownDevices = !node.allowUnknownDevices;
+ // incoming device-verification requests from other users/devices
+ node.matrixClient.on(CryptoEvent.VerificationRequestReceived, function(request) {
+ try {
+ node.log("Received device verification request from " + request.otherUserId);
+ node.trackVerificationRequest(request);
+ } catch(e) {
+ node.error("Failed to handle incoming verification request: " + e);
}
- node.log("Connecting to Matrix server...");
- await node.matrixClient.startClient({
- initialSyncLimit: node.initialSyncLimit
- });
- } catch(error) {
- node.error(error, {});
- }
- }
+ });
- // do an authed request and only continue if we don't get an error
- // this prevent the matrix client from crashing Node-RED on invalid auth token
- (function checkAuthTokenThenStart() {
- if(node.matrixClient.clientRunning) {
- return;
- }
-
- /**
- * We do a /whoami request before starting for a few reasons:
- * - validate our auth token
- * - make sure auth token belongs to provided node.userId
- * - fetch device_id if possible (only available on Synapse >= v1.40.0 under MSC2033)
- */
- node.matrixClient.whoami()
- .then(
- function(data) {
- if((typeof data['device_id'] === undefined || !data['device_id']) && !node.deviceId && !getStoredDeviceId(localStorage)) {
- node.error("/whoami request did not return device_id. You will need to manually set one in your configuration because this cannot be automatically fetched.", {});
+ async function run() {
+ try {
+ if(node.e2ee){
+ node.log("Initializing crypto...");
+ ensureIndexedDBShim();
+ // If the device ID has changed (e.g. a new login), the
+ // persisted crypto store belongs to the old device and
+ // cannot be loaded - discard it and start fresh.
+ // Otherwise restore the previously persisted state.
+ let effectiveDeviceId = node.matrixClient.getDeviceId(),
+ storedDeviceId = getStoredDeviceId(localStorage);
+ if(storedDeviceId && effectiveDeviceId && storedDeviceId !== effectiveDeviceId) {
+ node.warn(`Device ID changed (${storedDeviceId} -> ${effectiveDeviceId}); discarding the encryption store from the old device.`);
+ await discardCryptoStore();
+ } else {
+ await restoreCryptoStore(cryptoSnapshotPath);
}
- if('device_id' in data && data['device_id'] && !node.deviceId) {
- // if we have no device_id configured lets use the one
- // returned by /whoami for this access_token
- node.matrixClient.deviceId = data['device_id'];
- }
-
- // make sure our userId matches the access token's
- if(data['user_id'].toLowerCase() !== node.userId.toLowerCase()) {
- node.error(`User ID provided is ${node.userId} but token belongs to ${data['user_id']}`, {});
- return;
- }
- run().catch((error) => node.error(error));
- },
- function(err) {
- // if the error isn't authentication related retry in a little bit
- if(err.code !== "M_UNKNOWN_TOKEN") {
- retryStartTimeout = setTimeout(checkAuthTokenThenStart, 15000);
- node.error("Auth check failed: " + err, {});
+ await node.matrixClient.initRustCrypto({
+ useIndexedDB: true,
+ cryptoDatabasePrefix: cryptoDbPrefix,
+ });
+ let crypto = node.matrixClient.getCrypto();
+ if(crypto) {
+ // Blacklist (refuse to encrypt to) unverified devices only
+ // when the user has explicitly unticked "Allow unverified
+ // devices". Default/undefined allows them, as before.
+ crypto.globalBlacklistUnverifiedDevices = (node.allowUnknownDevices === false);
}
+ // periodically persist crypto state so it survives an unclean shutdown
+ cryptoSnapshotInterval = setInterval(persistCrypto, 5 * 60 * 1000);
}
- );
- })();
+ node.log("Connecting to Matrix server...");
+ await node.matrixClient.startClient({
+ initialSyncLimit: node.initialSyncLimit
+ });
+ } catch(error) {
+ node.error(error);
+ }
+ }
+
+ // do an authed request and only continue if we don't get an error
+ // this prevent the matrix client from crashing Node-RED on invalid auth token
+ (function checkAuthTokenThenStart() {
+ if(node.matrixClient.clientRunning) {
+ return;
+ }
+
+ /**
+ * We do a /whoami request before starting for a few reasons:
+ * - validate our auth token
+ * - make sure auth token belongs to provided node.userId
+ * - fetch device_id if possible (only available on Synapse >= v1.40.0 under MSC2033)
+ */
+ node.matrixClient.whoami()
+ .then(
+ function(data) {
+ if((typeof data['device_id'] === undefined || !data['device_id']) && !node.deviceId && !getStoredDeviceId(localStorage)) {
+ node.error("/whoami request did not return device_id. You will need to manually set one in your configuration because this cannot be automatically fetched.");
+ }
+ if('device_id' in data && data['device_id'] && !node.deviceId) {
+ // if we have no device_id configured lets use the one
+ // returned by /whoami for this access_token
+ node.matrixClient.deviceId = data['device_id'];
+ }
+
+ // make sure our userId matches the access token's
+ if(data['user_id'].toLowerCase() !== node.userId.toLowerCase()) {
+ node.error(`User ID provided is ${node.userId} but token belongs to ${data['user_id']}`);
+ return;
+ }
+ run().catch((error) => node.error(error));
+ },
+ function(err) {
+ // if the error isn't authentication related retry in a little bit
+ if(err.code !== "M_UNKNOWN_TOKEN") {
+ retryStartTimeout = setTimeout(checkAuthTokenThenStart, 15000);
+ node.error("Auth check failed: " + err);
+ }
+ }
+ )
+ })();
+ }
}
}
@@ -478,40 +752,41 @@ module.exports = function(RED) {
userId: { type: "text", required: true },
accessToken: { type: "text", required: true },
deviceId: { type: "text", required: false },
- url: { type: "text", required: true }
+ url: { type: "text", required: true },
+ password: { type: "password", required: false }
}
});
RED.httpAdmin.post(
"/matrix-chat/login",
RED.auth.needsPermission('flows.write'),
- function(req, res) {
+ async function(req, res) {
let userId = req.body.userId || undefined,
password = req.body.password || undefined,
baseUrl = req.body.baseUrl || undefined,
deviceId = req.body.deviceId || undefined,
displayName = req.body.displayName || undefined;
- (async () => {
- const mod = await import("matrix-js-sdk");
- const matrixClient = mod.createClient({
+ try {
+ const sdk = await sdkPromise;
+ // Resolve .well-known delegation so users can enter their domain.
+ baseUrl = await resolveHomeserverUrl(sdk, baseUrl);
+ const matrixClient = sdk.createClient({
baseUrl: baseUrl,
deviceId: deviceId,
timelineSupport: true,
localTimeoutMs: '30000'
});
-
- matrixClient.timelineSupport = true;
-
- matrixClient.login('m.login.password', {
- identifier: {
- type: 'm.id.user',
- user: userId,
- },
- password: password,
- initial_device_display_name: displayName
- })
+ matrixClient.login(
+ 'm.login.password', {
+ identifier: {
+ type: 'm.id.user',
+ user: userId,
+ },
+ password: password,
+ initial_device_display_name: displayName
+ })
.then(
function(response) {
res.json({
@@ -528,11 +803,393 @@ module.exports = function(RED) {
});
}
);
- })().catch(err => {
- res.json({ result: 'error', message: err.toString() });
- });
- }
- );
+ } catch(err) {
+ res.json({
+ 'result': 'error',
+ 'message': err
+ });
+ }
+ });
+
+ /**
+ * Interactive Secure Secret Storage (4S) / cross-signing setup for the
+ * config editor's "Set up secure backup" button.
+ *
+ * Secured with the same flows.write permission as the login endpoint, so it
+ * is not publicly exposed. Operates on the live, connected client of an
+ * already-deployed server configuration node (identified by req.body.id).
+ *
+ * Actions:
+ * - status : report connection / cross-signing / secret-storage state
+ * - unlock : unlock existing 4S with a recovery key/passphrase, then set up
+ * cross-signing for this device
+ * - reset : create brand new cross-signing keys and secret storage
+ * (requires the account password); returns the new recovery key
+ */
+ RED.httpAdmin.post(
+ "/matrix-chat/secure-backup",
+ RED.auth.needsPermission('flows.write'),
+ async function(req, res) {
+ try {
+ const serverNode = RED.nodes.getNode(req.body.id);
+ if(!serverNode || !serverNode.matrixClient) {
+ return res.json({ result: 'error', message: 'Server configuration not found. Save and deploy the server configuration node first.' });
+ }
+ if(typeof serverNode.isConnected !== 'function' || !serverNode.isConnected()) {
+ return res.json({ result: 'error', message: 'The Matrix client is not connected. Deploy the server configuration and wait for it to connect, then try again.' });
+ }
+ const crypto = serverNode.matrixClient.getCrypto();
+ if(!crypto) {
+ return res.json({ result: 'error', message: 'End-to-end encryption is not enabled on this server configuration.' });
+ }
+ const secretStorage = serverNode.matrixClient.secretStorage;
+ const action = req.body.action || 'status';
+
+ if(action === 'status') {
+ const defaultKeyId = await secretStorage.getDefaultKeyId();
+ return res.json({
+ result: 'ok',
+ crossSigningReady: await crypto.isCrossSigningReady(),
+ secretStorageReady: await crypto.isSecretStorageReady(),
+ secretStorageExists: !!defaultKeyId,
+ });
+ }
+
+ if(action === 'unlock') {
+ const cryptoApi = await cryptoApiPromise;
+ const recoveryInput = String(req.body.recoveryKey || '').trim();
+ if(!recoveryInput) {
+ return res.json({ result: 'error', message: 'A recovery key or passphrase is required.' });
+ }
+ const keyId = await secretStorage.getDefaultKeyId();
+ if(!keyId) {
+ return res.json({ result: 'error', message: 'This account has no secure backup to unlock. Use Reset to create one.' });
+ }
+ const stored = await secretStorage.getKey(keyId);
+ const keyInfo = stored && stored[1];
+ if(!keyInfo) {
+ return res.json({ result: 'error', message: 'Could not read the secure backup key description from the account.' });
+ }
+
+ let keyBytes = null;
+ try {
+ keyBytes = cryptoApi.decodeRecoveryKey(recoveryInput.replace(/\s+/g, ''));
+ } catch(e) { /* not a recovery key - fall back to passphrase */ }
+ if(!keyBytes && keyInfo.passphrase) {
+ keyBytes = await cryptoApi.deriveRecoveryKeyFromPassphrase(
+ recoveryInput, keyInfo.passphrase.salt, keyInfo.passphrase.iterations);
+ }
+ if(!keyBytes) {
+ return res.json({ result: 'error', message: 'Could not read that value as a recovery key or passphrase.' });
+ }
+ if(!(await secretStorage.checkKey(keyBytes, keyInfo))) {
+ return res.json({ result: 'error', message: 'That recovery key / passphrase is not correct.' });
+ }
+
+ serverNode._secretStorageKeyCache = [keyId, keyBytes];
+ await crypto.bootstrapCrossSigning({
+ authUploadDeviceSigningKeys: async function(makeRequest) {
+ if(req.body.password) {
+ await makeRequest({
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: serverNode.userId },
+ password: req.body.password,
+ });
+ } else {
+ await makeRequest(null);
+ }
+ },
+ });
+ try { await crypto.checkKeyBackupAndEnable(); } catch(e) { /* best effort */ }
+ serverNode.log("Secure backup unlocked; cross-signing set up.");
+ return res.json({
+ result: 'ok',
+ message: 'Secure backup unlocked. Cross-signing is now set up for this bot.',
+ crossSigningReady: await crypto.isCrossSigningReady(),
+ });
+ }
+
+ if(action === 'reset') {
+ const password = req.body.password;
+ if(!password) {
+ return res.json({ result: 'error', message: 'The account password is required to reset secure backup.' });
+ }
+ const newKey = await crypto.createRecoveryKeyFromPassphrase();
+ // Replace secret storage FIRST. This makes the new 4S key
+ // (whose private key we hold and cache via cacheSecretStorageKey)
+ // the default before cross-signing is reset. bootstrapCrossSigning
+ // exports the new signing keys into whatever 4S is current, so if
+ // the old 4S were still default it would need the old (unknown)
+ // key and fail with "getSecretStorageKey callback returned falsey".
+ await crypto.bootstrapSecretStorage({
+ setupNewSecretStorage: true,
+ createSecretStorageKey: async function() { return newKey; },
+ });
+ await crypto.bootstrapCrossSigning({
+ setupNewCrossSigning: true,
+ authUploadDeviceSigningKeys: async function(makeRequest) {
+ await makeRequest({
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: serverNode.userId },
+ password: password,
+ });
+ },
+ });
+ serverNode.log("Cross-signing and secure backup were reset.");
+ return res.json({
+ result: 'ok',
+ message: 'Cross-signing and secure backup have been reset. Store the new recovery key somewhere safe - it is shown only once.',
+ recoveryKey: newKey.encodedPrivateKey,
+ });
+ }
+
+ return res.json({ result: 'error', message: 'Unknown action: ' + action });
+ } catch(error) {
+ res.json({ result: 'error', message: String(error && error.message || error) });
+ }
+ });
+
+ /**
+ * Lists and drives device verification requests for the config editor's
+ * "Verification" button (the verification list modal). Same flows.write
+ * protection as the other admin endpoints, so it is not publicly exposed.
+ *
+ * Actions (on req.body.id, the server config node):
+ * - list : the pending verification requests (newest 20)
+ * - advance : accept / start SAS for one request and return its state
+ * - confirm : confirm the SAS emoji match
+ * - mismatch: declare the SAS emoji do not match
+ * - cancel : cancel the verification
+ */
+ RED.httpAdmin.post(
+ "/matrix-chat/verification",
+ RED.auth.needsPermission('flows.write'),
+ async function(req, res) {
+ try {
+ const serverNode = RED.nodes.getNode(req.body.id);
+ if(!serverNode || !serverNode.matrixClient) {
+ return res.json({ result: 'error', message: 'Server configuration not found. Save and deploy the server configuration node first.' });
+ }
+ if(typeof serverNode.isConnected !== 'function' || !serverNode.isConnected()) {
+ return res.json({ result: 'error', message: 'The Matrix client is not connected.' });
+ }
+ if(!serverNode.matrixClient.getCrypto()) {
+ return res.json({ result: 'error', message: 'End-to-end encryption is not enabled on this server configuration.' });
+ }
+
+ const { VerificationPhase } = await cryptoApiPromise;
+ const PHASE_NAMES = { 1: 'unsent', 2: 'requested', 3: 'ready', 4: 'started', 5: 'cancelled', 6: 'done' };
+ const requests = serverNode.verificationRequests;
+ const sasMap = serverNode.verificationSas;
+ const action = req.body.action || 'list';
+
+ function safe(fn, fallback) {
+ try { return fn(); } catch(e) { return fallback; }
+ }
+ function detailOf(vid, r) {
+ const roomId = safe(function(){ return r.roomId; }, null) || null;
+ const timeout = safe(function(){ return r.timeout; }, null);
+ const sas = sasMap.get(vid);
+ return {
+ verificationId : vid,
+ phase : PHASE_NAMES[safe(function(){ return r.phase; })] || 'unknown',
+ userId : safe(function(){ return r.otherUserId; }, null),
+ deviceId : safe(function(){ return r.otherDeviceId; }, null) || null,
+ roomId : roomId,
+ type : roomId ? 'room' : 'device',
+ isSelfVerification: safe(function(){ return r.isSelfVerification; }, false),
+ initiatedByMe : safe(function(){ return r.initiatedByMe; }, false),
+ ageMs : Date.now() - (r.__nrSeenAt || Date.now()),
+ expiresInMs : (typeof timeout === 'number') ? timeout : null,
+ cancellationCode : safe(function(){ return r.cancellationCode; }, null),
+ sas : (sas && sas.sas) ? { emoji: sas.sas.emoji || null, decimal: sas.sas.decimal || null } : null,
+ };
+ }
+
+ if(action === 'list') {
+ const now = Date.now();
+ // sweep finished verifications kept only for recent lookups
+ for(const entry of Array.from(requests)) {
+ if(entry[1].__nrEndedAt && (now - entry[1].__nrEndedAt) > 120000) {
+ requests.delete(entry[0]);
+ sasMap.delete(entry[0]);
+ }
+ }
+ let items = [];
+ for(const entry of requests) {
+ const detail = detailOf(entry[0], entry[1]);
+ if(detail.phase === 'done' || detail.phase === 'cancelled' || detail.phase === 'unsent') {
+ continue;
+ }
+ items.push(detail);
+ }
+ items.sort(function(a, b) { return a.ageMs - b.ageMs; }); // newest first
+ return res.json({
+ result: 'ok',
+ refreshSeconds: 5,
+ total: items.length,
+ hidden: Math.max(0, items.length - 20),
+ verifications: items.slice(0, 20),
+ });
+ }
+
+ // remaining actions operate on a single verification
+ const request = requests.get(req.body.verificationId);
+ if(!request) {
+ return res.json({ result: 'ok', verification: { verificationId: req.body.verificationId, phase: 'gone' } });
+ }
+
+ if(action === 'advance') {
+ try {
+ const phase = safe(function(){ return request.phase; });
+ if(phase === VerificationPhase.Requested
+ && !safe(function(){ return request.initiatedByMe; }, false)
+ && !safe(function(){ return request.accepting; }, false)) {
+ await request.accept();
+ } else if(phase === VerificationPhase.Ready && !safe(function(){ return request.verifier; })) {
+ await request.startVerification("m.sas.v1");
+ }
+ const verifier = safe(function(){ return request.verifier; });
+ if(verifier && !request.__nrVerifyCalled) {
+ request.__nrVerifyCalled = true;
+ verifier.verify().catch(function(){ /* completes/cancels elsewhere */ });
+ }
+ } catch(e) {
+ serverNode.warn("Verification advance error: " + e);
+ }
+ return res.json({ result: 'ok', verification: detailOf(req.body.verificationId, request) });
+ }
+
+ if(action === 'confirm' || action === 'mismatch') {
+ const sas = sasMap.get(req.body.verificationId);
+ if(!sas) {
+ return res.json({ result: 'error', message: 'This verification has no SAS awaiting confirmation yet.' });
+ }
+ if(action === 'confirm') {
+ await sas.confirm();
+ } else {
+ sas.mismatch();
+ }
+ return res.json({ result: 'ok', verification: detailOf(req.body.verificationId, request) });
+ }
+
+ if(action === 'cancel') {
+ await request.cancel();
+ return res.json({ result: 'ok', verification: detailOf(req.body.verificationId, request) });
+ }
+
+ return res.json({ result: 'error', message: 'Unknown action: ' + action });
+ } catch(error) {
+ res.json({ result: 'error', message: String(error && error.message || error) });
+ }
+ });
+
+ /**
+ * Session (device) management for the config editor's "Sessions" button.
+ * Same flows.write protection as the other admin endpoints.
+ *
+ * Actions (on req.body.id, the server config node):
+ * - list : the account's sessions (current + others) with verification state
+ * - rename : set a session's display name
+ * - remove : delete a session (requires the account password)
+ * - verify : start verifying a session; returns a verificationId to hand
+ * off to the verification modal
+ */
+ RED.httpAdmin.post(
+ "/matrix-chat/sessions",
+ RED.auth.needsPermission('flows.write'),
+ async function(req, res) {
+ try {
+ const serverNode = RED.nodes.getNode(req.body.id);
+ if(!serverNode || !serverNode.matrixClient) {
+ return res.json({ result: 'error', message: 'Server configuration not found. Save and deploy the server configuration node first.' });
+ }
+ if(typeof serverNode.isConnected !== 'function' || !serverNode.isConnected()) {
+ return res.json({ result: 'error', message: 'The Matrix client is not connected.' });
+ }
+ const client = serverNode.matrixClient;
+ const crypto = client.getCrypto();
+ if(!crypto) {
+ return res.json({ result: 'error', message: 'End-to-end encryption is not enabled on this server configuration.' });
+ }
+ const action = req.body.action || 'list';
+
+ if(action === 'list') {
+ const currentDeviceId = client.getDeviceId();
+ const devices = (await client.getDevices()).devices || [];
+ const enriched = await Promise.all(devices.map(async function(d) {
+ let verified = false;
+ try {
+ const status = await crypto.getDeviceVerificationStatus(serverNode.userId, d.device_id);
+ verified = !!(status && status.isVerified());
+ } catch(e) { /* unknown - treat as unverified */ }
+ return {
+ deviceId : d.device_id,
+ displayName : d.display_name || null,
+ lastSeenTs : d.last_seen_ts || null,
+ lastSeenIp : d.last_seen_ip || null,
+ verified : verified,
+ };
+ }));
+ const current = enriched.find(function(d){ return d.deviceId === currentDeviceId; })
+ || { deviceId: currentDeviceId, displayName: null, lastSeenTs: null, lastSeenIp: null, verified: false };
+ let others = enriched.filter(function(d){ return d.deviceId !== currentDeviceId; });
+ others.sort(function(a, b){ return (b.lastSeenTs || 0) - (a.lastSeenTs || 0); });
+ return res.json({
+ result: 'ok',
+ current: current,
+ others: others.slice(0, 50),
+ hidden: Math.max(0, others.length - 50),
+ });
+ }
+
+ const deviceId = req.body.deviceId;
+ if(!deviceId) {
+ return res.json({ result: 'error', message: 'A deviceId is required.' });
+ }
+
+ if(action === 'rename') {
+ await client.setDeviceDetails(deviceId, { display_name: req.body.displayName || '' });
+ return res.json({ result: 'ok' });
+ }
+
+ if(action === 'remove') {
+ const password = req.body.password;
+ try {
+ await client.deleteDevice(deviceId);
+ } catch(e) {
+ // deleting a device is user-interactive-auth protected
+ if(e && e.httpStatus === 401 && e.data && e.data.flows) {
+ if(!password) {
+ return res.json({ result: 'error', message: 'The account password is required to remove a session.' });
+ }
+ await client.deleteDevice(deviceId, {
+ type: 'm.login.password',
+ identifier: { type: 'm.id.user', user: serverNode.userId },
+ password: password,
+ session: e.data.session,
+ });
+ } else {
+ throw e;
+ }
+ }
+ serverNode.log("Removed session " + deviceId);
+ return res.json({ result: 'ok', message: 'Session removed.' });
+ }
+
+ if(action === 'verify') {
+ const request = await crypto.requestDeviceVerification(serverNode.userId, deviceId);
+ if(typeof serverNode.trackVerificationRequest === 'function') {
+ serverNode.trackVerificationRequest(request);
+ }
+ return res.json({ result: 'ok', verificationId: request.transactionId || null });
+ }
+
+ return res.json({ result: 'error', message: 'Unknown action: ' + action });
+ } catch(error) {
+ res.json({ result: 'error', message: String(error && error.message || error) });
+ }
+ });
function upgradeDirectoryIfNecessary(node, storageDir) {
let oldStorageDir = './matrix-local-storage',
@@ -551,7 +1208,7 @@ module.exports = function(RED) {
fs.copySync(oldStorageDir, dir);
}
} catch (err) {
- node.error(err, {});
+ node.error(err);
}
});
@@ -573,6 +1230,9 @@ module.exports = function(RED) {
}
}
+ /**
+ * If a device ID is stored we will use that for the client
+ */
function getStoredDeviceId(localStorage) {
let deviceId = localStorage.getItem('my_device_id');
if(deviceId === "null" || !deviceId) {
@@ -588,4 +1248,4 @@ module.exports = function(RED) {
localStorage.setItem('my_device_id', deviceId);
return true;
}
-};
\ No newline at end of file
+}
diff --git a/src/matrix-synapse-register.js b/src/matrix-synapse-register.js
index 2135821..95b0ece 100644
--- a/src/matrix-synapse-register.js
+++ b/src/matrix-synapse-register.js
@@ -22,7 +22,7 @@ module.exports = function(RED) {
}
node.on("input", async function (msg) {
- const { got } = await import('got');
+ const got = (await import('got')).default;
if(!msg.payload.username) {
node.error("msg.payload.username is required", msg);
diff --git a/src/matrix-upload-file.js b/src/matrix-upload-file.js
index 80495d4..2ed9a2a 100644
--- a/src/matrix-upload-file.js
+++ b/src/matrix-upload-file.js
@@ -1,7 +1,8 @@
const crypto = require("isomorphic-webcrypto");
const ffmpeg = require('fluent-ffmpeg');
const sharp = require('sharp');
-const getImageSize = require('image-size');
+const { imageSize } = require('image-size');
+const { imageSizeFromFile } = require('image-size/fromFile');
const tmp = require('tmp');
const fs = require('fs');
const path = require('path');
@@ -37,7 +38,7 @@ module.exports = function(RED) {
});
async function detectFileType(filename, bufferOrPath, msg) {
- const Mime = require('mime');
+ const Mime = (await import('mime')).default;
let file = Buffer.isBuffer(bufferOrPath) ? filename : bufferOrPath;
try {
@@ -216,10 +217,10 @@ module.exports = function(RED) {
async function addThumbnail(buffer) {
try {
- let imageSize = getImageSize(Buffer.isBuffer(buffer) ? buffer : buffer.data);
+ let thumbSize = imageSize(Buffer.isBuffer(buffer) ? buffer : buffer.data);
msg.payload.info.thumbnail_info = {
- w: imageSize.width,
- h: imageSize.height,
+ w: thumbSize.width,
+ h: thumbSize.height,
size: getFileSize(Buffer.isBuffer(buffer) ? buffer : buffer.data)
}
let uploadedThumbnail = await node.server.matrixClient.uploadContent(
@@ -293,7 +294,8 @@ module.exports = function(RED) {
msg.payload.url = file.content_uri;
}
msg.payload.msgtype = msgtype;
- msg.payload.body = msg.body || msg.filename || "";
+ msg.payload.body = msg.body || filename || "";
+ msg.payload.filename = filename;
msg.payload.info = {
"mimetype": contentType,
"size": getFileSize(bufferOrPath),
@@ -301,9 +303,11 @@ module.exports = function(RED) {
if (msgtype === 'm.image') {
// detect size of image
try {
- let imageSize = getImageSize(bufferOrPath);
- msg.payload.info.h = imageSize.height;
- msg.payload.info.w = imageSize.width;
+ let dimensions = Buffer.isBuffer(bufferOrPath)
+ ? imageSize(bufferOrPath)
+ : await imageSizeFromFile(bufferOrPath);
+ msg.payload.info.h = dimensions.height;
+ msg.payload.info.w = dimensions.width;
// Generate thumbnail for image
if (node.generateThumbnails) {
diff --git a/src/matrix-verification-action.html b/src/matrix-verification-action.html
new file mode 100644
index 0000000..7914136
--- /dev/null
+++ b/src/matrix-verification-action.html
@@ -0,0 +1,126 @@
+
+
+
+
+
diff --git a/src/matrix-verification-action.js b/src/matrix-verification-action.js
new file mode 100644
index 0000000..4688860
--- /dev/null
+++ b/src/matrix-verification-action.js
@@ -0,0 +1,139 @@
+module.exports = function(RED) {
+ function MatrixVerificationAction(n) {
+ RED.nodes.createNode(this, n);
+
+ let node = this;
+
+ this.name = n.name;
+ this.server = RED.nodes.getNode(n.server);
+ this.mode = n.mode || "accept";
+
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
+
+ if (!node.server) {
+ node.error("No configuration node");
+ return;
+ }
+ node.server.register(node);
+
+ node.server.on("disconnected", function() {
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
+ });
+ node.server.on("connected", function() {
+ node.status({ fill: "green", shape: "ring", text: "connected" });
+ });
+
+ node.on("input", async function(msg) {
+ if (!node.server || !node.server.matrixClient) {
+ msg.error = "No matrix server selected";
+ node.error(msg.error, msg);
+ node.send([null, msg]);
+ return;
+ }
+
+ if (!node.server.isConnected()) {
+ msg.error = "Matrix server connection is currently closed";
+ node.error(msg.error, msg);
+ node.send([null, msg]);
+ return;
+ }
+
+ const crypto = node.server.matrixClient.getCrypto();
+ if (!crypto) {
+ msg.error = "End-to-end encryption is not enabled on the Matrix server config";
+ node.error(msg.error, msg);
+ node.send([null, msg]);
+ return;
+ }
+
+ // msg.mode overrides the node's configured mode if provided
+ const mode = msg.mode || node.mode;
+
+ try {
+ if (mode === "request") {
+ // Start a new verification request.
+ // - msg.userId + msg.deviceId : verify a specific device (to-device)
+ // - msg.userId + msg.topic : verify a user in a DM room
+ // - otherwise : verify our own other devices
+ let request;
+ if (msg.userId && msg.deviceId) {
+ request = await crypto.requestDeviceVerification(msg.userId, msg.deviceId);
+ } else if (msg.userId && msg.topic) {
+ request = await crypto.requestVerificationDM(msg.userId, msg.topic);
+ } else {
+ request = await crypto.requestOwnUserVerification();
+ }
+
+ if (typeof node.server.trackVerificationRequest === "function") {
+ node.server.trackVerificationRequest(request);
+ }
+ msg.verificationId = request.transactionId;
+ node.send([msg, null]);
+ return;
+ }
+
+ // Every other mode acts on an existing tracked request.
+ const request = node.server.verificationRequests.get(msg.verificationId);
+ if (!request) {
+ throw new Error(`No active verification found for msg.verificationId '${msg.verificationId}'`);
+ }
+
+ switch (mode) {
+ case "accept":
+ await request.accept();
+ break;
+
+ case "start": {
+ // Begin SAS (emoji) verification. The SAS emoji is delivered
+ // through the matrix-verification node when it becomes ready.
+ let verifier = request.verifier;
+ if (!verifier) {
+ verifier = await request.startVerification("m.sas.v1");
+ }
+ verifier.verify().catch(function(e) {
+ node.warn("Verification ended: " + e);
+ });
+ break;
+ }
+
+ case "confirm": {
+ const sas = node.server.verificationSas.get(msg.verificationId);
+ if (!sas) {
+ throw new Error("This verification has no SAS awaiting confirmation");
+ }
+ await sas.confirm();
+ break;
+ }
+
+ case "mismatch": {
+ const sas = node.server.verificationSas.get(msg.verificationId);
+ if (!sas) {
+ throw new Error("This verification has no SAS awaiting confirmation");
+ }
+ sas.mismatch();
+ break;
+ }
+
+ case "cancel":
+ await request.cancel();
+ break;
+
+ default:
+ throw new Error("Unknown verification action mode: " + mode);
+ }
+
+ msg.verificationId = request.transactionId;
+ node.send([msg, null]);
+ } catch (e) {
+ msg.error = String(e && e.message || e);
+ node.error("Verification action failed: " + msg.error, msg);
+ node.send([null, msg]);
+ }
+ });
+
+ node.on("close", function() {
+ node.server.deregister(node);
+ });
+ }
+ RED.nodes.registerType("matrix-verification-action", MatrixVerificationAction);
+}
diff --git a/src/matrix-verification.html b/src/matrix-verification.html
new file mode 100644
index 0000000..93f550e
--- /dev/null
+++ b/src/matrix-verification.html
@@ -0,0 +1,199 @@
+
+
+
+
+
diff --git a/src/matrix-verification.js b/src/matrix-verification.js
new file mode 100644
index 0000000..839e6f2
--- /dev/null
+++ b/src/matrix-verification.js
@@ -0,0 +1,113 @@
+module.exports = function(RED) {
+ function MatrixVerification(n) {
+ RED.nodes.createNode(this, n);
+
+ let node = this;
+
+ this.name = n.name;
+ this.server = RED.nodes.getNode(n.server);
+
+ // Phase filter - emit only the ticked phases. Undefined (config saved
+ // before these options existed) is treated as ticked, so old nodes
+ // keep emitting every phase.
+ this.phases = {
+ requested: n.phaseRequested !== false,
+ ready: n.phaseReady !== false,
+ started: n.phaseStarted !== false,
+ sas: n.phaseSas !== false,
+ done: n.phaseDone !== false,
+ cancelled: n.phaseCancelled !== false,
+ };
+ this.initiatedBy = n.initiatedBy || 'any'; // any | me | notme
+ this.verificationType = n.verificationType || 'any'; // any | room | device
+ this.selfVerification = n.selfVerification || 'any'; // any | self | others
+ this.userFilter = (n.userFilter || '').split(',')
+ .map(function(s){ return s.trim().toLowerCase(); })
+ .filter(Boolean);
+ this.roomFilter = (n.roomFilter || '').split(',')
+ .map(function(s){ return s.trim(); })
+ .filter(Boolean);
+
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
+
+ if (!node.server) {
+ node.error("No configuration node");
+ return;
+ }
+ node.server.register(node);
+
+ // Returns true if a verification update message passes every configured
+ // filter. All filters AND-combine; each defaults to "pass everything".
+ function passesFilters(m) {
+ // phase
+ if ((m.phase in node.phases) && !node.phases[m.phase]) {
+ return false;
+ }
+ // initiated by
+ if (node.initiatedBy === 'me' && !m.initiatedByMe) {
+ return false;
+ }
+ if (node.initiatedBy === 'notme' && m.initiatedByMe) {
+ return false;
+ }
+ // verification type - room verifications carry a roomId (msg.topic),
+ // to-device verifications do not
+ if (node.verificationType === 'room' && !m.topic) {
+ return false;
+ }
+ if (node.verificationType === 'device' && m.topic) {
+ return false;
+ }
+ // self-verification (the other party is one of the bot's own devices)
+ if (node.selfVerification === 'self' && !m.isSelfVerification) {
+ return false;
+ }
+ if (node.selfVerification === 'others' && m.isSelfVerification) {
+ return false;
+ }
+ // user id allowlist
+ if (node.userFilter.length &&
+ (!m.userId || node.userFilter.indexOf(m.userId.toLowerCase()) === -1)) {
+ return false;
+ }
+ // room id filter - only constrains room verifications; device
+ // verifications have no room and are not affected
+ if (node.roomFilter.length && m.topic &&
+ node.roomFilter.indexOf(m.topic) === -1) {
+ return false;
+ }
+ return true;
+ }
+
+ const onConnected = function() {
+ node.status({ fill: "green", shape: "ring", text: "connected" });
+ };
+ const onDisconnected = function() {
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
+ };
+ const onVerificationUpdate = function(verificationMsg) {
+ if (!passesFilters(verificationMsg)) {
+ return;
+ }
+ node.status({ fill: "blue", shape: "dot", text: verificationMsg.phase });
+ // clone so multiple verification nodes don't share/mutate one object
+ node.send(RED.util.cloneMessage(verificationMsg));
+ };
+
+ node.server.on("connected", onConnected);
+ node.server.on("disconnected", onDisconnected);
+ node.server.on("Verification.update", onVerificationUpdate);
+
+ if (node.server.isConnected && node.server.isConnected()) {
+ onConnected();
+ }
+
+ node.on("close", function() {
+ node.server.removeListener("connected", onConnected);
+ node.server.removeListener("disconnected", onDisconnected);
+ node.server.removeListener("Verification.update", onVerificationUpdate);
+ node.server.deregister(node);
+ });
+ }
+ RED.nodes.registerType("matrix-verification", MatrixVerification);
+}