chat-ma 1.0.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 +100 -0
- package/bin/chat.js +279 -0
- package/client/lib/decryptAnimation.js +26 -0
- package/client/lib/glitch.js +23 -0
- package/client/lib/hackerBoxes.js +38 -0
- package/client/lib/localConfig.js +32 -0
- package/client/lib/matrixRain.js +26 -0
- package/client/lib/prompts.js +35 -0
- package/client/lib/ui.js +12 -0
- package/client/lib/wsClient.js +24 -0
- package/package.json +23 -0
- package/server/auth.js +47 -0
- package/server/config.js +12 -0
- package/server/memoryMessages.js +58 -0
- package/server/rateLimit.js +19 -0
- package/server/server.js +106 -0
- package/server/userDb.js +41 -0
- package/server/ws.js +102 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# chat-ma
|
|
2
|
+
|
|
3
|
+
Cinematic Matrix-style one-time terminal messenger distributed as an npm CLI package.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- WebSocket-based incoming message notifications.
|
|
8
|
+
- Full-screen terminal UI with Matrix digital rain and glitching header.
|
|
9
|
+
- Green hacker status boxes for register/login/send flows.
|
|
10
|
+
- One-time ephemeral messages only stored in memory.
|
|
11
|
+
- SQLite storage only for users + bcrypt password hashes.
|
|
12
|
+
- Password confirmation required before decrypting a message.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Run server
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm run serve
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Server default: `http://localhost:3000`
|
|
27
|
+
|
|
28
|
+
## Use CLI
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx chat-ma register
|
|
32
|
+
npx chat-ma login
|
|
33
|
+
npx chat-ma send
|
|
34
|
+
npx chat-ma open
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Authentication + local config
|
|
38
|
+
|
|
39
|
+
After successful register/login, client stores token in:
|
|
40
|
+
|
|
41
|
+
`~/.chat-ma/config.json`
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"serverUrl": "http://localhost:3000",
|
|
48
|
+
"token": "...",
|
|
49
|
+
"username": "alice"
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Security model
|
|
54
|
+
|
|
55
|
+
- Passwords are hashed with bcrypt and never stored plaintext.
|
|
56
|
+
- JWT session token for API/WebSocket auth.
|
|
57
|
+
- In-memory messages only (never written to SQLite).
|
|
58
|
+
- Message TTL defaults to 5 minutes.
|
|
59
|
+
- Message is destroyed after close (`VIEW_CLOSE`) or expiry.
|
|
60
|
+
- Basic rate limiting on register/login endpoints.
|
|
61
|
+
|
|
62
|
+
## Publish to npm
|
|
63
|
+
|
|
64
|
+
1. Update `version` in `package.json`.
|
|
65
|
+
2. Login to npm:
|
|
66
|
+
```bash
|
|
67
|
+
npm login
|
|
68
|
+
```
|
|
69
|
+
3. Publish:
|
|
70
|
+
```bash
|
|
71
|
+
npm publish --access public
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Project structure
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
chat-ma/
|
|
78
|
+
package.json
|
|
79
|
+
README.md
|
|
80
|
+
/bin
|
|
81
|
+
chat.js
|
|
82
|
+
/server
|
|
83
|
+
server.js
|
|
84
|
+
ws.js
|
|
85
|
+
auth.js
|
|
86
|
+
userDb.js
|
|
87
|
+
memoryMessages.js
|
|
88
|
+
rateLimit.js
|
|
89
|
+
config.js
|
|
90
|
+
/client
|
|
91
|
+
/lib
|
|
92
|
+
ui.js
|
|
93
|
+
matrixRain.js
|
|
94
|
+
glitch.js
|
|
95
|
+
hackerBoxes.js
|
|
96
|
+
decryptAnimation.js
|
|
97
|
+
wsClient.js
|
|
98
|
+
prompts.js
|
|
99
|
+
localConfig.js
|
|
100
|
+
```
|
package/bin/chat.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import blessed from 'blessed';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import cliProgress from 'cli-progress';
|
|
5
|
+
import { askCredentials, askSendPayload } from '../client/lib/prompts.js';
|
|
6
|
+
import { loadLocalConfig, requireAuthConfig, saveLocalConfig } from '../client/lib/localConfig.js';
|
|
7
|
+
import { printBanner } from '../client/lib/ui.js';
|
|
8
|
+
import {
|
|
9
|
+
showAuthorizedBox,
|
|
10
|
+
showDeniedBox,
|
|
11
|
+
showMessageFailedBox,
|
|
12
|
+
showMessageSentBox,
|
|
13
|
+
showStatusBox
|
|
14
|
+
} from '../client/lib/hackerBoxes.js';
|
|
15
|
+
import { connectWs } from '../client/lib/wsClient.js';
|
|
16
|
+
import { runDecryptAnimation } from '../client/lib/decryptAnimation.js';
|
|
17
|
+
import { startHeaderGlitch } from '../client/lib/glitch.js';
|
|
18
|
+
import { startMatrixRain } from '../client/lib/matrixRain.js';
|
|
19
|
+
|
|
20
|
+
const [, , command] = process.argv;
|
|
21
|
+
|
|
22
|
+
async function postJson(url, payload, token) {
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(payload)
|
|
30
|
+
});
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
if (!res.ok) throw new Error(data.error || 'Request failed');
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function register() {
|
|
37
|
+
printBanner();
|
|
38
|
+
const cfg = loadLocalConfig();
|
|
39
|
+
const { username, password } = await askCredentials('Register');
|
|
40
|
+
const spinner = ora('Provisioning identity...').start();
|
|
41
|
+
try {
|
|
42
|
+
const data = await postJson(`${cfg.serverUrl}/register`, { username, password });
|
|
43
|
+
spinner.succeed('Identity created');
|
|
44
|
+
saveLocalConfig({ token: data.token, username: data.username, serverUrl: cfg.serverUrl });
|
|
45
|
+
showAuthorizedBox();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
spinner.fail(err.message);
|
|
48
|
+
showDeniedBox();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function login() {
|
|
53
|
+
printBanner();
|
|
54
|
+
const cfg = loadLocalConfig();
|
|
55
|
+
const { username, password } = await askCredentials('Login');
|
|
56
|
+
const spinner = ora('Authenticating...').start();
|
|
57
|
+
try {
|
|
58
|
+
const data = await postJson(`${cfg.serverUrl}/login`, { username, password });
|
|
59
|
+
spinner.succeed('Session established');
|
|
60
|
+
saveLocalConfig({ token: data.token, username: data.username, serverUrl: cfg.serverUrl });
|
|
61
|
+
showAuthorizedBox();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
spinner.fail(err.message);
|
|
64
|
+
showDeniedBox();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function send() {
|
|
69
|
+
printBanner();
|
|
70
|
+
const cfg = requireAuthConfig();
|
|
71
|
+
const { to, body } = await askSendPayload();
|
|
72
|
+
const bar = new cliProgress.SingleBar({ format: 'UPLINK [{bar}] {percentage}%' }, cliProgress.Presets.shades_classic);
|
|
73
|
+
bar.start(100, 0);
|
|
74
|
+
|
|
75
|
+
showStatusBox('INITIALIZING UPLINK');
|
|
76
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
77
|
+
bar.update(40);
|
|
78
|
+
showStatusBox('ENCAPSULATING PAYLOAD');
|
|
79
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
80
|
+
bar.update(85);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await postJson(`${cfg.serverUrl}/send`, { to, body }, cfg.token);
|
|
84
|
+
bar.update(100);
|
|
85
|
+
bar.stop();
|
|
86
|
+
showMessageSentBox();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
bar.stop();
|
|
89
|
+
process.stdout.write(`${err.message}\n`);
|
|
90
|
+
showMessageFailedBox();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function askPasswordInScreen(screen) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const prompt = blessed.prompt({
|
|
97
|
+
parent: screen,
|
|
98
|
+
border: 'line',
|
|
99
|
+
label: ' Decrypt ',
|
|
100
|
+
top: 'center',
|
|
101
|
+
left: 'center',
|
|
102
|
+
width: '50%',
|
|
103
|
+
height: 8,
|
|
104
|
+
keys: true,
|
|
105
|
+
vi: true,
|
|
106
|
+
style: { border: { fg: 'green' }, fg: 'green' }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
prompt.input('Enter password to decrypt:', '', (_err, value) => resolve(value || ''));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function openInbox() {
|
|
114
|
+
const cfg = requireAuthConfig();
|
|
115
|
+
|
|
116
|
+
const screen = blessed.screen({ smartCSR: true, title: 'chat-ma terminal' });
|
|
117
|
+
const rain = blessed.box({
|
|
118
|
+
parent: screen,
|
|
119
|
+
top: 0,
|
|
120
|
+
left: 0,
|
|
121
|
+
width: '100%',
|
|
122
|
+
height: '100%',
|
|
123
|
+
tags: true,
|
|
124
|
+
style: { fg: 'green' }
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const headerText = 'AUTHORIZED TERMINAL';
|
|
128
|
+
const header = blessed.box({
|
|
129
|
+
parent: screen,
|
|
130
|
+
top: 0,
|
|
131
|
+
left: 'center',
|
|
132
|
+
width: 'shrink',
|
|
133
|
+
height: 1,
|
|
134
|
+
tags: true,
|
|
135
|
+
content: `{green-fg}${headerText}{/green-fg}`
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const status = blessed.box({
|
|
139
|
+
parent: screen,
|
|
140
|
+
bottom: 0,
|
|
141
|
+
left: 1,
|
|
142
|
+
height: 1,
|
|
143
|
+
tags: true,
|
|
144
|
+
content: `{green-fg}CONNECTED | USER: ${cfg.username} | ONE TIME MODE{/green-fg}`
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const modal = blessed.list({
|
|
148
|
+
parent: screen,
|
|
149
|
+
width: 40,
|
|
150
|
+
height: 11,
|
|
151
|
+
top: 'center',
|
|
152
|
+
left: 'center',
|
|
153
|
+
border: 'line',
|
|
154
|
+
style: {
|
|
155
|
+
fg: 'green',
|
|
156
|
+
border: { fg: 'green' },
|
|
157
|
+
selected: { bg: 'green', fg: 'black' }
|
|
158
|
+
},
|
|
159
|
+
keys: true,
|
|
160
|
+
vi: true,
|
|
161
|
+
mouse: false,
|
|
162
|
+
tags: true,
|
|
163
|
+
hidden: true,
|
|
164
|
+
label: ' INCOMING '
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const messageBox = blessed.box({
|
|
168
|
+
parent: screen,
|
|
169
|
+
width: '70%',
|
|
170
|
+
height: 9,
|
|
171
|
+
top: 'center',
|
|
172
|
+
left: 'center',
|
|
173
|
+
border: 'line',
|
|
174
|
+
style: { fg: 'green', border: { fg: 'green' } },
|
|
175
|
+
tags: true,
|
|
176
|
+
hidden: true
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const stopRain = startMatrixRain(screen, rain);
|
|
180
|
+
const stopGlitch = startHeaderGlitch(header, headerText, screen);
|
|
181
|
+
|
|
182
|
+
let currentIncoming = null;
|
|
183
|
+
|
|
184
|
+
const ws = connectWs(cfg.serverUrl, cfg.token, {
|
|
185
|
+
onMessage: async (msg, socket) => {
|
|
186
|
+
if (msg.type === 'INCOMING') {
|
|
187
|
+
currentIncoming = msg;
|
|
188
|
+
modal.setItems([
|
|
189
|
+
'',
|
|
190
|
+
' NEW ENCRYPTED MESSAGE RECEIVED ',
|
|
191
|
+
` FROM: ${msg.from} (${msg.len} chars)`,
|
|
192
|
+
'',
|
|
193
|
+
' [ VIEW ]',
|
|
194
|
+
' [ DISMISS ]'
|
|
195
|
+
]);
|
|
196
|
+
modal.select(4);
|
|
197
|
+
modal.show();
|
|
198
|
+
modal.focus();
|
|
199
|
+
screen.render();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (msg.type === 'VIEW_PAYLOAD') {
|
|
204
|
+
messageBox.show();
|
|
205
|
+
messageBox.setContent('{green-fg}Decrypting...{/green-fg}');
|
|
206
|
+
screen.render();
|
|
207
|
+
|
|
208
|
+
await runDecryptAnimation((line) => {
|
|
209
|
+
messageBox.setContent(`{green-fg}${line}{/green-fg}\n\n{green-fg}[ CLOSE ]{/green-fg}`);
|
|
210
|
+
screen.render();
|
|
211
|
+
}, msg.body);
|
|
212
|
+
|
|
213
|
+
messageBox.key(['enter'], () => {
|
|
214
|
+
socket.send(JSON.stringify({ type: 'VIEW_CLOSE', id: msg.id }));
|
|
215
|
+
messageBox.hide();
|
|
216
|
+
screen.render();
|
|
217
|
+
});
|
|
218
|
+
messageBox.focus();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (msg.type === 'VIEW_MISSING') {
|
|
222
|
+
messageBox.show();
|
|
223
|
+
messageBox.setContent('{green-fg}MESSAGE EXPIRED OR ALREADY VIEWED{/green-fg}');
|
|
224
|
+
screen.render();
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
messageBox.hide();
|
|
227
|
+
screen.render();
|
|
228
|
+
}, 1000);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
modal.on('select', async (_el, index) => {
|
|
234
|
+
if (!currentIncoming) return;
|
|
235
|
+
if (index === 4) {
|
|
236
|
+
modal.hide();
|
|
237
|
+
screen.render();
|
|
238
|
+
const password = await askPasswordInScreen(screen);
|
|
239
|
+
try {
|
|
240
|
+
await postJson(`${cfg.serverUrl}/verify-password`, { password }, cfg.token);
|
|
241
|
+
ws.send(JSON.stringify({ type: 'VIEW_REQUEST', id: currentIncoming.id }));
|
|
242
|
+
} catch {
|
|
243
|
+
modal.setItems(['', ' ACCESS DENIED ', '', ' [ OK ]']);
|
|
244
|
+
modal.select(3);
|
|
245
|
+
modal.show();
|
|
246
|
+
screen.render();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (index === 5 || index === 3) {
|
|
251
|
+
modal.hide();
|
|
252
|
+
currentIncoming = null;
|
|
253
|
+
screen.render();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
screen.key(['C-c', 'q'], () => {
|
|
258
|
+
stopRain();
|
|
259
|
+
stopGlitch();
|
|
260
|
+
ws.close();
|
|
261
|
+
return process.exit(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
screen.render();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function main() {
|
|
268
|
+
if (command === 'register') return register();
|
|
269
|
+
if (command === 'login') return login();
|
|
270
|
+
if (command === 'send') return send();
|
|
271
|
+
if (command === 'open') return openInbox();
|
|
272
|
+
|
|
273
|
+
process.stdout.write('Usage: chat-ma <register|login|send|open>\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
main().catch((err) => {
|
|
277
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
|
|
2
|
+
|
|
3
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
|
|
5
|
+
export async function runDecryptAnimation(setLine, message) {
|
|
6
|
+
const initial = 'X'.repeat(message.length);
|
|
7
|
+
setLine(initial);
|
|
8
|
+
const duration = 1400 + Math.floor(Math.random() * 1200);
|
|
9
|
+
const steps = Math.max(20, Math.floor(duration / 45));
|
|
10
|
+
|
|
11
|
+
for (let s = 0; s < steps; s += 1) {
|
|
12
|
+
const locked = Math.floor((s / steps) * message.length);
|
|
13
|
+
let line = '';
|
|
14
|
+
for (let i = 0; i < message.length; i += 1) {
|
|
15
|
+
if (i <= locked) {
|
|
16
|
+
line += message[i];
|
|
17
|
+
} else {
|
|
18
|
+
line += chars[Math.floor(Math.random() * chars.length)];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
setLine(line);
|
|
22
|
+
await sleep(45);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setLine(message);
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const glitchChars = '!@#$%^&*?';
|
|
2
|
+
|
|
3
|
+
function mutate(text) {
|
|
4
|
+
return text
|
|
5
|
+
.split('')
|
|
6
|
+
.map((c) => (Math.random() > 0.8 && c !== ' ' ? glitchChars[Math.floor(Math.random() * glitchChars.length)] : c))
|
|
7
|
+
.join('');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function startHeaderGlitch(headerBox, normalText, screen) {
|
|
11
|
+
const timer = setInterval(() => {
|
|
12
|
+
if (Math.random() > 0.82) {
|
|
13
|
+
headerBox.setContent(`{green-fg}${mutate(normalText)}{/green-fg}`);
|
|
14
|
+
screen.render();
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
headerBox.setContent(`{green-fg}${normalText}{/green-fg}`);
|
|
17
|
+
screen.render();
|
|
18
|
+
}, 140);
|
|
19
|
+
}
|
|
20
|
+
}, 700);
|
|
21
|
+
|
|
22
|
+
return () => clearInterval(timer);
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function center(line) {
|
|
2
|
+
const cols = process.stdout.columns || 80;
|
|
3
|
+
const pad = Math.max(0, Math.floor((cols - line.length) / 2));
|
|
4
|
+
return `${' '.repeat(pad)}${line}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function buildBox(lines = []) {
|
|
8
|
+
const width = Math.max(...lines.map((l) => l.length), 30) + 4;
|
|
9
|
+
const top = `╔${'═'.repeat(width - 2)}╗`;
|
|
10
|
+
const bottom = `╚${'═'.repeat(width - 2)}╝`;
|
|
11
|
+
const rows = lines.map((line) => {
|
|
12
|
+
const pad = width - 2 - line.length;
|
|
13
|
+
const left = Math.floor(pad / 2);
|
|
14
|
+
const right = pad - left;
|
|
15
|
+
return `║${' '.repeat(left)}${line}${' '.repeat(right)}║`;
|
|
16
|
+
});
|
|
17
|
+
return [top, ...rows, bottom].map(center).join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function showStatusBox(text) {
|
|
21
|
+
process.stdout.write(`${buildBox(['', text, ''])}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function showAuthorizedBox() {
|
|
25
|
+
showStatusBox('AUTHORIZED TERMINAL');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function showDeniedBox() {
|
|
29
|
+
showStatusBox('ACCESS DENIED');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function showMessageSentBox() {
|
|
33
|
+
showStatusBox('MESSAGE SENT');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function showMessageFailedBox() {
|
|
37
|
+
showStatusBox('MESSAGE FAILED');
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const cfgDir = path.join(os.homedir(), '.chat-ma');
|
|
6
|
+
const cfgPath = path.join(cfgDir, 'config.json');
|
|
7
|
+
|
|
8
|
+
export function loadLocalConfig() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return {
|
|
13
|
+
serverUrl: process.env.CHAT_MA_SERVER || 'http://localhost:3000'
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveLocalConfig(partial) {
|
|
19
|
+
const current = loadLocalConfig();
|
|
20
|
+
const merged = { ...current, ...partial };
|
|
21
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2));
|
|
23
|
+
return merged;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function requireAuthConfig() {
|
|
27
|
+
const cfg = loadLocalConfig();
|
|
28
|
+
if (!cfg.token || !cfg.username) {
|
|
29
|
+
throw new Error('Not logged in. Run: npx chat-ma login');
|
|
30
|
+
}
|
|
31
|
+
return cfg;
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const glyphs = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*+-?<>[]{}';
|
|
2
|
+
|
|
3
|
+
export function startMatrixRain(screen, targetBox) {
|
|
4
|
+
const width = screen.width;
|
|
5
|
+
const height = screen.height;
|
|
6
|
+
const drops = Array.from({ length: width }, () => Math.floor(Math.random() * height));
|
|
7
|
+
|
|
8
|
+
const timer = setInterval(() => {
|
|
9
|
+
const frame = Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
|
|
10
|
+
|
|
11
|
+
for (let x = 0; x < width; x += 1) {
|
|
12
|
+
const y = drops[x];
|
|
13
|
+
frame[y % height][x] = glyphs[Math.floor(Math.random() * glyphs.length)];
|
|
14
|
+
if (Math.random() > 0.96) {
|
|
15
|
+
drops[x] = 0;
|
|
16
|
+
} else {
|
|
17
|
+
drops[x] += 1 + (Math.random() > 0.7 ? 1 : 0);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
targetBox.setContent(`{green-fg}${frame.map((r) => r.join('')).join('\n')}{/green-fg}`);
|
|
22
|
+
screen.render();
|
|
23
|
+
}, 90);
|
|
24
|
+
|
|
25
|
+
return () => clearInterval(timer);
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
|
|
3
|
+
export async function askCredentials(action) {
|
|
4
|
+
return prompts([
|
|
5
|
+
{
|
|
6
|
+
type: 'text',
|
|
7
|
+
name: 'username',
|
|
8
|
+
message: `${action} - Username:`,
|
|
9
|
+
validate: (v) => (v?.trim() ? true : 'Username required')
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
type: 'password',
|
|
13
|
+
name: 'password',
|
|
14
|
+
message: `${action} - Password:`,
|
|
15
|
+
validate: (v) => (v?.length ? true : 'Password required')
|
|
16
|
+
}
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function askSendPayload() {
|
|
21
|
+
return prompts([
|
|
22
|
+
{
|
|
23
|
+
type: 'text',
|
|
24
|
+
name: 'to',
|
|
25
|
+
message: 'Recipient username:',
|
|
26
|
+
validate: (v) => (v?.trim() ? true : 'Recipient required')
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'text',
|
|
30
|
+
name: 'body',
|
|
31
|
+
message: 'Message:',
|
|
32
|
+
validate: (v) => (v?.length ? true : 'Message required')
|
|
33
|
+
}
|
|
34
|
+
]);
|
|
35
|
+
}
|
package/client/lib/ui.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export const palette = {
|
|
4
|
+
green: chalk.hex('#00ff66'),
|
|
5
|
+
dim: chalk.hex('#44aa66')
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function printBanner() {
|
|
9
|
+
process.stdout.write(
|
|
10
|
+
`${palette.green('\n[ AUTHORIZED TERMINAL ]')} ${palette.dim('ONE-TIME EPHEMERAL CHANNEL\n')}`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
|
|
3
|
+
export function connectWs(serverHttpUrl, token, handlers) {
|
|
4
|
+
const wsUrl = serverHttpUrl.replace(/^http/, 'ws') + '/ws';
|
|
5
|
+
const ws = new WebSocket(wsUrl);
|
|
6
|
+
|
|
7
|
+
ws.on('open', () => {
|
|
8
|
+
ws.send(JSON.stringify({ type: 'AUTH', token }));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
ws.on('message', (raw) => {
|
|
12
|
+
try {
|
|
13
|
+
const msg = JSON.parse(raw.toString());
|
|
14
|
+
handlers?.onMessage?.(msg, ws);
|
|
15
|
+
} catch {
|
|
16
|
+
// ignore malformed frames
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
ws.on('close', () => handlers?.onClose?.());
|
|
21
|
+
ws.on('error', (err) => handlers?.onError?.(err));
|
|
22
|
+
|
|
23
|
+
return ws;
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chat-ma",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cinematic Matrix-style ephemeral terminal messenger",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chat-ma": "./bin/chat.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"serve": "node server/server.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"bcrypt": "^5.1.1",
|
|
14
|
+
"better-sqlite3": "^11.7.0",
|
|
15
|
+
"blessed": "^0.1.81",
|
|
16
|
+
"chalk": "^5.3.0",
|
|
17
|
+
"cli-progress": "^3.12.0",
|
|
18
|
+
"jsonwebtoken": "^9.0.2",
|
|
19
|
+
"ora": "^8.1.1",
|
|
20
|
+
"prompts": "^2.4.2",
|
|
21
|
+
"ws": "^8.18.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { config } from './config.js';
|
|
4
|
+
import { createUser, findUserById, findUserByUsername } from './userDb.js';
|
|
5
|
+
|
|
6
|
+
const SALT_ROUNDS = 12;
|
|
7
|
+
|
|
8
|
+
export async function registerUser(username, password) {
|
|
9
|
+
if (!username || !password) {
|
|
10
|
+
throw new Error('Username and password required');
|
|
11
|
+
}
|
|
12
|
+
if (findUserByUsername(username)) {
|
|
13
|
+
throw new Error('Username already exists');
|
|
14
|
+
}
|
|
15
|
+
const passhash = await bcrypt.hash(password, SALT_ROUNDS);
|
|
16
|
+
const user = createUser(username, passhash);
|
|
17
|
+
const token = issueToken(user);
|
|
18
|
+
return { user, token };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loginUser(username, password) {
|
|
22
|
+
const user = findUserByUsername(username);
|
|
23
|
+
if (!user) {
|
|
24
|
+
throw new Error('Invalid credentials');
|
|
25
|
+
}
|
|
26
|
+
const ok = await bcrypt.compare(password, user.passhash);
|
|
27
|
+
if (!ok) {
|
|
28
|
+
throw new Error('Invalid credentials');
|
|
29
|
+
}
|
|
30
|
+
return { user, token: issueToken(user) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function verifyUserPassword(userId, password) {
|
|
34
|
+
const user = findUserById(userId);
|
|
35
|
+
if (!user) return false;
|
|
36
|
+
return bcrypt.compare(password, user.passhash);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function issueToken(user) {
|
|
40
|
+
return jwt.sign({ sub: user.id, username: user.username }, config.jwtSecret, {
|
|
41
|
+
expiresIn: config.jwtExpiresIn
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function verifyToken(token) {
|
|
46
|
+
return jwt.verify(token, config.jwtSecret);
|
|
47
|
+
}
|
package/server/config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
|
|
4
|
+
export const config = {
|
|
5
|
+
port: process.env.PORT ? Number(process.env.PORT) : 3000,
|
|
6
|
+
jwtSecret: process.env.JWT_SECRET || 'chat-ma-dev-secret-change-me',
|
|
7
|
+
jwtExpiresIn: '7d',
|
|
8
|
+
messageTtlMs: 5 * 60 * 1000,
|
|
9
|
+
dataDir: path.resolve(process.cwd(), 'server', 'data'),
|
|
10
|
+
userDbPath: path.resolve(process.cwd(), 'server', 'data', 'users.db'),
|
|
11
|
+
clientConfigPath: path.join(os.homedir(), '.chat-ma', 'config.json')
|
|
12
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
|
|
4
|
+
const messages = new Map();
|
|
5
|
+
|
|
6
|
+
export function createMessage({ from, to, body }) {
|
|
7
|
+
const id = crypto.randomUUID();
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
messages.set(id, {
|
|
10
|
+
id,
|
|
11
|
+
from,
|
|
12
|
+
to,
|
|
13
|
+
body,
|
|
14
|
+
createdAt: new Date(now).toISOString(),
|
|
15
|
+
expiresAt: now + config.messageTtlMs,
|
|
16
|
+
viewed: false
|
|
17
|
+
});
|
|
18
|
+
return messages.get(id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getPendingForUser(username) {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
return [...messages.values()].filter(
|
|
24
|
+
(msg) => msg.to === username && msg.expiresAt > now && !msg.viewed
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getMessageForRecipient(id, username) {
|
|
29
|
+
const msg = messages.get(id);
|
|
30
|
+
if (!msg || msg.to !== username) return null;
|
|
31
|
+
if (msg.expiresAt <= Date.now()) {
|
|
32
|
+
messages.delete(id);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return msg;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function markViewed(id) {
|
|
39
|
+
const msg = messages.get(id);
|
|
40
|
+
if (!msg) return false;
|
|
41
|
+
msg.viewed = true;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deleteMessage(id) {
|
|
46
|
+
return messages.delete(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cleanupExpired() {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
for (const [id, msg] of messages) {
|
|
52
|
+
if (msg.expiresAt <= now) {
|
|
53
|
+
messages.delete(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setInterval(cleanupExpired, 15_000).unref();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const buckets = new Map();
|
|
2
|
+
|
|
3
|
+
export function createRateLimiter({ windowMs, maxHits }) {
|
|
4
|
+
return (key) => {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
const current = buckets.get(key);
|
|
7
|
+
if (!current || now > current.resetAt) {
|
|
8
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
9
|
+
return { allowed: true, remaining: maxHits - 1 };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (current.count >= maxHits) {
|
|
13
|
+
return { allowed: false, retryMs: current.resetAt - now };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
current.count += 1;
|
|
17
|
+
return { allowed: true, remaining: maxHits - current.count };
|
|
18
|
+
};
|
|
19
|
+
}
|
package/server/server.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { loginUser, registerUser, verifyToken, verifyUserPassword } from './auth.js';
|
|
3
|
+
import { config } from './config.js';
|
|
4
|
+
import { createMessage } from './memoryMessages.js';
|
|
5
|
+
import { createRateLimiter } from './rateLimit.js';
|
|
6
|
+
import { attachWsServer, pushIncomingMessage } from './ws.js';
|
|
7
|
+
|
|
8
|
+
const authLimiter = createRateLimiter({ windowMs: 60_000, maxHits: 15 });
|
|
9
|
+
|
|
10
|
+
function json(res, status, payload) {
|
|
11
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
12
|
+
res.end(JSON.stringify(payload));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getIp(req) {
|
|
16
|
+
return req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress || 'unknown';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function bodyParser(req) {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
22
|
+
if (!chunks.length) return {};
|
|
23
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getBearer(req) {
|
|
27
|
+
const auth = req.headers.authorization || '';
|
|
28
|
+
const [scheme, token] = auth.split(' ');
|
|
29
|
+
if (scheme !== 'Bearer' || !token) return null;
|
|
30
|
+
try {
|
|
31
|
+
return verifyToken(token);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const server = http.createServer(async (req, res) => {
|
|
38
|
+
if (req.method === 'POST' && req.url === '/register') {
|
|
39
|
+
const limit = authLimiter(`register:${getIp(req)}`);
|
|
40
|
+
if (!limit.allowed) return json(res, 429, { error: 'Too many attempts' });
|
|
41
|
+
try {
|
|
42
|
+
const { username, password } = await bodyParser(req);
|
|
43
|
+
const result = await registerUser(username?.trim(), password);
|
|
44
|
+
return json(res, 200, {
|
|
45
|
+
token: result.token,
|
|
46
|
+
username: result.user.username
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return json(res, 400, { error: err.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method === 'POST' && req.url === '/login') {
|
|
54
|
+
const limit = authLimiter(`login:${getIp(req)}`);
|
|
55
|
+
if (!limit.allowed) return json(res, 429, { error: 'Too many attempts' });
|
|
56
|
+
try {
|
|
57
|
+
const { username, password } = await bodyParser(req);
|
|
58
|
+
const result = await loginUser(username?.trim(), password);
|
|
59
|
+
return json(res, 200, {
|
|
60
|
+
token: result.token,
|
|
61
|
+
username: result.user.username
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return json(res, 401, { error: err.message });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (req.method === 'POST' && req.url === '/send') {
|
|
69
|
+
const auth = getBearer(req);
|
|
70
|
+
if (!auth) return json(res, 401, { error: 'Unauthorized' });
|
|
71
|
+
try {
|
|
72
|
+
const { to, body } = await bodyParser(req);
|
|
73
|
+
if (!to || !body) return json(res, 400, { error: 'Recipient and body required' });
|
|
74
|
+
const message = createMessage({ from: auth.username, to: to.trim(), body: String(body) });
|
|
75
|
+
pushIncomingMessage(to.trim(), message);
|
|
76
|
+
return json(res, 200, {
|
|
77
|
+
ok: true,
|
|
78
|
+
id: message.id,
|
|
79
|
+
expiresAt: message.expiresAt
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return json(res, 400, { error: 'Failed to send message' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (req.method === 'POST' && req.url === '/verify-password') {
|
|
87
|
+
const auth = getBearer(req);
|
|
88
|
+
if (!auth) return json(res, 401, { error: 'Unauthorized' });
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const { password } = await bodyParser(req);
|
|
92
|
+
const ok = await verifyUserPassword(auth.sub, password);
|
|
93
|
+
return json(res, ok ? 200 : 403, { ok });
|
|
94
|
+
} catch {
|
|
95
|
+
return json(res, 400, { ok: false });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
json(res, 404, { error: 'Not found' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
attachWsServer(server);
|
|
103
|
+
|
|
104
|
+
server.listen(config.port, () => {
|
|
105
|
+
process.stdout.write(`chat-ma server listening on ${config.port}\n`);
|
|
106
|
+
});
|
package/server/userDb.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
|
|
6
|
+
fs.mkdirSync(path.dirname(config.userDbPath), { recursive: true });
|
|
7
|
+
|
|
8
|
+
const db = new Database(config.userDbPath);
|
|
9
|
+
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
12
|
+
id INTEGER PRIMARY KEY,
|
|
13
|
+
username TEXT UNIQUE,
|
|
14
|
+
passhash TEXT,
|
|
15
|
+
created_at TEXT
|
|
16
|
+
);
|
|
17
|
+
`);
|
|
18
|
+
|
|
19
|
+
const insertUserStmt = db.prepare(
|
|
20
|
+
'INSERT INTO users (username, passhash, created_at) VALUES (?, ?, ?)'
|
|
21
|
+
);
|
|
22
|
+
const findByUsernameStmt = db.prepare(
|
|
23
|
+
'SELECT id, username, passhash, created_at FROM users WHERE username = ?'
|
|
24
|
+
);
|
|
25
|
+
const findByIdStmt = db.prepare(
|
|
26
|
+
'SELECT id, username, passhash, created_at FROM users WHERE id = ?'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export function createUser(username, passhash) {
|
|
30
|
+
const createdAt = new Date().toISOString();
|
|
31
|
+
const info = insertUserStmt.run(username, passhash, createdAt);
|
|
32
|
+
return findByIdStmt.get(info.lastInsertRowid);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function findUserByUsername(username) {
|
|
36
|
+
return findByUsernameStmt.get(username);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findUserById(id) {
|
|
40
|
+
return findByIdStmt.get(id);
|
|
41
|
+
}
|
package/server/ws.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { verifyToken } from './auth.js';
|
|
3
|
+
import {
|
|
4
|
+
deleteMessage,
|
|
5
|
+
getMessageForRecipient,
|
|
6
|
+
getPendingForUser,
|
|
7
|
+
markViewed
|
|
8
|
+
} from './memoryMessages.js';
|
|
9
|
+
|
|
10
|
+
const sessionsByUser = new Map();
|
|
11
|
+
|
|
12
|
+
export function attachWsServer(httpServer) {
|
|
13
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
14
|
+
|
|
15
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
16
|
+
if (req.url !== '/ws') {
|
|
17
|
+
socket.destroy();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
21
|
+
wss.emit('connection', ws, req);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
wss.on('connection', (ws) => {
|
|
26
|
+
let username = null;
|
|
27
|
+
|
|
28
|
+
ws.on('message', (raw) => {
|
|
29
|
+
let msg;
|
|
30
|
+
try {
|
|
31
|
+
msg = JSON.parse(raw.toString());
|
|
32
|
+
} catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (msg.type === 'AUTH') {
|
|
37
|
+
try {
|
|
38
|
+
const payload = verifyToken(msg.token);
|
|
39
|
+
username = payload.username;
|
|
40
|
+
sessionsByUser.set(username, ws);
|
|
41
|
+
ws.send(JSON.stringify({ type: 'AUTH_OK', username }));
|
|
42
|
+
|
|
43
|
+
const pending = getPendingForUser(username);
|
|
44
|
+
for (const item of pending) {
|
|
45
|
+
ws.send(
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
type: 'INCOMING',
|
|
48
|
+
id: item.id,
|
|
49
|
+
from: item.from,
|
|
50
|
+
len: item.body.length
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
ws.send(JSON.stringify({ type: 'AUTH_FAIL' }));
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!username) return;
|
|
61
|
+
|
|
62
|
+
if (msg.type === 'VIEW_REQUEST') {
|
|
63
|
+
const target = getMessageForRecipient(msg.id, username);
|
|
64
|
+
if (!target || target.viewed) {
|
|
65
|
+
ws.send(JSON.stringify({ type: 'VIEW_MISSING', id: msg.id }));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
markViewed(target.id);
|
|
69
|
+
ws.send(
|
|
70
|
+
JSON.stringify({ type: 'VIEW_PAYLOAD', id: target.id, body: target.body })
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (msg.type === 'VIEW_CLOSE') {
|
|
76
|
+
deleteMessage(msg.id);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
ws.on('close', () => {
|
|
81
|
+
if (username && sessionsByUser.get(username) === ws) {
|
|
82
|
+
sessionsByUser.delete(username);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return wss;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function pushIncomingMessage(toUser, message) {
|
|
91
|
+
const ws = sessionsByUser.get(toUser);
|
|
92
|
+
if (!ws || ws.readyState !== ws.OPEN) return;
|
|
93
|
+
|
|
94
|
+
ws.send(
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
type: 'INCOMING',
|
|
97
|
+
id: message.id,
|
|
98
|
+
from: message.from,
|
|
99
|
+
len: message.body.length
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
}
|