mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-12 03:42:57 +02:00
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:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
Reference in New Issue
Block a user