mirror of
https://github.com/Skylar-Tech/node-red-contrib-matrix-chat.git
synced 2026-05-23 07:33:37 -06:00
67920840e1
Adds two interactive admin tools to the matrix-server-config node so device verification and session management can be done from the editor without building a flow. Both open as modal dialogs and are backed by new flows.write-protected admin endpoints. Pending verifications - New "Pending verification requests" button opens a modal listing incoming and in-progress verification requests, refreshed every 5 seconds. - Each entry shows the type (device or room), who it is from, a live age, and a live expiry countdown; the list is capped at the newest 20 with a hidden-count note. - Clicking an entry drives the SAS flow (accept, start, show emoji, confirm/cancel) within the modal. - New /matrix-chat/verification endpoint with list, advance, confirm, mismatch, and cancel actions. - trackVerificationRequest now records a seen-at timestamp and keeps finished requests for two minutes so their outcome can still be reported to the editor. Sessions - New "Manage sessions" button opens a modal listing the account's sessions, modelled on Element's session manager: the current session with a verified / not-verified status box, then other sessions, each with a green or red shield, last activity, and IP address. - Clicking a session shows its details (session ID, last activity, IP), a Rename action, and a password-confirmed Remove. Unverified sessions offer a Verify action that hands off into the verification modal. - New /matrix-chat/sessions endpoint with list, rename, remove, and verify actions.
1242 lines
63 KiB
JavaScript
1242 lines
63 KiB
JavaScript
// 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");
|
|
const { resolve } = require('path');
|
|
const { LocalStorage } = require('node-localstorage');
|
|
const { ensureIndexedDBShim, restoreCryptoStore, snapshotCryptoStore } = 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 })
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
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(() => { /* logger module path changed - ignore */ });
|
|
}
|
|
|
|
function MatrixFolderNameFromUserId(name) {
|
|
return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
|
}
|
|
|
|
function MatrixServerNode(n) {
|
|
let node = this,
|
|
storageDir = RED.settings.userDir + '/matrix-client-storage';
|
|
RED.nodes.createNode(this, n);
|
|
node.setMaxListeners(1000);
|
|
|
|
node.log("Initializing Matrix Server Config node");
|
|
|
|
if(!this.credentials) {
|
|
this.credentials = {};
|
|
}
|
|
|
|
this.users = {};
|
|
this.connected = null;
|
|
this.name = n.name;
|
|
this.userId = this.credentials.userId;
|
|
this.deviceLabel = this.credentials.deviceLabel || null;
|
|
this.deviceId = this.credentials.deviceId || null;
|
|
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.initializedAt = new Date();
|
|
node.initialSyncLimit = 25;
|
|
|
|
// 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;
|
|
};
|
|
node.deregister = function(consumerNode) {
|
|
delete node.users[consumerNode.id];
|
|
};
|
|
|
|
if(!this.userId) {
|
|
node.log("Matrix connection failed: missing user ID in configuration.");
|
|
return;
|
|
}
|
|
|
|
let localStorageDir = storageDir + '/' + MatrixFolderNameFromUserId(this.userId),
|
|
localStorage = new LocalStorage(localStorageDir),
|
|
initialSetup = false;
|
|
|
|
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.");
|
|
} else if(!this.url) {
|
|
node.error("Matrix connection failed: missing server URL in configuration.");
|
|
} else {
|
|
node.setConnected = async function(connected, cb) {
|
|
if (node.connected !== connected) {
|
|
node.connected = connected;
|
|
if(typeof cb === 'function') {
|
|
cb(connected);
|
|
}
|
|
|
|
if (connected) {
|
|
node.log("Matrix server connection ready.");
|
|
node.emit("connected");
|
|
if(!initialSetup) {
|
|
// store Device ID internally
|
|
let stored_device_id = getStoredDeviceId(localStorage),
|
|
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.")
|
|
} else {
|
|
if(!stored_device_id || stored_device_id !== device_id) {
|
|
node.log(`Saving Device ID (old:${stored_device_id} new:${device_id})`);
|
|
storeDeviceId(localStorage, device_id);
|
|
}
|
|
|
|
// update device label
|
|
if(node.deviceLabel) {
|
|
node.matrixClient
|
|
.getDevice(device_id)
|
|
.then(
|
|
function(response) {
|
|
if(response.display_name !== node.deviceLabel) {
|
|
node.matrixClient.setDeviceDetails(device_id, {
|
|
display_name: node.deviceLabel
|
|
}).then(
|
|
function(response) {},
|
|
function(error) {
|
|
node.error("Failed to set device label: " + error);
|
|
}
|
|
);
|
|
}
|
|
},
|
|
function(error) {
|
|
node.error("Failed to fetch device: " + error);
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
initialSetup = true;
|
|
}
|
|
} else {
|
|
node.emit("disconnected");
|
|
}
|
|
|
|
if(this.globalAccess) {
|
|
this.context().global.set('matrixClientOnline["'+this.userId+'"]', connected);
|
|
}
|
|
}
|
|
};
|
|
node.setConnected(false);
|
|
|
|
node.isConnected = function() {
|
|
return node.connected;
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
if(node.matrixClient && node.matrixClient.clientRunning) {
|
|
node.matrixClient.stopClient();
|
|
node.setConnected(false);
|
|
}
|
|
|
|
if(retryStartTimeout) {
|
|
clearTimeout(retryStartTimeout);
|
|
}
|
|
if(cryptoSnapshotInterval) {
|
|
clearInterval(cryptoSnapshotInterval);
|
|
cryptoSnapshotInterval = null;
|
|
}
|
|
}
|
|
|
|
node.on('close', function(done) {
|
|
stopClient();
|
|
persistCrypto().finally(function() {
|
|
if(node.globalAccess) {
|
|
try {
|
|
node.context().global.set('matrixClient["'+node.userId+'"]', undefined);
|
|
} catch(e){
|
|
node.error(e.message);
|
|
}
|
|
}
|
|
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);
|
|
};
|
|
|
|
// 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`);
|
|
}
|
|
|
|
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.
|
|
clientOpts.cryptoStore = 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.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 <i>after</i> 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.error("Authentication failure: " + errorObj);
|
|
stopClient();
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
)
|
|
})();
|
|
}
|
|
}
|
|
}
|
|
|
|
RED.nodes.registerType("matrix-server-config", MatrixServerNode, {
|
|
credentials: {
|
|
deviceLabel: { type: "text", required: false },
|
|
userId: { type: "text", required: true },
|
|
accessToken: { type: "text", required: true },
|
|
deviceId: { type: "text", required: false },
|
|
url: { type: "text", required: true },
|
|
password: { type: "password", required: false }
|
|
}
|
|
});
|
|
|
|
RED.httpAdmin.post(
|
|
"/matrix-chat/login",
|
|
RED.auth.needsPermission('flows.write'),
|
|
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;
|
|
|
|
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.login(
|
|
'm.login.password', {
|
|
identifier: {
|
|
type: 'm.id.user',
|
|
user: userId,
|
|
},
|
|
password: password,
|
|
initial_device_display_name: displayName
|
|
})
|
|
.then(
|
|
function(response) {
|
|
res.json({
|
|
'result': 'ok',
|
|
'token': response.access_token,
|
|
'device_id': response.device_id,
|
|
'user_id': response.user_id,
|
|
});
|
|
},
|
|
function(err) {
|
|
res.json({
|
|
'result': 'error',
|
|
'message': err
|
|
});
|
|
}
|
|
);
|
|
} 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',
|
|
oldStorageDir2 = './matrix-client-storage';
|
|
|
|
// if the old storage location exists lets move it to the new location
|
|
if(fs.pathExistsSync(oldStorageDir)){
|
|
RED.nodes.eachNode(function(n){
|
|
try {
|
|
if(n.type !== 'matrix-server-config') return;
|
|
let { userId } = RED.nodes.getCredentials(n.id);
|
|
let dir = storageDir + '/' + MatrixFolderNameFromUserId(userId);
|
|
if(!fs.pathExistsSync(dir)) {
|
|
fs.ensureDirSync(dir);
|
|
node.log("found old '" + oldStorageDir + "' path, copying to new location '" + dir);
|
|
fs.copySync(oldStorageDir, dir);
|
|
}
|
|
} catch (err) {
|
|
node.error(err);
|
|
}
|
|
});
|
|
|
|
// rename folder to keep as a backup (and so we don't run again)
|
|
node.log("archiving old config folder '" + oldStorageDir + "' to '" + oldStorageDir + "-backup");
|
|
fs.renameSync(oldStorageDir, oldStorageDir + "-backup");
|
|
}
|
|
|
|
if(RED.settings.userDir !== resolve('./') && resolve(oldStorageDir2) !== resolve(storageDir)) {
|
|
// user directory does not match running directory
|
|
// check if we stored stuff in wrong directory and move it
|
|
if(fs.pathExistsSync(oldStorageDir2)){
|
|
fs.ensureDirSync(storageDir);
|
|
node.log("found old '" + oldStorageDir2 + "' path, copying to new location '" + storageDir);
|
|
fs.copySync(oldStorageDir2, storageDir);
|
|
// rename folder to keep as a backup (and so we don't run again)
|
|
fs.renameSync(oldStorageDir2, oldStorageDir2 + "-backup");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return null;
|
|
}
|
|
return deviceId;
|
|
}
|
|
|
|
function storeDeviceId(localStorage, deviceId) {
|
|
if(!deviceId) {
|
|
return false;
|
|
}
|
|
localStorage.setItem('my_device_id', deviceId);
|
|
return true;
|
|
}
|
|
}
|