Fetch encrypted chat log entries for log tab (#434)

* Fetch encrypted chat log entries for log tab

* Guard log-only chat log merge from plaintext
This commit is contained in:
l5y
2025-11-12 14:13:46 +01:00
committed by GitHub
parent 70fca17230
commit e479983d38
3 changed files with 173 additions and 6 deletions
@@ -184,3 +184,68 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
const lastEntry = model.logEntries[model.logEntries.length - 1];
assert.equal(lastEntry.neighborId, neighborId);
});
test('buildChatTabModel merges dedicated encrypted log feed without altering channels', () => {
const regularMessages = fixtureMessages().filter(message => !message.encrypted);
const encryptedOnly = [
{ id: 'log-only', encrypted: true, rx_time: NOW - 3, channel: 7 }
];
const model = buildChatTabModel({
nodes: [],
messages: regularMessages,
logOnlyMessages: encryptedOnly,
nowSeconds: NOW,
windowSeconds: WINDOW
});
const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED);
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'log-only');
const channelMessageIds = model.channels.reduce((acc, channel) => {
if (!channel || !Array.isArray(channel.entries)) {
return acc;
}
for (const entry of channel.entries) {
if (entry && entry.message && entry.message.id) {
acc.push(entry.message.id);
}
}
return acc;
}, []);
assert.ok(!channelMessageIds.includes('log-only'));
});
test('buildChatTabModel de-duplicates encrypted messages across feeds', () => {
const duplicateMessage = { id: 'dup', encrypted: true, rx_time: NOW - 4 };
const model = buildChatTabModel({
nodes: [],
messages: [duplicateMessage],
logOnlyMessages: [duplicateMessage],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED);
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'dup');
});
test('buildChatTabModel ignores plaintext log-only entries', () => {
const logOnlyMessages = [
{ id: 'plain', encrypted: false, rx_time: NOW - 5 },
{ id: 'enc', encrypted: true, rx_time: NOW - 4 }
];
const model = buildChatTabModel({
nodes: [],
messages: [],
logOnlyMessages,
nowSeconds: NOW,
windowSeconds: WINDOW
});
const encryptedEntries = model.logEntries.filter(entry => entry.type === CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED);
assert.equal(encryptedEntries.length, 1);
assert.equal(encryptedEntries[0]?.message?.id, 'enc');
});
+78 -1
View File
@@ -54,6 +54,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
* positions?: Array<Object>,
* neighbors?: Array<Object>,
* messages?: Array<Object>,
* logOnlyMessages?: Array<Object>,
* nowSeconds: number,
* windowSeconds: number,
* maxChannelIndex?: number,
@@ -70,6 +71,7 @@ export function buildChatTabModel({
positions = [],
neighbors = [],
messages = [],
logOnlyMessages = [],
nowSeconds,
windowSeconds,
maxChannelIndex = MAX_CHANNEL_INDEX,
@@ -128,13 +130,20 @@ export function buildChatTabModel({
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, neighbor: neighborEntry, nodeId, nodeNum, neighborId });
}
const encryptedLogEntries = [];
const encryptedLogKeys = new Set();
for (const message of messages || []) {
if (!message) continue;
const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso);
if (ts == null || ts < cutoff) continue;
if (message.encrypted) {
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message });
const key = buildEncryptedMessageKey(message);
if (!encryptedLogKeys.has(key)) {
encryptedLogKeys.add(key);
encryptedLogEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message });
}
continue;
}
@@ -178,6 +187,23 @@ export function buildChatTabModel({
bucket.entries.push({ ts, message });
}
const extraLogMessages = Array.isArray(logOnlyMessages) ? logOnlyMessages : [];
for (const message of extraLogMessages) {
if (!message || !message.encrypted) continue;
const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso);
if (ts == null || ts < cutoff) continue;
const key = buildEncryptedMessageKey(message);
if (encryptedLogKeys.has(key)) {
continue;
}
encryptedLogKeys.add(key);
encryptedLogEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED, message });
}
if (encryptedLogEntries.length > 0) {
logEntries.push(...encryptedLogEntries);
}
logEntries.sort((a, b) => a.ts - b.ts);
let hasPrimaryBucket = false;
@@ -213,6 +239,57 @@ export function buildChatTabModel({
return { logEntries, channels };
}
/**
* Build a stable key for encrypted message de-duplication when merging feeds.
*
* @param {?Object} message Chat message payload.
* @returns {string} Stable deduplication key.
*/
function buildEncryptedMessageKey(message) {
if (!message || typeof message !== 'object') {
return 'encrypted:unknown';
}
const rawId = pickFirstPropertyValue(message, ['id', 'packet_id', 'packetId']);
if (rawId != null && rawId !== '') {
const id = String(rawId).trim();
if (id) {
return `encrypted:id:${id}`;
}
}
const rx = pickFirstPropertyValue(message, ['rx_time', 'rxTime', 'rx_iso', 'rxIso']);
const fromId = pickFirstPropertyValue(message, ['from_id', 'fromId']);
const toId = pickFirstPropertyValue(message, ['to_id', 'toId']);
const replyId = pickFirstPropertyValue(message, ['reply_id', 'replyId']);
return `encrypted:fallback:${String(rx ?? '')}|${String(fromId ?? '')}|${String(toId ?? '')}|${String(replyId ?? '')}`;
}
/**
* Retrieve the first present property value from the provided source object.
*
* @param {?Object} source Candidate data source.
* @param {Array<string>} keys Preferred property order.
* @returns {*|null} First matching property value, otherwise null.
*/
function pickFirstPropertyValue(source, keys) {
if (!source || typeof source !== 'object' || !Array.isArray(keys)) {
return null;
}
for (const key of keys) {
if (typeof key !== 'string' || key.length === 0) {
continue;
}
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
const value = source[key];
if (value == null || value === '') {
continue;
}
return value;
}
return null;
}
/**
* Extract a canonical node identifier from a payload when available.
*
+30 -5
View File
@@ -133,6 +133,8 @@ export function initializeApp(config) {
/** @type {Array<Object>} */
let allMessages = [];
/** @type {Array<Object>} */
let allEncryptedMessages = [];
/** @type {Array<Object>} */
let allTelemetryEntries = [];
/** @type {Array<Object>} */
let allPositionEntries = [];
@@ -2752,6 +2754,7 @@ let messagesById = new Map();
* @param {{
* nodes?: Array<Object>,
* messages?: Array<Object>,
* encryptedMessages?: Array<Object>,
* telemetryEntries?: Array<Object>,
* positionEntries?: Array<Object>,
* neighborEntries?: Array<Object>,
@@ -2762,13 +2765,18 @@ let messagesById = new Map();
function renderChatLog({
nodes = [],
messages = [],
encryptedMessages = [],
telemetryEntries = [],
positionEntries = [],
neighborEntries = [],
filterQuery = ''
}) {
if (!CHAT_ENABLED || !chatEl) return;
messagesById = buildMessageIndex(messages);
const combinedMessages = Array.isArray(messages) ? [...messages] : [];
if (Array.isArray(encryptedMessages) && encryptedMessages.length > 0) {
combinedMessages.push(...encryptedMessages);
}
messagesById = buildMessageIndex(combinedMessages);
const nowSeconds = Math.floor(Date.now() / 1000);
const { logEntries, channels } = buildChatTabModel({
nodes,
@@ -2776,6 +2784,7 @@ let messagesById = new Map();
positions: positionEntries,
neighbors: neighborEntries,
messages,
logOnlyMessages: encryptedMessages,
nowSeconds,
windowSeconds: CHAT_RECENT_WINDOW_SECONDS,
maxChannelIndex: MAX_CHANNEL_INDEX,
@@ -3047,12 +3056,18 @@ let messagesById = new Map();
* Fetch recent messages from the JSON API.
*
* @param {number} [limit=NODE_LIMIT] Maximum number of rows.
* @param {{ encrypted?: boolean }} [options] Optional retrieval flags.
* @returns {Promise<Array<Object>>} Parsed message payloads.
*/
async function fetchMessages(limit = MESSAGE_LIMIT) {
async function fetchMessages(limit = MESSAGE_LIMIT, options = {}) {
if (!CHAT_ENABLED) return [];
const safeLimit = normaliseMessageLimit(limit);
const r = await fetch(`/api/messages?limit=${safeLimit}`, { cache: 'no-store' });
const params = new URLSearchParams({ limit: String(safeLimit) });
if (options && options.encrypted) {
params.set('encrypted', 'true');
}
const query = params.toString();
const r = await fetch(`/api/messages?${query}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
@@ -3640,6 +3655,7 @@ let messagesById = new Map();
renderChatLog({
nodes: allNodes,
messages: allMessages,
encryptedMessages: allEncryptedMessages,
telemetryEntries: allTelemetryEntries,
positionEntries: allPositionEntries,
neighborEntries: allNeighbors,
@@ -3691,12 +3707,17 @@ let messagesById = new Map();
console.warn('position refresh failed; continuing without updates', err);
return [];
});
const [nodes, positions, neighborTuples, messages, telemetryEntries] = await Promise.all([
const encryptedMessagesPromise = fetchMessages(MESSAGE_LIMIT, { encrypted: true }).catch(err => {
console.warn('encrypted message refresh failed; continuing without encrypted entries', err);
return [];
});
const [nodes, positions, neighborTuples, messages, telemetryEntries, encryptedMessages] = await Promise.all([
fetchNodes(),
positionsPromise,
neighborPromise,
fetchMessages(MESSAGE_LIMIT),
telemetryPromise,
encryptedMessagesPromise
]);
nodes.forEach(applyNodeNameFallback);
mergePositionsIntoNodes(nodes, positions);
@@ -3704,8 +3725,12 @@ let messagesById = new Map();
mergeTelemetryIntoNodes(nodes, telemetryEntries);
allNodes = nodes;
rebuildNodeIndex(allNodes);
const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById);
const [chatMessages, encryptedChatMessages] = await Promise.all([
messageNodeHydrator.hydrate(messages, nodesById),
messageNodeHydrator.hydrate(encryptedMessages, nodesById)
]);
allMessages = Array.isArray(chatMessages) ? chatMessages : [];
allEncryptedMessages = Array.isArray(encryptedChatMessages) ? encryptedChatMessages : [];
allTelemetryEntries = Array.isArray(telemetryEntries) ? telemetryEntries : [];
allPositionEntries = Array.isArray(positions) ? positions : [];
allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];