ciphermesh 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/README.pt-BR.md +18 -4
- package/package.json +1 -1
- package/src/client/ChatController.js +335 -29
- package/src/client/Connection.js +31 -1
- package/src/client/FileTransfer.js +53 -24
- package/src/client/UI.js +277 -19
- package/src/crypto/CertPinStore.js +39 -3
- package/src/p2p/P2PChatController.js +764 -21
- package/src/p2p/index.js +12 -0
- package/src/protocol/validators.js +10 -1
- package/src/shared/dnd.js +13 -0
- package/src/shared/doctor.js +225 -0
|
@@ -71,7 +71,38 @@ export class FileTransfer {
|
|
|
71
71
|
// Compute SHA-256
|
|
72
72
|
const sha256 = await this.#computeSHA256(absPath);
|
|
73
73
|
|
|
74
|
-
//
|
|
74
|
+
// Register the transfer BEFORE announcing it. The peer can accept the
|
|
75
|
+
// instant the offer lands — on a fast LAN that easily beats reading a
|
|
76
|
+
// large file off disk — and an accept that finds no transfer would be
|
|
77
|
+
// dropped silently, killing the transfer at the accept timeout.
|
|
78
|
+
let resolveP;
|
|
79
|
+
const done = new Promise((r) => {
|
|
80
|
+
resolveP = r;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const acceptTimer = setTimeout(() => {
|
|
84
|
+
this.#outgoing.delete(transferId);
|
|
85
|
+
callbacks.onError(`${fileName}: offer was not accepted in time`);
|
|
86
|
+
resolveP();
|
|
87
|
+
}, this.#acceptTimeoutMs);
|
|
88
|
+
if (acceptTimer.unref) {
|
|
89
|
+
acceptTimer.unref();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.#outgoing.set(transferId, {
|
|
93
|
+
interval: null,
|
|
94
|
+
resolve: resolveP,
|
|
95
|
+
chunks: null, // filled in once the file is read
|
|
96
|
+
skip: new Set(),
|
|
97
|
+
pending: true,
|
|
98
|
+
acceptedEarly: false, // accepted while we were still reading
|
|
99
|
+
acceptTimer,
|
|
100
|
+
broadcastFn,
|
|
101
|
+
callbacks,
|
|
102
|
+
fileName,
|
|
103
|
+
totalChunks,
|
|
104
|
+
});
|
|
105
|
+
|
|
75
106
|
broadcastFn({
|
|
76
107
|
action: 'file_offer',
|
|
77
108
|
transferId,
|
|
@@ -87,29 +118,19 @@ export class FileTransfer {
|
|
|
87
118
|
// (so files are never pushed without consent).
|
|
88
119
|
const chunks = await this.#readChunks(absPath);
|
|
89
120
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}, this.#acceptTimeoutMs);
|
|
96
|
-
if (acceptTimer.unref) {
|
|
97
|
-
acceptTimer.unref();
|
|
98
|
-
}
|
|
121
|
+
const transfer = this.#outgoing.get(transferId);
|
|
122
|
+
if (!transfer) {
|
|
123
|
+
return done; // rejected or timed out while we were reading
|
|
124
|
+
}
|
|
125
|
+
transfer.chunks = chunks;
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
broadcastFn,
|
|
108
|
-
callbacks,
|
|
109
|
-
fileName,
|
|
110
|
-
totalChunks,
|
|
111
|
-
});
|
|
112
|
-
});
|
|
127
|
+
// The peer already said yes — start now that the bytes are ready.
|
|
128
|
+
if (transfer.acceptedEarly) {
|
|
129
|
+
transfer.pending = false;
|
|
130
|
+
this.#beginStreaming(transferId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return done;
|
|
113
134
|
}
|
|
114
135
|
|
|
115
136
|
/** Receiver accepted the offer — start streaming (honouring resume `have`). */
|
|
@@ -118,7 +139,6 @@ export class FileTransfer {
|
|
|
118
139
|
if (!transfer || !transfer.pending) {
|
|
119
140
|
return;
|
|
120
141
|
}
|
|
121
|
-
transfer.pending = false;
|
|
122
142
|
clearTimeout(transfer.acceptTimer);
|
|
123
143
|
if (Array.isArray(data.have)) {
|
|
124
144
|
for (const i of data.have) {
|
|
@@ -127,6 +147,15 @@ export class FileTransfer {
|
|
|
127
147
|
}
|
|
128
148
|
}
|
|
129
149
|
}
|
|
150
|
+
|
|
151
|
+
// Accepted before the file finished being read: remember the consent and
|
|
152
|
+
// let initSend start streaming as soon as the chunks are ready.
|
|
153
|
+
if (!transfer.chunks) {
|
|
154
|
+
transfer.acceptedEarly = true;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
transfer.pending = false;
|
|
130
159
|
this.#beginStreaming(data.transferId);
|
|
131
160
|
}
|
|
132
161
|
|
package/src/client/UI.js
CHANGED
|
@@ -19,7 +19,10 @@ const COMMAND_INFO = [
|
|
|
19
19
|
['/users', 'List online users'],
|
|
20
20
|
['/msg', 'Private message (DM)'],
|
|
21
21
|
['/reply', 'Reply to the last message'],
|
|
22
|
+
['/me', 'Third-person action message'],
|
|
23
|
+
['/topic', 'Show or set the room topic'],
|
|
22
24
|
['/mentions', 'Recent mentions of you'],
|
|
25
|
+
['/watch', 'Alert on a keyword in any room'],
|
|
23
26
|
['/contacts', 'Contact book — aliases for peers'],
|
|
24
27
|
['/away', 'Mark yourself as away'],
|
|
25
28
|
['/back', 'Clear away status'],
|
|
@@ -41,7 +44,9 @@ const COMMAND_INFO = [
|
|
|
41
44
|
['/img', 'Render a high-resolution image'],
|
|
42
45
|
['/sound', 'Sound notifications'],
|
|
43
46
|
['/notify', 'Desktop notifications'],
|
|
44
|
-
['/search', 'Search history'],
|
|
47
|
+
['/search', 'Search history (on disk)'],
|
|
48
|
+
['/find', 'Find in this room and jump to it'],
|
|
49
|
+
['/doctor', 'Diagnose why a connection fails'],
|
|
45
50
|
['/history', 'Recent messages from history'],
|
|
46
51
|
['/export', 'Export history'],
|
|
47
52
|
['/backup', 'Back up identity + trust'],
|
|
@@ -89,7 +94,9 @@ export const COMMANDS = [
|
|
|
89
94
|
'/sound',
|
|
90
95
|
'/msg',
|
|
91
96
|
'/reply',
|
|
97
|
+
'/me',
|
|
92
98
|
'/mentions',
|
|
99
|
+
'/watch',
|
|
93
100
|
'/contacts',
|
|
94
101
|
'/away',
|
|
95
102
|
'/back',
|
|
@@ -99,11 +106,14 @@ export const COMMANDS = [
|
|
|
99
106
|
'/dnd',
|
|
100
107
|
'/join',
|
|
101
108
|
'/leave',
|
|
109
|
+
'/topic',
|
|
102
110
|
'/create',
|
|
103
111
|
'/rooms',
|
|
104
112
|
'/room',
|
|
105
113
|
'/invite',
|
|
106
114
|
'/search',
|
|
115
|
+
'/find',
|
|
116
|
+
'/doctor',
|
|
107
117
|
'/history',
|
|
108
118
|
'/export',
|
|
109
119
|
'/backup',
|
|
@@ -314,6 +324,39 @@ export function renderMarkdown(text) {
|
|
|
314
324
|
return out.join('\n');
|
|
315
325
|
}
|
|
316
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Find `query` in rendered chat lines, returning where each hit is plus a
|
|
329
|
+
* readable preview. Blessed tags are stripped before matching so a search for
|
|
330
|
+
* "fg" never matches colour markup, and the preview is centred on the hit
|
|
331
|
+
* instead of always showing the start of a long line. Pure — exported for
|
|
332
|
+
* testing.
|
|
333
|
+
*
|
|
334
|
+
* @returns {Array<{ lineIndex: number, preview: string }>} newest last
|
|
335
|
+
*/
|
|
336
|
+
export function findInLines(lines, query, maxHits = 200) {
|
|
337
|
+
const needle = String(query || '')
|
|
338
|
+
.trim()
|
|
339
|
+
.toLowerCase();
|
|
340
|
+
if (!needle || !Array.isArray(lines)) {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
const hits = [];
|
|
344
|
+
for (let i = 0; i < lines.length && hits.length < maxHits; i++) {
|
|
345
|
+
const plain = String(lines[i]).replace(/\{[^{}]*\}/g, '');
|
|
346
|
+
const at = plain.toLowerCase().indexOf(needle);
|
|
347
|
+
if (at === -1) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const start = Math.max(0, at - 24);
|
|
351
|
+
const slice = plain.slice(start, start + 76).trim();
|
|
352
|
+
hits.push({
|
|
353
|
+
lineIndex: i,
|
|
354
|
+
preview: `${start > 0 ? '…' : ''}${blessed.escape(slice)}`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return hits;
|
|
358
|
+
}
|
|
359
|
+
|
|
317
360
|
// Sanitizes pasted text while PRESERVING its line structure — the input box is
|
|
318
361
|
// multi-line and fenced code blocks render in markdown, so pasted code must
|
|
319
362
|
// keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
|
|
@@ -504,6 +547,12 @@ export class UI extends EventEmitter {
|
|
|
504
547
|
#activeBuffer; // name of the buffer currently on screen
|
|
505
548
|
#redirecting; // true while add* calls are being written to an inactive buffer
|
|
506
549
|
#bufferBar; // [{ room, active, unread, private }] for the status bar
|
|
550
|
+
#topic; // current room topic, shown in the status bar
|
|
551
|
+
#finder; // Ctrl+F scrollback search overlay
|
|
552
|
+
#finderOpen;
|
|
553
|
+
#finderQuery;
|
|
554
|
+
#finderHits; // [{ lineIndex, preview }]
|
|
555
|
+
#finderMark; // line index currently highlighted by a jump
|
|
507
556
|
|
|
508
557
|
constructor(nickname) {
|
|
509
558
|
super();
|
|
@@ -557,6 +606,11 @@ export class UI extends EventEmitter {
|
|
|
557
606
|
this.#activeBuffer = 'general';
|
|
558
607
|
this.#redirecting = false;
|
|
559
608
|
this.#bufferBar = [];
|
|
609
|
+
this.#topic = null;
|
|
610
|
+
this.#finderOpen = false;
|
|
611
|
+
this.#finderQuery = '';
|
|
612
|
+
this.#finderHits = [];
|
|
613
|
+
this.#finderMark = null;
|
|
560
614
|
|
|
561
615
|
// blessed's terminfo parser can't compile the modern Setulc (underline
|
|
562
616
|
// colour) capability that terminals like ghostty ship, so it dumps a
|
|
@@ -658,6 +712,24 @@ export class UI extends EventEmitter {
|
|
|
658
712
|
},
|
|
659
713
|
});
|
|
660
714
|
|
|
715
|
+
// ── Scrollback finder (Ctrl+F) ───────────────────────
|
|
716
|
+
this.#finder = blessed.list({
|
|
717
|
+
parent: this.#screen,
|
|
718
|
+
hidden: true,
|
|
719
|
+
top: 'center',
|
|
720
|
+
left: 'center',
|
|
721
|
+
width: '80%',
|
|
722
|
+
height: '55%',
|
|
723
|
+
tags: true,
|
|
724
|
+
border: { type: 'line' },
|
|
725
|
+
label: ' Find in this room (Ctrl+F) ',
|
|
726
|
+
style: {
|
|
727
|
+
border: { fg: 'cyan' },
|
|
728
|
+
selected: { bg: 'cyan', fg: 'black' },
|
|
729
|
+
item: { fg: 'white' },
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
661
733
|
// ── Emoji picker (Ctrl+E) ────────────────────────────
|
|
662
734
|
this.#emojiPicker = blessed.list({
|
|
663
735
|
parent: this.#screen,
|
|
@@ -732,6 +804,11 @@ export class UI extends EventEmitter {
|
|
|
732
804
|
return;
|
|
733
805
|
}
|
|
734
806
|
|
|
807
|
+
if (this.#finderOpen) {
|
|
808
|
+
this.#handleFinderKey(ch, key);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
735
812
|
this.#handleKey(ch, key);
|
|
736
813
|
});
|
|
737
814
|
|
|
@@ -773,6 +850,12 @@ export class UI extends EventEmitter {
|
|
|
773
850
|
return;
|
|
774
851
|
}
|
|
775
852
|
|
|
853
|
+
// Ctrl+F — find in the current room's scrollback
|
|
854
|
+
if (key.ctrl && name === 'f') {
|
|
855
|
+
this.openFinder();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
776
859
|
// Tab — autocomplete
|
|
777
860
|
if (name === 'tab') {
|
|
778
861
|
this.#handleTab();
|
|
@@ -1142,6 +1225,104 @@ export class UI extends EventEmitter {
|
|
|
1142
1225
|
}
|
|
1143
1226
|
}
|
|
1144
1227
|
|
|
1228
|
+
// ── Scrollback finder (Ctrl+F) ───────────────────────
|
|
1229
|
+
// Search what is on screen in THIS room and jump to the hit. Unlike
|
|
1230
|
+
// /search (which queries the encrypted history on disk, possibly from other
|
|
1231
|
+
// sessions), every result here has a real line to scroll to.
|
|
1232
|
+
|
|
1233
|
+
/** Open the finder, optionally pre-filled (used by /search results). */
|
|
1234
|
+
openFinder(query = '') {
|
|
1235
|
+
this.#finderOpen = true;
|
|
1236
|
+
this.#finderQuery = query;
|
|
1237
|
+
this.#refreshFinder();
|
|
1238
|
+
this.#finder.show();
|
|
1239
|
+
this.#finder.setFront();
|
|
1240
|
+
this.#screen.render();
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
#closeFinder() {
|
|
1244
|
+
this.#finderOpen = false;
|
|
1245
|
+
this.#finder.hide();
|
|
1246
|
+
this.#screen.render();
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
#refreshFinder() {
|
|
1250
|
+
this.#finderHits = findInLines(this.#lines, this.#finderQuery);
|
|
1251
|
+
this.#finder.setItems(
|
|
1252
|
+
this.#finderHits.length > 0
|
|
1253
|
+
? this.#finderHits.map((h) => ` {#8888aa-fg}${h.lineIndex + 1}{/#8888aa-fg} ${h.preview}`)
|
|
1254
|
+
: [
|
|
1255
|
+
this.#finderQuery
|
|
1256
|
+
? ' {#8888aa-fg}no match in this room{/#8888aa-fg}'
|
|
1257
|
+
: ' {#8888aa-fg}type to search…{/#8888aa-fg}',
|
|
1258
|
+
],
|
|
1259
|
+
);
|
|
1260
|
+
this.#finder.select(0);
|
|
1261
|
+
const q = this.#finderQuery ? ` › ${this.#finderQuery}` : '';
|
|
1262
|
+
const n = this.#finderHits.length ? ` — ${this.#finderHits.length} hit(s)` : '';
|
|
1263
|
+
this.#finder.setLabel(` Find in this room (Ctrl+F)${q}${n} `);
|
|
1264
|
+
this.#screen.render();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** Scroll the chat to `lineIndex` and mark it so the eye finds it. */
|
|
1268
|
+
jumpToLine(lineIndex) {
|
|
1269
|
+
if (lineIndex < 0 || lineIndex >= this.#lines.length) {
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
this.#clearJumpMark();
|
|
1273
|
+
const original = this.#lines[lineIndex];
|
|
1274
|
+
this.#finderMark = { lineIndex, original };
|
|
1275
|
+
this.#lines[lineIndex] = `{yellow-fg}▶{/yellow-fg}${original}`;
|
|
1276
|
+
this.#chatLog.setContent(this.#lines.join('\n'));
|
|
1277
|
+
// Put the hit a few lines from the top so its context stays visible.
|
|
1278
|
+
this.#chatLog.scrollTo(Math.max(0, lineIndex - 3));
|
|
1279
|
+
this.#screen.render();
|
|
1280
|
+
this.#syncScrollState();
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
#clearJumpMark() {
|
|
1285
|
+
if (!this.#finderMark) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const { lineIndex, original } = this.#finderMark;
|
|
1289
|
+
if (this.#lines[lineIndex] !== undefined) {
|
|
1290
|
+
this.#lines[lineIndex] = original;
|
|
1291
|
+
}
|
|
1292
|
+
this.#finderMark = null;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
#handleFinderKey(ch, key) {
|
|
1296
|
+
const name = key.name || '';
|
|
1297
|
+
if (name === 'escape' || (key.ctrl && name === 'c')) {
|
|
1298
|
+
this.#closeFinder();
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (name === 'up' || name === 'down') {
|
|
1302
|
+
this.#finder[name === 'up' ? 'up' : 'down'](1);
|
|
1303
|
+
this.#screen.render();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if (name === 'return' || name === 'enter') {
|
|
1307
|
+
const hit = this.#finderHits[this.#finder.selected];
|
|
1308
|
+
this.#closeFinder();
|
|
1309
|
+
if (hit) {
|
|
1310
|
+
this.jumpToLine(hit.lineIndex);
|
|
1311
|
+
}
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (name === 'backspace') {
|
|
1315
|
+
this.#finderQuery = this.#finderQuery.slice(0, -1);
|
|
1316
|
+
this.#refreshFinder();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const code = ch ? ch.charCodeAt(0) : 0;
|
|
1320
|
+
if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
|
|
1321
|
+
this.#finderQuery += ch;
|
|
1322
|
+
this.#refreshFinder();
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1145
1326
|
// ── Emoji picker (Ctrl+E, fuzzy) ─────────────────────
|
|
1146
1327
|
#openEmoji() {
|
|
1147
1328
|
this.#emojiOpen = true;
|
|
@@ -1416,14 +1597,22 @@ export class UI extends EventEmitter {
|
|
|
1416
1597
|
} else {
|
|
1417
1598
|
room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
|
|
1418
1599
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1600
|
+
// The topic earns its place next to the room name; it is what tells you
|
|
1601
|
+
// what a room is FOR. Truncated so it can never push the hints off-screen.
|
|
1602
|
+
const topic = this.#topic
|
|
1603
|
+
? ` {#9a9ad0-fg}📋 ${blessed.escape(
|
|
1604
|
+
this.#topic.length > 60 ? `${this.#topic.slice(0, 57)}…` : this.#topic,
|
|
1605
|
+
)}{/#9a9ad0-fg}`
|
|
1421
1606
|
: '';
|
|
1607
|
+
const fp =
|
|
1608
|
+
this.#statusFingerprint && !this.#topic
|
|
1609
|
+
? ` {#8888aa-fg}🔑 ${this.#statusFingerprint}{/#8888aa-fg}`
|
|
1610
|
+
: '';
|
|
1422
1611
|
const hint =
|
|
1423
1612
|
this.#bufferBar.length > 1
|
|
1424
1613
|
? '{#7777aa-fg}Alt+1..9 buffers · Ctrl+K commands · /help{/#7777aa-fg}'
|
|
1425
1614
|
: '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
|
|
1426
|
-
return ` ${room}${fp} {|} ${hint} `;
|
|
1615
|
+
return ` ${room}${topic}${fp} {|} ${hint} `;
|
|
1427
1616
|
}
|
|
1428
1617
|
|
|
1429
1618
|
#updateStatusBar() {
|
|
@@ -1433,6 +1622,12 @@ export class UI extends EventEmitter {
|
|
|
1433
1622
|
}
|
|
1434
1623
|
}
|
|
1435
1624
|
|
|
1625
|
+
/** Set (or clear with null) the topic shown in the status bar. */
|
|
1626
|
+
setTopic(text) {
|
|
1627
|
+
this.#topic = text || null;
|
|
1628
|
+
this.#updateStatusBar();
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1436
1631
|
setFingerprint(fingerprint) {
|
|
1437
1632
|
// short prefix of the fingerprint as a persistent identity anchor
|
|
1438
1633
|
this.#statusFingerprint = (fingerprint || '').slice(0, 17);
|
|
@@ -1588,6 +1783,64 @@ export class UI extends EventEmitter {
|
|
|
1588
1783
|
trust = 'none',
|
|
1589
1784
|
) {
|
|
1590
1785
|
this.#daySeparator();
|
|
1786
|
+
const isSelfNow = nickname === this.#nickname || nickname.includes('\u2192');
|
|
1787
|
+
const opts = {
|
|
1788
|
+
isDM,
|
|
1789
|
+
ephemeralLabel,
|
|
1790
|
+
deniable,
|
|
1791
|
+
mentioned,
|
|
1792
|
+
trust,
|
|
1793
|
+
grouped: !isSelfNow && !isDM && this.#lastSender === nickname,
|
|
1794
|
+
stamp: time(),
|
|
1795
|
+
};
|
|
1796
|
+
const line = this.#composeMessageLine(nickname, text, opts);
|
|
1797
|
+
|
|
1798
|
+
this.#lines.push(line);
|
|
1799
|
+
this.#chatLog.log(line);
|
|
1800
|
+
this.#screen.render();
|
|
1801
|
+
this.#lastSender = isSelfNow ? 'self' : nickname;
|
|
1802
|
+
if (!isSelfNow) {
|
|
1803
|
+
this.#noteIncoming(mentioned || isDM);
|
|
1804
|
+
}
|
|
1805
|
+
return { lineIndex: this.#lines.length - 1, render: { nickname, opts } };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Rewrite an existing message line with new text — used by /edit, so an
|
|
1810
|
+
* edited message changes IN PLACE instead of arriving as a separate line the
|
|
1811
|
+
* reader has to mentally staple to the original.
|
|
1812
|
+
*/
|
|
1813
|
+
replaceMessageText(lineIndex, nickname, newText, opts) {
|
|
1814
|
+
this.updateLine(
|
|
1815
|
+
lineIndex,
|
|
1816
|
+
this.#composeMessageLine(nickname, newText, { ...opts, edited: true }),
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** Replace a message with a tombstone (used by /delete). */
|
|
1821
|
+
tombstoneMessage(lineIndex, nickname) {
|
|
1822
|
+
this.updateLine(
|
|
1823
|
+
lineIndex,
|
|
1824
|
+
` {white-fg}[${time()}]{/white-fg} {#666666-fg}\ud83d\udeab ${blessed.escape(
|
|
1825
|
+
nickname,
|
|
1826
|
+
)} deleted a message{/#666666-fg}`,
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Builds a message line. Shared by addMessage and replaceMessageText so an
|
|
1831
|
+
// edited message keeps exactly the layout it had (alignment, grouping,
|
|
1832
|
+
// badges) instead of drifting into a different shape.
|
|
1833
|
+
#composeMessageLine(nickname, text, opts) {
|
|
1834
|
+
const {
|
|
1835
|
+
isDM = false,
|
|
1836
|
+
ephemeralLabel = null,
|
|
1837
|
+
deniable = false,
|
|
1838
|
+
mentioned = false,
|
|
1839
|
+
trust = 'none',
|
|
1840
|
+
grouped = false,
|
|
1841
|
+
edited = false,
|
|
1842
|
+
stamp = time(),
|
|
1843
|
+
} = opts || {};
|
|
1591
1844
|
|
|
1592
1845
|
const color = nickColor(nickname);
|
|
1593
1846
|
const isSelf = nickname === this.#nickname || nickname.includes('\u2192');
|
|
@@ -1609,24 +1862,15 @@ export class UI extends EventEmitter {
|
|
|
1609
1862
|
|
|
1610
1863
|
// Consecutive messages from the same peer collapse the avatar/name into a
|
|
1611
1864
|
// compact continuation bullet (cleaner layout).
|
|
1612
|
-
const
|
|
1865
|
+
const editedMark = edited ? ' {#8888aa-fg}(edited){/#8888aa-fg}' : '';
|
|
1613
1866
|
const core = grouped
|
|
1614
|
-
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}`
|
|
1615
|
-
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}`;
|
|
1867
|
+
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}${editedMark}`
|
|
1868
|
+
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}${editedMark}`;
|
|
1616
1869
|
|
|
1617
1870
|
// My own messages on the right (timestamp at the end), others on the left
|
|
1618
|
-
|
|
1619
|
-
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${
|
|
1620
|
-
: `${bar}{white-fg}[${
|
|
1621
|
-
|
|
1622
|
-
this.#lines.push(line);
|
|
1623
|
-
this.#chatLog.log(line);
|
|
1624
|
-
this.#screen.render();
|
|
1625
|
-
this.#lastSender = isSelf ? 'self' : nickname;
|
|
1626
|
-
if (!isSelf) {
|
|
1627
|
-
this.#noteIncoming(mentioned || isDM);
|
|
1628
|
-
}
|
|
1629
|
-
return { lineIndex: this.#lines.length - 1 };
|
|
1871
|
+
return isSelf
|
|
1872
|
+
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${stamp}]{/white-fg}`)
|
|
1873
|
+
: `${bar}{white-fg}[${stamp}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
|
|
1630
1874
|
}
|
|
1631
1875
|
|
|
1632
1876
|
#daySeparator() {
|
|
@@ -1673,6 +1917,20 @@ export class UI extends EventEmitter {
|
|
|
1673
1917
|
this.updateLine(lineIndex, line);
|
|
1674
1918
|
}
|
|
1675
1919
|
|
|
1920
|
+
// Third-person action (/me). Rendered as a distinct italic line so it never
|
|
1921
|
+
// reads like someone quoting themselves.
|
|
1922
|
+
addActionMessage(nickname, text) {
|
|
1923
|
+
this.#lastSender = null; // an action breaks message grouping
|
|
1924
|
+
const line = ` {white-fg}[${time()}]{/white-fg} {magenta-fg}✦ {bold}${blessed.escape(
|
|
1925
|
+
nickname,
|
|
1926
|
+
)}{/bold} ${renderMarkdown(text)}{/magenta-fg}`;
|
|
1927
|
+
this.#lines.push(line);
|
|
1928
|
+
this.#chatLog.log(line);
|
|
1929
|
+
this.#screen.render();
|
|
1930
|
+
this.#noteIncoming();
|
|
1931
|
+
return { lineIndex: this.#lines.length - 1 };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1676
1934
|
addSystemMessage(text) {
|
|
1677
1935
|
this.#lastSender = null; // interrupts message grouping
|
|
1678
1936
|
const line = ` {white-fg}[${time()}] * ${blessed.escape(text)}{/white-fg}`;
|
|
@@ -17,6 +17,7 @@ export const PinResult = {
|
|
|
17
17
|
export class CertPinStore {
|
|
18
18
|
#path;
|
|
19
19
|
#store; // Map<host, sha256Fingerprint>
|
|
20
|
+
#caHosts; // Set<host> — hosts that already presented a CA-valid certificate
|
|
20
21
|
|
|
21
22
|
constructor(baseDir = process.cwd()) {
|
|
22
23
|
const dir = join(baseDir, PIN_DIR);
|
|
@@ -25,6 +26,7 @@ export class CertPinStore {
|
|
|
25
26
|
}
|
|
26
27
|
this.#path = join(dir, PIN_FILE);
|
|
27
28
|
this.#store = new Map();
|
|
29
|
+
this.#caHosts = new Set();
|
|
28
30
|
this.#load();
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -32,22 +34,56 @@ export class CertPinStore {
|
|
|
32
34
|
try {
|
|
33
35
|
if (existsSync(this.#path)) {
|
|
34
36
|
const data = JSON.parse(readFileSync(this.#path, 'utf-8'));
|
|
35
|
-
for (const [host,
|
|
36
|
-
|
|
37
|
+
for (const [host, entry] of Object.entries(data)) {
|
|
38
|
+
// v1 stored a bare fingerprint string; v2 stores { fp, ca }.
|
|
39
|
+
if (typeof entry === 'string') {
|
|
40
|
+
this.#store.set(host, entry);
|
|
41
|
+
} else if (entry && typeof entry === 'object') {
|
|
42
|
+
if (typeof entry.fp === 'string') {
|
|
43
|
+
this.#store.set(host, entry.fp);
|
|
44
|
+
}
|
|
45
|
+
if (entry.ca) {
|
|
46
|
+
this.#caHosts.add(host);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
} catch {
|
|
40
52
|
this.#store = new Map();
|
|
53
|
+
this.#caHosts = new Set();
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
#save() {
|
|
45
|
-
|
|
58
|
+
const obj = {};
|
|
59
|
+
// Union of both maps: a host can be CA-validated before (or without) ever
|
|
60
|
+
// having a pinned fingerprint, and that flag must survive a restart.
|
|
61
|
+
for (const host of new Set([...this.#store.keys(), ...this.#caHosts])) {
|
|
62
|
+
obj[host] = { fp: this.#store.get(host) || null, ca: this.#caHosts.has(host) };
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(this.#path, JSON.stringify(obj, null, 2), {
|
|
46
65
|
encoding: 'utf-8',
|
|
47
66
|
mode: 0o600,
|
|
48
67
|
});
|
|
49
68
|
}
|
|
50
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Remember that a host served a certificate that chains to a public CA.
|
|
72
|
+
* Subsequent connections to it demand full TLS verification, so a hosted
|
|
73
|
+
* relay can never be silently downgraded to a self-signed (attacker) cert.
|
|
74
|
+
*/
|
|
75
|
+
markCAValidated(host) {
|
|
76
|
+
if (!this.#caHosts.has(host)) {
|
|
77
|
+
this.#caHosts.add(host);
|
|
78
|
+
this.#save();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if this host must be verified strictly (it was CA-valid before). */
|
|
83
|
+
requiresStrictTLS(host) {
|
|
84
|
+
return this.#caHosts.has(host);
|
|
85
|
+
}
|
|
86
|
+
|
|
51
87
|
/**
|
|
52
88
|
* Check a server's cert fingerprint against the pinned one (pinning on first use).
|
|
53
89
|
* @param {string} host - e.g. "100.73.206.23:3600"
|