-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
744 lines (668 loc) · 30.2 KB
/
Copy pathserver.js
File metadata and controls
744 lines (668 loc) · 30.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
app.set("trust proxy", true);
app.use((req, res, next) => {
if (process.env.DEBUG_IP === '1') {
try {
console.log('DEBUG_IP: incoming request', {
url: req.url,
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip'],
forwarded: req.headers['forwarded']
},
req_ip: req.ip,
socket_remote: req.socket && req.socket.remoteAddress
});
} catch (e) {
console.log('DEBUG_IP: error logging request debug info', e);
}
}
next();
});
if (process.env.TRUST_PROXY) {
app.set('trust proxy', process.env.TRUST_PROXY);
console.log('server: trust proxy set to', process.env.TRUST_PROXY);
}
app.use(express.static(__dirname));
const CONFIG_FILE = path.join(__dirname, 'config', 'config.json');
let params = { maxLines: 24, maxCols: 48, cooldownMs: 10000 };
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
const parsed = JSON.parse(raw);
params = Object.assign(params, parsed || {});
}
} catch (e) {
console.warn('Failed to read config file, using defaults:', e && e.message ? e.message : e);
}
params.maxLines = Number(process.env.MAX_LINES || params.maxLines);
params.maxCols = Number(process.env.MAX_COLS || params.maxCols);
params.cooldownMs = Number(process.env.COOLDOWN_MS || params.cooldownMs);
try {
params.maxLines = Math.max(0, Math.floor(Number(params.maxLines) || 0));
params.maxCols = Math.max(0, Math.floor(Number(params.maxCols) || 0));
params.maxChars = params.maxLines * params.maxCols;
} catch (e) {
params.maxLines = Number(params.maxLines) || 24;
params.maxCols = Number(params.maxCols) || 48;
params.maxChars = params.maxLines * params.maxCols;
}
app.get('/api/config', (req, res) => {
res.json({
maxLines: params.maxLines,
maxCols: params.maxCols,
maxChars: params.maxChars,
cooldownMs: params.cooldownMs,
colors: params.colors || {}
});
});
function rawBodySaver(req, res, buf, encoding) {
if (buf && buf.length) {
try { req.rawBody = buf.toString(encoding || 'utf8'); } catch (e) { req.rawBody = '<unable to decode raw body>'; }
}
}
app.use(bodyParser.urlencoded({ extended: true, verify: rawBodySaver }));
app.use(bodyParser.json({ verify: rawBodySaver }));
function safeStringify(obj, maxLen = 2000) {
try {
const s = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
if (s.length > maxLen) return s.slice(0, maxLen) + `...<truncated ${s.length - maxLen} chars>`;
return s;
} catch (e) {
return String(obj);
}
}
const COUNTERS_FILE = path.join(__dirname, 'data', 'counters.json');
const LAST_PRINTS_FILE = path.join(__dirname, 'data', 'last_prints.json');
const ALIASES_FILE = path.join(__dirname, 'data', 'aliases.json');
const HISTORY_FILE = path.join(__dirname, 'data', 'history.json');
function readLastPrints() {
try {
if (fs.existsSync(LAST_PRINTS_FILE)) {
const data = fs.readFileSync(LAST_PRINTS_FILE, 'utf8');
const parsed = JSON.parse(data);
return parsed && typeof parsed === 'object' ? parsed : {};
}
} catch (error) {
console.error('Error reading last_prints file:', error);
}
return {};
}
function writeLastPrints(lastPrints) {
try {
fs.writeFileSync(LAST_PRINTS_FILE, JSON.stringify(lastPrints, null, 2), 'utf8');
} catch (error) {
console.error('Error writing last_prints file:', error);
}
}
function readAliases() {
try {
if (fs.existsSync(ALIASES_FILE)) {
const data = fs.readFileSync(ALIASES_FILE, 'utf8');
const parsed = JSON.parse(data);
return parsed && typeof parsed === 'object' ? parsed : {};
}
} catch (error) {
console.error('Error reading aliases file:', error);
}
return {};
}
function readHistory() {
try {
if (fs.existsSync(HISTORY_FILE)) {
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
const parsed = JSON.parse(data);
return Array.isArray(parsed) ? parsed : [];
}
} catch (error) {
console.error('Error reading history file:', error);
}
return [];
}
function writeHistory(history) {
try {
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2), 'utf8');
} catch (error) {
console.error('Error writing history file:', error);
}
}
function appendHistory(entry) {
try {
const hist = readHistory();
hist.push(entry);
// keep history reasonable size (optional): trim to last 1000 entries
if (hist.length > 5000) hist.splice(0, hist.length - 5000);
writeHistory(hist);
} catch (e) {
console.error('Failed to append history entry:', e);
}
}
function writeAliases(aliases) {
try {
fs.writeFileSync(ALIASES_FILE, JSON.stringify(aliases, null, 2), 'utf8');
} catch (error) {
console.error('Error writing aliases file:', error);
}
}
function readCounters() {
try {
if (fs.existsSync(COUNTERS_FILE)) {
const data = fs.readFileSync(COUNTERS_FILE, 'utf8');
const parsed = JSON.parse(data);
return parsed && typeof parsed === 'object' ? parsed : {};
}
} catch (error) {
console.error('Error reading counters file:', error);
}
return {};
}
function writeCounters(counters) {
try {
fs.writeFileSync(COUNTERS_FILE, JSON.stringify(counters, null, 2), 'utf8');
} catch (error) {
console.error('Error writing counters file:', error);
}
}
function normalizeIp(ip) {
if (!ip) return ip;
if (ip.indexOf(',') !== -1) ip = ip.split(',')[0];
ip = ip.trim();
ip = ip.replace(/^::ffff:/i, '');
ip = ip.replace(/:\d+$/, '');
// If normalization produced something that's only colons or empty, treat as invalid
if (!ip || /^:+$/.test(ip)) return null;
return ip;
}
function sanitizeIp(ip) {
// Normalize then validate simple cases; return 'Unknown' for anything clearly invalid.
try {
if (!ip) return 'Unknown';
ip = normalizeIp(ip);
if (!ip) return 'Unknown';
// Reject odd results like just a colon or empty
if (ip.trim() === '' || /^:+$/.test(ip)) return 'Unknown';
return ip;
} catch (e) {
return 'Unknown';
}
}
// Return a masked/blurred representation of an IP (or generic key) safe for display
function maskIpDisplay(ip) {
try {
if (!ip || ip === 'Unknown') return 'Unknown';
// If input looks like IPv4
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
const parts = ip.split('.');
// show first two octets, mask last two
return `${parts[0]}.${parts[1]}.x.x`;
}
// IPv6-ish (contains colon)
if (ip.indexOf(':') !== -1) {
const parts = ip.split(':').filter(Boolean);
if (parts.length <= 2) return ip.replace(/:[^:]+$/, ':x');
// show first two and last one
return `${parts[0]}:${parts[1]}:...:${parts[parts.length-1]}`;
}
// Fallback: mask sequences of digits/letters near the end
return String(ip).replace(/([0-9a-fA-F]{2,})[^0-9a-fA-F]*$/, 'x');
} catch (e) {
return 'Unknown';
}
}
function maskKeyForDisplay(key) {
try {
if (!key) return 'Unknown';
if (String(key).startsWith('peer:')) {
return 'peer:' + maskIpDisplay(String(key).slice(5));
}
return maskIpDisplay(String(key));
} catch (e) {
return 'Unknown';
}
}
function getClientIp(req) {
// Prefer explicit proxy headers (X-Forwarded-For / X-Real-IP) when present.
// These headers are commonly set by reverse proxies or Docker / nginx setups
// and carry the original client IP. Only fall back to req.ip or socket
// addresses when the headers are absent.
const debug = process.env.DEBUG_IP === '1';
// X-Forwarded-For is the most common header and may contain a comma-separated list.
const xff = req.headers['x-forwarded-for'] || req.headers['x-forwarded'] || req.headers['forwarded-for'];
if (xff) {
if (debug) console.log('DEBUG_IP: using x-forwarded-for/header:', xff);
// xff may contain a comma-separated list of IPs; normalizeIp will take the first
return normalizeIp(xff);
}
const xreal = req.headers['x-real-ip'];
if (xreal) {
if (debug) console.log('DEBUG_IP: using x-real-ip:', xreal);
return normalizeIp(xreal);
}
const forwarded = req.headers['forwarded'];
if (forwarded) {
if (debug) console.log('DEBUG_IP: raw Forwarded header:', forwarded);
// Extract the first for= value, which may be quoted
const m = forwarded.match(/for=(?:\")?([^;,"]+)(?:\")?/i);
if (m && m[1]) {
if (debug) console.log('DEBUG_IP: parsed forwarded for=', m[1]);
return normalizeIp(m[1]);
}
}
if (req.ip && app.get('trust proxy')) {
return normalizeIp(req.ip);
}
const remote = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : null;
return normalizeIp(remote) || 'Unknown';
}
function getPeerIp(req) {
const remote = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : null;
return normalizeIp(remote) || 'Unknown';
}
const USE_REAL_PRINTER = process.env.REAL_PRINTER === '1';
let ThermalPrinter, PrinterTypes;
if (USE_REAL_PRINTER) {
try {
({ ThermalPrinter, PrinterTypes } = require('node-thermal-printer'));
console.log('Diagnostic: node-thermal-printer loaded in server.js', !!ThermalPrinter, !!PrinterTypes);
} catch (e) {
console.warn('Diagnostic: node-thermal-printer not available in server.js; using simulation.');
ThermalPrinter = null; PrinterTypes = null;
}
} else {
console.log('REAL_PRINTER not enabled; running in simulation mode. Set REAL_PRINTER=1 to enable.');
}
// Printer device path must be provided via environment variable to avoid
// embedding local device paths in source checked into public repos.
const PRINTER_DEVICE = process.env.PRINTER_DEVICE;
async function printMessageToThermalPrinter(ipAddress, message, alias) {
console.log('server.printMessageToThermalPrinter called - ThermalPrinter present?', !!ThermalPrinter);
if (!ThermalPrinter || !PrinterTypes) {
throw new Error('Thermal printer library not loaded');
}
if (!PRINTER_DEVICE) throw new Error('PRINTER_DEVICE not configured');
const printer = new ThermalPrinter({
type: PrinterTypes.EPSON,
interface: PRINTER_DEVICE
});
try {
printer.setTextNormal();
printer.alignLeft();
const lines = String(message || '').split('\n');
for (const line of lines) {
printer.println(line);
}
const lastsixdigitsofip = ipAddress.replace(/[^0-9]/g, '').slice(-6);
const now = new Date();
const pad = n => n.toString().padStart(2, '0');
const day = pad(now.getDate());
const month = pad(now.getMonth() + 1);
const year = pad(now.getFullYear() % 100);
const hour = pad(now.getHours());
const minute = pad(now.getMinutes());
const safeAlias = alias || 'anon';
let line = safeAlias;
if (lastsixdigitsofip) line += `-${lastsixdigitsofip}`;
const dateTime = `${day}${month}${year}-${hour}${minute}`;
if (dateTime) line += `-${dateTime}`;
printer.setTextNormal();
printer.println('---');
printer.println(line);
printer.partialCut();
await printer.execute();
} catch (err) {
console.error('server: thermal print failed', err && err.stack ? err.stack : err);
throw err;
} finally {
try { if (typeof printer.disconnect === 'function') printer.disconnect(); } catch (e) { }
}
}
app.post('/print', (req, res) => {
(async () => {
const rawIp = getClientIp(req);
const ipAddress = sanitizeIp(rawIp);
const peer = getPeerIp(req) || 'UnknownPeer';
// Do not include ephemeral remote port in the cooldownKey for normal cases.
// Fallback order:
// 1) sanitized client IP
// 2) peer (socket) IP if available
// 3) as last resort, use socket address+port (rare)
let cooldownKey;
if (ipAddress && ipAddress !== 'Unknown') cooldownKey = ipAddress;
else if (peer && peer !== 'Unknown') cooldownKey = peer;
else {
const ra = (req.socket && req.socket.remoteAddress) ? String(req.socket.remoteAddress) : 'UnknownPeer';
const rp = (req.socket && req.socket.remotePort) ? String(req.socket.remotePort) : '';
cooldownKey = rp ? `${ra}:${rp}` : ra;
}
// Prefer normalized message provided by client; do server-side light sanitization
// but preserve newlines and spaces so the printed output matches the input.
const rawMessage = (req.body && (req.body._normalized_message || req.body.message)) ? String(req.body._normalized_message || req.body.message) : '';
// Server-side sanitation: normalize CRLF to LF, remove embedded NULs and other
// control characters (but keep tabs/newlines and printable characters). Do
// NOT collapse spaces or newlines — we want the printer to receive the exact
// layout the user entered.
const normalizeServerWhitespace = s => {
let str = String(s || '');
// Normalize CRLF -> LF
str = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Remove NULs and other C0 control characters except TAB (\t) and LF (\n)
// Keep characters in basic printable ranges and high unicode.
try {
str = str.replace(/[^\t\n\x20-\x7E\xA0-\u{10FFFF}]/gu, '');
} catch (e) {
// If the regex with unicode flag fails for older Node, fall back to
// a conservative removal of NULs only.
str = str.replace(/\u0000/g, '');
}
return str;
};
let message = normalizeServerWhitespace(rawMessage);
// Preserve blank lines: do not strip lines consisting only of whitespace
// (the client enforces the grid defined in limits.json and the printer should receive exact layout)
// Enforce printer physical width: chunk every line into `limits.maxCols`-character segments
// so that the receipt prints with explicit breaks at the printer's column width.
const RECEIPT_COLS = Number(params.maxCols) || 48;
try {
const lines = message.split('\n');
const chunked = [];
for (const ln of lines) {
let pos = 0;
while (pos < ln.length) {
chunked.push(ln.substr(pos, RECEIPT_COLS));
pos += RECEIPT_COLS;
}
// If a line was exactly multiple of RECEIPT_COLS, we don't add an extra empty line here;
// the split/print will naturally produce the correct line breaks.
}
message = chunked.join('\n');
} catch (e) {
// ignore and proceed with original message on error
}
// optional alias field - a nickname the user may set once per-ip
const requestedAlias = (req.body.alias && String(req.body.alias).trim()) ? String(req.body.alias).trim().slice(0, 50) : null;
if (!message || message.trim() === '') {
console.warn(`Invalid submission from ${ipAddress}: Missing message.`);
return res.status(400).json({ error: 'Message is required.' });
}
const MAX_PRINT_CHARS = (Number(params.maxCols) || 48) * (Number(params.maxLines) || 48);
// Count printable characters only (exclude newline characters) and
// truncate per-line while preserving newline boundaries so the printer
// receives the same line breaks the user entered.
const origLines = message.split('\n');
let printableCount = origLines.reduce((acc, l) => acc + l.length, 0);
if (printableCount > MAX_PRINT_CHARS) {
// Truncate lines in order, preserving newline separators, until budget exhausted
let remaining = MAX_PRINT_CHARS;
const kept = [];
for (const l of origLines) {
if (remaining <= 0) break;
if (l.length <= remaining) {
kept.push(l);
remaining -= l.length;
} else {
kept.push(l.slice(0, remaining));
remaining = 0;
}
}
message = kept.join('\n');
printableCount = MAX_PRINT_CHARS;
}
if (printableCount > MAX_PRINT_CHARS) {
console.warn(`Invalid submission from ${ipAddress}: Message too long (${printableCount} printable chars).`);
return res.status(400).json({ error: `Message too long. Maximum ${MAX_PRINT_CHARS} printable characters allowed.` });
}
// Prepare two variants: one for printing (raw) and one for storage/display
// (escaped so HTML pages can't be inadvertently injected). The printed
// message uses the possibly-truncated `message` computed above.
const printedMessage = message;
const safeMessage = printedMessage.replace(/</g, '<').replace(/>/g, '>');
try {
// Enforce a per-IP cooldown (ms)
const COOLDOWN_MS = Number(params.cooldownMs) || 10000;
const lastPrints = readLastPrints();
// Use cooldownKey so unknown clients don't share a single 'Unknown' timestamp
const lastTs = Number(lastPrints[cooldownKey] || 0);
const now = Date.now();
if (lastTs && (now - lastTs) < COOLDOWN_MS) {
const waitSec = Math.ceil((COOLDOWN_MS - (now - lastTs)) / 1000);
console.warn(`Cooldown: rejecting print from ${cooldownKey} (reported ip: ${ipAddress}), wait ${waitSec}s`);
return res.status(429).json({ ok: false, error: 'Too many requests', retry_after_seconds: waitSec });
}
// Determine effective alias for printing: prefer existing stored alias, otherwise the requestedAlias
let effectiveAlias = null;
try {
const aliases = readAliases();
effectiveAlias = aliases[cooldownKey] || requestedAlias || null;
} catch (e) {
console.error('Failed to read aliases when determining effectiveAlias:', e);
effectiveAlias = requestedAlias || null;
}
if (USE_REAL_PRINTER && typeof printMessageToThermalPrinter === 'function' && ThermalPrinter && PrinterTypes) {
try {
console.log('Attempting real thermal print for IP:', ipAddress, 'alias:', effectiveAlias);
// pass unescaped/raw text to the printer so newlines/spaces are preserved
await printMessageToThermalPrinter(ipAddress, printedMessage, effectiveAlias);
console.log(`[Server] Message from ${ipAddress} printed to thermal printer.`);
} catch (printerErr) {
console.error('Real printer failed, falling back to simulation:', printerErr && printerErr.message ? printerErr.message : printerErr);
}
} else {
// Use masked IP in logs/simulated prints so frontends and logs won't display full client IPs
const displayIp = maskIpDisplay(ipAddress);
console.log('Thermal library not available, using simulation for IP:', displayIp, 'alias:', effectiveAlias);
// Simulate printing including alias
if (effectiveAlias) {
console.log('--- SIMULATED PRINT ---');
console.log(effectiveAlias);
console.log('-----------------------');
}
// In simulation mode show the raw printed message (not HTML-escaped)
console.log(printedMessage);
console.log('IP:', displayIp);
console.log('---- end simulated print ----');
}
// Ensure data directory and last_prints file exist before writing
try {
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(LAST_PRINTS_FILE)) writeLastPrints({});
if (!fs.existsSync(COUNTERS_FILE)) writeCounters({});
if (!fs.existsSync(ALIASES_FILE)) writeAliases({});
if (!fs.existsSync(HISTORY_FILE)) writeHistory([]);
} catch (e) {
console.error('Failed to ensure data files exist:', e);
}
const counters = readCounters();
const current = Number(counters[cooldownKey] || 0) + 1;
counters[cooldownKey] = current;
writeCounters(counters);
// Persist alias if provided and not already set for this ip (alias is immutable)
try {
if (requestedAlias) {
// Basic sanitization: remove control chars and limit length
const safeAlias = requestedAlias.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 50);
// Read current aliases and refuse to overwrite an existing mapping.
let aliases = readAliases();
// If an alias is already set for this key, never overwrite it.
if (aliases[cooldownKey]) {
// existing alias is immutable; ignore requestedAlias
} else {
// Re-read just before writing to reduce race windows where another
// concurrent request might have set the alias. Only write if still not present.
aliases = readAliases();
if (!aliases[cooldownKey]) {
aliases[cooldownKey] = safeAlias;
writeAliases(aliases);
}
}
}
} catch (e) {
console.error('Failed to persist alias:', e);
}
// Update last print timestamp on success
try {
lastPrints[cooldownKey] = Date.now();
writeLastPrints(lastPrints);
} catch (e) {
console.error('Failed to update last_prints:', e);
}
// Persist message into history (include sanitized message, ip/peer key, alias, timestamp)
try {
const aliases = readAliases();
const storedAlias = aliases[cooldownKey] || null;
const historyEntry = {
key: cooldownKey,
alias: storedAlias,
message: safeMessage,
timestamp: new Date().toISOString()
};
appendHistory(historyEntry);
} catch (e) {
console.error('Failed to write history entry:', e);
}
// Include any stored alias (if present) in the response so the UI can display it.
// Return a masked IP for display to avoid revealing customer data.
let alias = null;
try {
const aliases = readAliases();
alias = aliases[cooldownKey] || null;
} catch (e) {
console.error('Failed to read alias for response:', e);
}
const displayIp = maskIpDisplay(ipAddress);
res.status(200).json({ ok: true, ip: displayIp, count: current, alias });
} catch (error) {
console.error('Error during printing process:', error && error.stack ? error.stack : error);
res.status(500).send('Failed to print message due to internal server error.');
}
})();
});
app.get('/whoami', (req, res) => {
const rawIp = getClientIp(req);
const ip = sanitizeIp(rawIp);
// Recreate the same cooldownKey logic used in /print so we can match history entries
const peer = getPeerIp(req) || 'UnknownPeer';
let cooldownKey;
if (ip && ip !== 'Unknown') cooldownKey = ip;
else if (peer && peer !== 'Unknown') cooldownKey = peer;
else {
const ra = (req.socket && req.socket.remoteAddress) ? String(req.socket.remoteAddress) : 'UnknownPeer';
const rp = (req.socket && req.socket.remotePort) ? String(req.socket.remotePort) : '';
cooldownKey = rp ? `${ra}:${rp}` : ra;
}
const counters = readCounters();
// We prefer the counter indexed by cooldownKey if present, otherwise fall back to sanitized ip
const count = Number(counters[cooldownKey] || counters[ip] || 0);
// Look up alias if present (use cooldownKey)
let alias = null;
try {
const aliases = readAliases();
alias = aliases[cooldownKey] || aliases[ip] || null;
} catch (e) {
console.error('Failed to read aliases in whoami:', e);
}
// Collect history entries for this key (most recent first) and limit to last 3
let historyEntries = [];
try {
const hist = readHistory();
const filtered = hist.filter(h => h && h.key === cooldownKey);
// Take up to last 3 entries. `slice(-3)` handles arrays smaller than 3.
const rawEntries = filtered.slice(-3).reverse();
// Mask any fields that might reveal IPs or keys before sending to UI
historyEntries = rawEntries.map(e => ({
timestamp: e.timestamp,
alias: e.alias || null,
message: e.message,
// mask the stored key so frontend can't map to a raw IP
key: maskKeyForDisplay(e.key)
}));
} catch (e) {
console.error('Failed to read history in whoami:', e);
}
// Return masked/ip-safe value for display
const displayIp = maskIpDisplay(ip);
res.json({ ip: displayIp, count, alias, history: historyEntries });
});
app.listen(PORT, () => {
console.log(`✅ Server running at http://localhost:${PORT}`);
// Perform a startup test print on every container (re)start.
// This helps verify printer connectivity after Docker restarts.
(async () => {
try {
const startupMessage = 'Thermobanko: Docker restart test print';
const startupAlias = 'startup';
if (USE_REAL_PRINTER && typeof printMessageToThermalPrinter === 'function' && ThermalPrinter && PrinterTypes) {
try {
console.log('Startup: attempting real thermal test print...');
// ipAddress parameter is informational here; use 'container' to indicate origin
await printMessageToThermalPrinter('container', startupMessage, startupAlias);
console.log('Startup: real test print sent successfully.');
} catch (e) {
console.error('Startup: real test print failed, falling back to simulation:', e && e.message ? e.message : e);
}
} else {
console.log('REAL_PRINTER not enabled or thermal library missing; emitting startup simulated print:');
console.log('--- STARTUP SIMULATED PRINT ---');
console.log(startupAlias);
console.log(startupMessage);
console.log('---- end startup simulated print ----');
}
} catch (e) {
console.error('Startup test print encountered an error:', e && e.stack ? e.stack : e);
}
})();
});
// Ensure data directory and files exist on startup
try {
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(COUNTERS_FILE)) writeCounters({});
if (!fs.existsSync(LAST_PRINTS_FILE)) writeLastPrints({});
if (!fs.existsSync(HISTORY_FILE)) writeHistory([]);
// If aliases file exists, migrate keys to stable form (strip ephemeral ports)
try {
if (fs.existsSync(ALIASES_FILE)) {
const existing = readAliases();
const migrated = {};
const conflicts = [];
Object.keys(existing).forEach(origKey => {
let key = origKey;
// Strip previous 'peer:' prefix if present to attempt normalization
if (key.startsWith('peer:')) key = key.slice(5);
const normalizedCandidate = normalizeIp(key);
let normalized;
if (normalizedCandidate) normalized = normalizedCandidate;
else {
// Still not normal: fallback to 'peer:' + original with colons escaped
normalized = `peer:${origKey.replace(/:/g, '%3A')}`;
}
const aliasVal = existing[origKey];
if (!migrated[normalized]) {
migrated[normalized] = aliasVal;
} else if (String(migrated[normalized]) !== String(aliasVal)) {
// Conflict: two different aliases mapped to the same normalized key.
conflicts.push({ key: normalized, kept: migrated[normalized], dropped: aliasVal, origKey });
}
});
// If migration changed the structure, persist it and log conflicts
if (JSON.stringify(migrated) !== JSON.stringify(existing)) {
try {
writeAliases(migrated);
if (conflicts.length) console.warn('Alias migration conflicts (kept first, dropped others):', conflicts);
else console.log('Alias file migrated to normalized keys.');
} catch (e) {
console.error('Failed to write migrated aliases file:', e);
}
}
}
} catch (e) {
console.error('Failed to migrate aliases on startup:', e);
}
} catch (e) {
console.error('Failed to create data files on startup:', e);
}