enigma-bbs/core/qwk_mail_packet.js

1732 lines
67 KiB
JavaScript

const ArchiveUtil = require('./archive_util');
const { Errors } = require('./enig_error');
const Message = require('./message');
const { splitTextAtTerms } = require('./string_util');
const {
getMessageConfTagByAreaTag,
getMessageAreaByTag,
getMessageConferenceByTag,
getAllAvailableMessageAreaTags,
} = require('./message_area');
const StatLog = require('./stat_log');
const Config = require('./config').get;
const SysProps = require('./system_property');
const UserProps = require('./user_property');
const { numToMbf32 } = require('./mbf');
const { getEncodingFromCharacterSetIdentifier } = require('./ftn_util');
const { EventEmitter } = require('events');
const temptmp = require('temptmp');
const async = require('async');
const fs = require('graceful-fs');
const paths = require('path');
const { Parser } = require('binary-parser');
const iconv = require('iconv-lite');
const moment = require('moment');
const _ = require('lodash');
const IniConfigParser = require('ini-config-parser');
const enigmaVersion = require('../package.json').version;
// Synchronet smblib TZ to a UTC offset
// see https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h
const SMBTZToUTCOffset = {
// US Standard
'40F0': '-04:00', // Atlantic
'412C': '-05:00', // Eastern
4168: '-06:00', // Central
'41A4': '-07:00', // Mountain
'41E0': '-08:00', // Pacific
'421C': '-09:00', // Yukon
4258: '-10:00', // Hawaii/Alaska
4294: '-11:00', // Bering
// US Daylight
C0F0: '-03:00', // Atlantic
C12C: '-04:00', // Eastern
C168: '-05:00', // Central
C1A4: '-06:00', // Mountain
C1E0: '-07:00', // Pacific
C21C: '-08:00', // Yukon
C258: '-09:00', // Hawaii/Alaska
C294: '-10:00', // Bering
// "Non-Standard"
2294: '-11:00', // Midway
'21E0': '-08:00', // Vancouver
'21A4': '-07:00', // Edmonton
2168: '-06:00', // Winnipeg
'212C': '-05:00', // Bogota
'20F0': '-04:00', // Caracas
'20B4': '-03:00', // Rio de Janeiro
2078: '-02:00', // Fernando de Noronha
'203C': '-01:00', // Azores
1000: '+00:00', // London
'103C': '+01:00', // Berlin
1078: '+02:00', // Athens
'10B4': '+03:00', // Moscow
'10F0': '+04:00', // Dubai
'110E': '+04:30', // Kabul
'112C': '+05:00', // Karachi
'114A': '+05:30', // Bombay
1159: '+05:45', // Kathmandu
1168: '+06:00', // Dhaka
'11A4': '+07:00', // Bangkok
'11E0': '+08:00', // Hong Kong
'121C': '+09:00', // Tokyo
1258: '+10:00', // Sydney
1294: '+11:00', // Noumea
'12D0': '+12:00', // Wellington
};
const UTCOffsetToSMBTZ = _.invert(SMBTZToUTCOffset);
const QWKMessageBlockSize = 128;
const QWKHeaderTimestampFormat = 'MM-DD-YYHH:mm';
const QWKLF = 0xe3;
const QWKMessageStatusCodes = {
UnreadPublic: ' ',
ReadPublic: '-',
UnreadPrivate: '+',
ReadPrivate: '*',
UnreadCommentToSysOp: '~',
ReadCommentToSysOp: '`',
UnreadSenderPWProtected: '%',
ReadSenderPWProtected: '^',
UnreadGroupPWProtected: '!',
ReadGroupPWProtected: '#',
PWProtectedToAll: '$',
Vote: 'V',
};
const QWKMessageActiveStatus = {
Active: 255,
Deleted: 226,
};
const QWKNetworkTagIndicator = {
Present: '*',
NotPresent: ' ',
};
// See the following:
// - http://fileformats.archiveteam.org/wiki/QWK
// - http://wiki.synchro.net/ref:qwk
//
const MessageHeaderParser = new Parser()
.endianess('little')
.string('status', {
encoding: 'ascii',
length: 1,
})
.string('num', {
// message num or conf num for REP's
encoding: 'ascii',
length: 7,
formatter: n => {
return parseInt(n);
},
})
.string('timestamp', {
encoding: 'ascii',
length: 13,
})
// these fields may be encoded in something other than ascii/CP437
.array('toName', {
type: 'uint8',
length: 25,
})
.array('fromName', {
type: 'uint8',
length: 25,
})
.array('subject', {
type: 'uint8',
length: 25,
})
.string('password', {
encoding: 'ascii',
length: 12,
})
.string('replyToNum', {
encoding: 'ascii',
length: 8,
formatter: n => {
return parseInt(n);
},
})
.string('numBlocks', {
encoding: 'ascii',
length: 6,
formatter: n => {
return parseInt(n);
},
})
.uint8('status2')
.uint16('confNum')
.uint16('relNum')
.uint8('netTag');
const replaceCharInBuffer = (buffer, search, replace) => {
let i = 0;
search = Buffer.from([search]);
while (i < buffer.length) {
i = buffer.indexOf(search, i);
if (-1 === i) {
break;
}
buffer[i] = replace;
++i;
}
};
class QWKPacketReader extends EventEmitter {
constructor(
packetPath,
{ mode = QWKPacketReader.Modes.Guess, keepTearAndOrigin = true } = {
mode: QWKPacketReader.Modes.Guess,
keepTearAndOrigin: true,
}
) {
super();
this.packetPath = packetPath;
this.options = { mode, keepTearAndOrigin };
this.temptmp = temptmp.createTrackedSession('qwkpacketreader');
}
static get Modes() {
return {
Guess: 'guess', // try to guess
QWK: 'qwk', // standard incoming packet
REP: 'rep', // a reply packet
};
}
read() {
//
// A general overview:
//
// - Find out what kind of archive we're dealing with
// - Extract to temporary location
// - Process various files
// - Emit messages we find, information about the packet, so on
//
async.waterfall(
[
// determine packet archive type
callback => {
const archiveUtil = ArchiveUtil.getInstance();
archiveUtil.detectType(this.packetPath, (err, archiveType) => {
if (err) {
return callback(err);
}
this.emit('archive type', archiveType);
return callback(null, archiveType);
});
},
// create a temporary location to do processing
(archiveType, callback) => {
this.temptmp.mkdir({ prefix: 'enigqwkreader-' }, (err, tempDir) => {
if (err) {
return callback(err);
}
return callback(null, archiveType, tempDir);
});
},
// extract it
(archiveType, tempDir, callback) => {
const archiveUtil = ArchiveUtil.getInstance();
archiveUtil.extractTo(this.packetPath, tempDir, archiveType, err => {
if (err) {
return callback(err);
}
return callback(null, tempDir);
});
},
// gather extracted file list
(tempDir, callback) => {
fs.readdir(tempDir, (err, files) => {
if (err) {
return callback(err);
}
// Discover basic information about well known files
async.reduce(
files,
{},
(out, filename, next) => {
const key = filename.toUpperCase();
switch (key) {
case 'MESSAGES.DAT': // QWK
if (
this.options.mode ===
QWKPacketReader.Modes.Guess
) {
this.options.mode = QWKPacketReader.Modes.QWK;
}
if (
this.options.mode ===
QWKPacketReader.Modes.QWK
) {
out.messages = { filename };
}
break;
case 'ID.MSG':
if (
this.options.mode ===
QWKPacketReader.Modes.Guess
) {
this.options.mode = QWKPacketReader.Modes.REP;
}
if (
this.options.mode ===
QWKPacketReader.Modes.REP
) {
out.messages = { filename };
}
break;
case 'HEADERS.DAT': // Synchronet
out.headers = { filename };
break;
case 'VOTING.DAT': // Synchronet
out.voting = { filename };
break;
case 'CONTROL.DAT': // QWK
out.control = { filename };
break;
case 'DOOR.ID': // QWK
out.door = { filename };
break;
case 'NETFLAGS.DAT': // QWK
out.netflags = { filename };
break;
case 'NEWFILES.DAT': // QWK
out.newfiles = { filename };
break;
case 'PERSONAL.NDX': // QWK
out.personal = { filename };
break;
case '000.NDX': // QWK
out.inbox = { filename };
break;
case 'TOREADER.EXT': // QWKE
out.toreader = { filename };
break;
case 'QLR.DAT':
out.qlr = { filename };
break;
default:
if (/[0-9]+\.NDX/.test(key)) {
// QWK
out.pointers = out.pointers || {
filenames: [],
};
out.pointers.filenames.push(filename);
} else {
out[key] = { filename };
}
break;
}
return next(null, out);
},
(err, packetFileInfo) => {
this.packetInfo = Object.assign({}, packetFileInfo, {
tempDir,
});
return callback(null);
}
);
});
},
callback => {
return this.processPacketFiles(callback);
},
],
err => {
this.temptmp.cleanup();
if (err) {
return this.emit('error', err);
}
this.emit('done');
}
);
}
processPacketFiles(cb) {
async.series(
[
callback => {
return this.readControl(callback);
},
callback => {
return this.readHeadersExtension(callback);
},
callback => {
return this.readMessages(callback);
},
],
err => {
return cb(err);
}
);
}
readControl(cb) {
//
// CONTROL.DAT is a CRLF text file containing information about
// the originating BBS, conf number <> name mapping, etc.
//
// References:
// - http://fileformats.archiveteam.org/wiki/QWK
//
if (!this.packetInfo.control) {
return cb(Errors.DoesNotExist('No control file found within QWK packet'));
}
const path = paths.join(
this.packetInfo.tempDir,
this.packetInfo.control.filename
);
// note that we read as UTF-8. Legacy says it should be CP437/ASCII
// but this seems safer for now so conference names and the like
// can be non-English for example.
fs.readFile(path, { encoding: 'utf8' }, (err, controlLines) => {
if (err) {
return cb(err);
}
controlLines = splitTextAtTerms(controlLines);
let state = 'header';
const control = { confMap: {} };
let currConfNumber;
for (let lineNumber = 0; lineNumber < controlLines.length; ++lineNumber) {
const line = controlLines[lineNumber].trim();
switch (lineNumber) {
// first set of lines is header info
case 0:
control.bbsName = line;
break;
case 1:
control.bbsLocation = line;
break;
case 2:
control.bbsPhone = line;
break;
case 3:
control.bbsSysOp = line;
break;
case 4:
control.doorRegAndBoardID = line;
break;
case 5:
control.packetCreationTime = line;
break;
case 6:
control.toUser = line;
break;
case 7:
break; // Qmail menu
case 8:
break; // unknown, always 0?
case 9:
break; // total messages in packet (often set to 0)
case 10:
control.totalMessages = parseInt(line) + 1;
state = 'confNumber';
break;
default:
switch (state) {
case 'confNumber':
currConfNumber = parseInt(line);
if (isNaN(currConfNumber)) {
state = 'news';
control.welcomeFile = line;
} else {
state = 'confName';
}
break;
case 'confName':
control.confMap[currConfNumber] = line;
state = 'confNumber';
break;
case 'news':
control.newsFile = line;
state = 'logoff';
break;
case 'logoff':
control.logoffFile = line;
state = 'footer';
break;
case 'footer':
// some systems append additional info; we don't care.
break;
}
}
}
return cb(null);
});
}
readHeadersExtension(cb) {
if (!this.packetInfo.headers) {
return cb(null); // nothing to do
}
const path = paths.join(
this.packetInfo.tempDir,
this.packetInfo.headers.filename
);
fs.readFile(path, { encoding: 'utf8' }, (err, iniData) => {
if (err) {
this.emit(
'warning',
Errors.Invalid(`Problem reading HEADERS.DAT: ${err.message}`)
);
return cb(null); // non-fatal
}
try {
const parserOptions = {
lineComment: false, // no line comments; consume full lines
nativeType: false, // just keep everything as strings
dotKey: false, // 'a.b.c = value' stays 'a.b.c = value'
};
this.packetInfo.headers.ini = IniConfigParser.parse(
iniData,
parserOptions
);
} catch (e) {
this.emit(
'warning',
Errors.Invalid(`HEADERS.DAT file appears to be invalid: ${e.message}`)
);
}
return cb(null);
});
}
readMessages(cb) {
if (!this.packetInfo.messages) {
return cb(Errors.DoesNotExist('No messages file found within QWK packet'));
}
const encodingToSpec = 'cp437';
let encoding;
const path = paths.join(
this.packetInfo.tempDir,
this.packetInfo.messages.filename
);
fs.open(path, 'r', (err, fd) => {
if (err) {
return cb(err);
}
// Some mappings/etc. used in loops below....
// Sync sets these in HEADERS.DAT: http://wiki.synchro.net/ref:qwk
const FTNPropertyMapping = {
'X-FTN-AREA': Message.FtnPropertyNames.FtnArea,
'X-FTN-SEEN-BY': Message.FtnPropertyNames.FtnSeenBy,
};
const FTNKludgeMapping = {
'X-FTN-PATH': 'PATH',
'X-FTN-MSGID': 'MSGID',
'X-FTN-REPLY': 'REPLY',
'X-FTN-PID': 'PID',
'X-FTN-FLAGS': 'FLAGS',
'X-FTN-TID': 'TID',
'X-FTN-CHRS': 'CHRS',
// :TODO: X-FTN-KLUDGE - not sure what this is?
};
//
// Various kludge tags defined by QWKE, etc.
// See the following:
// - ftp://vert.synchro.net/main/BBS/qwke.txt
// - http://wiki.synchro.net/ref:qwk
//
const Kludges = {
// QWKE
To: 'To:',
From: 'From:',
Subject: 'Subject:',
// Synchronet
Via: '@VIA:',
MsgID: '@MSGID:',
Reply: '@REPLY:',
TZ: '@TZ:', // https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h
ReplyTo: '@REPLYTO:',
// :TODO: Look into other non-standards
// https://github.com/wmcbrine/MultiMail/blob/master/mmail/qwk.cc
// title, @subject, etc.
};
let blockCount = 0;
let currMessage = {};
let state;
let messageBlocksRemain;
const buffer = Buffer.alloc(QWKMessageBlockSize);
const readNextBlock = () => {
fs.read(fd, buffer, 0, QWKMessageBlockSize, null, (err, read) => {
if (err) {
return cb(err);
}
if (0 == read) {
// we're done consuming all blocks
return fs.close(fd, err => {
return cb(err);
});
}
if (QWKMessageBlockSize !== read) {
return cb(
Errors.Invalid(
`Invalid QWK message block size. Expected ${QWKMessageBlockSize} got ${read}`
)
);
}
if (0 === blockCount) {
// first 128 bytes is a space padded ID
const id = buffer.toString('ascii').trim();
this.emit('creator', id);
state = 'header';
} else {
switch (state) {
case 'header':
{
const header = MessageHeaderParser.parse(buffer);
encoding = encodingToSpec; // reset per message
// massage into something a little more sane (things we can't quite do in the parser directly)
['toName', 'fromName', 'subject'].forEach(field => {
// note: always use to-spec encoding here
header[field] = iconv
.decode(header[field], encodingToSpec)
.trim();
});
header.timestamp = moment(
header.timestamp,
QWKHeaderTimestampFormat
);
currMessage = {
header,
// these may be overridden
toName: header.toName,
fromName: header.fromName,
subject: header.subject,
};
if (_.has(this.packetInfo, 'headers.ini')) {
// Sections for a message in HEADERS.DAT are by current byte offset.
// 128 = first message header = 0x80 = section [80]
const headersSectionId = (
blockCount * QWKMessageBlockSize
).toString(16);
currMessage.headersExtension =
this.packetInfo.headers.ini[headersSectionId];
}
// if we have HEADERS.DAT with a 'Utf8' override for this message,
// the overridden to/from/subject/message fields are UTF-8
if (
currMessage.headersExtension &&
'true' ===
currMessage.headersExtension.Utf8.toLowerCase()
) {
encoding = 'utf8';
}
// remainder of blocks until the end of this message
messageBlocksRemain = header.numBlocks - 1;
state = 'message';
}
break;
case 'message':
if (!currMessage.body) {
currMessage.body = Buffer.from(buffer);
} else {
currMessage.body = Buffer.concat([
currMessage.body,
buffer,
]);
}
messageBlocksRemain -= 1;
if (0 === messageBlocksRemain) {
// 1:n buffers to make up body. Decode:
// First, replace QWK style line feeds (0xe3) unless the message is UTF-8.
// If the message is UTF-8, we assume it's using standard line feeds.
if (encoding !== 'utf8') {
replaceCharInBuffer(
currMessage.body,
QWKLF,
0x0a
);
}
//
// Decode the message based on our final message encoding. Split the message
// into lines so we can extract various bits such as QWKE headers, origin, tear
// lines, etc.
//
const messageLines = splitTextAtTerms(
iconv.decode(currMessage.body, encoding).trimEnd()
);
const bodyLines = [];
let bodyState = 'kludge';
const MessageTrailers = {
// While technically FTN oriented, these can come from any network
// (though we'll be processing a lot of messages that routed through FTN
// at some point)
Origin: /^[ ]{1,2}\* Origin: /,
Tear: /^--- /,
};
const qwkKludge = {};
const ftnProperty = {};
const ftnKludge = {};
messageLines.forEach(line => {
if (0 === line.length) {
return bodyLines.push('');
}
switch (bodyState) {
case 'kludge':
// :TODO: Update these to use the well known consts:
if (line.startsWith(Kludges.To)) {
currMessage.toName = line
.substring(Kludges.To.length)
.trim();
} else if (
line.startsWith(Kludges.From)
) {
currMessage.fromName = line
.substring(Kludges.From.length)
.trim();
} else if (
line.startsWith(Kludges.Subject)
) {
currMessage.subject = line
.substring(Kludges.Subject.length)
.trim();
} else if (line.startsWith(Kludges.Via)) {
qwkKludge['@VIA'] = line;
} else if (
line.startsWith(Kludges.MsgID)
) {
qwkKludge['@MSGID'] = line
.substring(Kludges.MsgID.length)
.trim();
} else if (
line.startsWith(Kludges.Reply)
) {
qwkKludge['@REPLY'] = line
.substring(Kludges.Reply.length)
.trim();
} else if (line.startsWith(Kludges.TZ)) {
qwkKludge['@TZ'] = line
.substring(Kludges.TZ.length)
.trim();
} else if (
line.startsWith(Kludges.ReplyTo)
) {
qwkKludge['@REPLYTO'] = line
.substring(Kludges.ReplyTo.length)
.trim();
} else {
bodyState = 'body'; // past this point and up to any tear/origin/etc., is the real message body
bodyLines.push(line);
}
break;
case 'body':
case 'trailers':
if (MessageTrailers.Origin.test(line)) {
ftnProperty.ftn_origin = line;
bodyState = 'trailers';
} else if (
MessageTrailers.Tear.test(line)
) {
ftnProperty.ftn_tear_line = line;
bodyState = 'trailers';
} else if ('body' === bodyState) {
bodyLines.push(line);
}
}
});
let messageTimestamp = currMessage.header.timestamp;
// HEADERS.DAT support.
let useTZKludge = true;
if (currMessage.headersExtension) {
const ext = currMessage.headersExtension;
// to and subject can be overridden yet again if entries are present
currMessage.toName = ext.To || currMessage.toName;
currMessage.subject =
ext.Subject || currMessage.subject;
currMessage.from =
ext.Sender || currMessage.fromName; // why not From? Who the fuck knows.
// possibly override message ID kludge
qwkKludge['@MSGID'] =
ext['Message-ID'] || qwkKludge['@MSGID'];
// WhenWritten contains a ISO-8601-ish timestamp and a Synchronet/SMB style TZ offset:
// 20180101174837-0600 4168
// We can use this to get a very slightly better precision on the timestamp (addition of seconds)
// over the headers value. Why not milliseconds? Who the fuck knows.
if (ext.WhenWritten) {
const whenWritten = moment(
ext.WhenWritten,
'YYYYMMDDHHmmssZ'
);
if (whenWritten.isValid()) {
messageTimestamp = whenWritten;
useTZKludge = false;
}
}
if (ext.Tags) {
currMessage.hashTags =
ext.Tags.toString().split(' ');
}
// FTN style properties/kludges represented as X-FTN-XXXX
for (let [extName, propName] of Object.entries(
FTNPropertyMapping
)) {
const v = ext[extName];
if (v) {
ftnProperty[propName] = v;
}
}
for (let [extName, kludgeName] of Object.entries(
FTNKludgeMapping
)) {
const v = ext[extName];
if (v) {
ftnKludge[kludgeName] = v;
}
}
}
const message = new Message({
toUserName: currMessage.toName,
fromUserName: currMessage.fromName,
subject: currMessage.subject,
modTimestamp: messageTimestamp,
message: bodyLines.join('\n'),
hashTags: currMessage.hashTags,
});
// Indicate this message was imported from a QWK packet
message.meta.System[
Message.SystemMetaNames.ExternalFlavor
] = Message.AddressFlavor.QWK;
if (!_.isEmpty(qwkKludge)) {
message.meta.QwkKludge = qwkKludge;
}
if (!_.isEmpty(ftnProperty)) {
message.meta.FtnProperty = ftnProperty;
}
if (!_.isEmpty(ftnKludge)) {
message.meta.FtnKludge = ftnKludge;
}
// Add in tear line and origin if requested
if (this.options.keepTearAndOrigin) {
if (ftnProperty.ftn_tear_line) {
message.message += `\r\n${ftnProperty.ftn_tear_line}\r\n`;
}
if (ftnProperty.ftn_origin) {
message.message += `${ftnProperty.ftn_origin}\r\n`;
}
}
// Update the timestamp if we have a valid TZ
if (useTZKludge && qwkKludge['@TZ']) {
const tzOffset =
SMBTZToUTCOffset[qwkKludge['@TZ']];
if (tzOffset) {
message.modTimestamp.utcOffset(tzOffset);
}
}
message.meta.QwkProperty = {
qwk_msg_status: currMessage.header.status,
qwk_in_reply_to_num:
currMessage.header.replyToNum,
};
if (this.options.mode === QWKPacketReader.Modes.QWK) {
message.meta.QwkProperty.qwk_msg_num =
currMessage.header.num;
message.meta.QwkProperty.qwk_conf_num =
currMessage.header.confNum;
} else {
// For REP's, prefer the larger field.
message.meta.QwkProperty.qwk_conf_num =
currMessage.header.num ||
currMessage.header.confNum;
}
// Another quick HEADERS.DAT fix-up
if (currMessage.headersExtension) {
message.meta.QwkProperty.qwk_conf_num =
currMessage.headersExtension.Conference ||
message.meta.QwkProperty.qwk_conf_num;
}
this.emit('message', message);
state = 'header';
}
break;
}
}
++blockCount;
readNextBlock();
});
};
// start reading blocks
readNextBlock();
});
}
}
class QWKPacketWriter extends EventEmitter {
constructor(
{
mode = QWKPacketWriter.Modes.User,
enableQWKE = true,
enableHeadersExtension = true,
enableAtKludges = true,
systemDomain = 'enigma-bbs',
bbsID = 'ENIGMA',
user = null,
archiveFormat = 'application/zip',
forceEncoding = null,
} = QWKPacketWriter.DefaultOptions
) {
super();
this.options = {
mode,
enableQWKE,
enableHeadersExtension,
enableAtKludges,
systemDomain,
bbsID,
user,
archiveFormat,
forceEncoding: forceEncoding ? forceEncoding.toLowerCase() : null,
};
this.temptmp = temptmp.createTrackedSession('qwkpacketwriter');
this.areaTagConfMap = {};
}
static get DefaultOptions() {
return {
mode: QWKPacketWriter.Modes.User,
enableQWKE: true,
enableHeadersExtension: true,
enableAtKludges: true,
systemDomain: 'enigma-bbs',
bbsID: 'ENIGMA',
user: null,
archiveFormat: 'application/zip',
forceEncoding: null,
};
}
static get Modes() {
return {
User: 'user', // creation of a packet for a user (non-network); non-mapped confs allowed
Network: 'network', // creation of a packet for QWK network
};
}
init() {
async.series(
[
callback => {
return StatLog.init(callback);
},
callback => {
this.temptmp.mkdir({ prefix: 'enigqwkwriter-' }, (err, workDir) => {
this.workDir = workDir;
return callback(err);
});
},
callback => {
//
// Prepare areaTag -> conference number mapping:
// - In User mode, areaTags's that are not explicitly configured
// will have their conference number auto-generated.
// - In Network mode areaTags's missing a configuration will not
// be mapped, and thus skipped.
//
const configuredAreas = _.get(Config(), 'messageNetworks.qwk.areas');
if (configuredAreas) {
Object.keys(configuredAreas).forEach(areaTag => {
const confNumber = configuredAreas[areaTag].conference;
if (confNumber) {
this.areaTagConfMap[areaTag] = confNumber;
}
});
}
if (this.options.mode === QWKPacketWriter.Modes.User) {
// All the rest
// Start at 1000 to work around what seems to be a bug with some readers
let confNumber = 1000;
const usedConfNumbers = new Set(
Object.values(this.areaTagConfMap)
);
getAllAvailableMessageAreaTags().forEach(areaTag => {
if (this.areaTagConfMap[areaTag]) {
return;
}
while (
confNumber < 10001 &&
usedConfNumbers.has(confNumber)
) {
++confNumber;
}
// we can go up to 65535 for some things, but NDX files are limited to 9999
if (confNumber === 10000) {
// sanity...
this.emit(
'warning',
Errors.General('To many conferences (over 9999)')
);
} else {
this.areaTagConfMap[areaTag] = confNumber;
++confNumber;
}
});
}
return callback(null);
},
callback => {
this.messagesStream = fs.createWriteStream(
paths.join(this.workDir, 'messages.dat')
);
if (this.options.enableHeadersExtension) {
this.headersDatStream = fs.createWriteStream(
paths.join(this.workDir, 'headers.dat')
);
}
// First block is a space padded ID
const id = `Created with ENiGMA 1/2 BBS v${enigmaVersion} Copyright (c) 2015-2022 Bryan Ashby`;
this.messagesStream.write(
id.padEnd(QWKMessageBlockSize, ' '),
'ascii'
);
this.currentMessageOffset = QWKMessageBlockSize;
this.totalMessages = 0;
this.areaTagsSeen = new Set();
this.personalIndex = []; // messages addressed to 'user'
this.inboxIndex = []; // private messages for 'user'
this.publicIndex = new Map();
return callback(null);
},
],
err => {
if (err) {
return this.emit('error', err);
}
this.emit('ready');
}
);
}
makeMessageIdentifier(message) {
return `<${message.messageId}.${message.messageUuid}@${this.options.systemDomain}>`;
}
_encodeWithFallback(s, encoding) {
try {
return iconv.encode(s, encoding);
} catch (e) {
this.emit(
'warning',
Errors.General(
`Failed to encode buffer using ${encoding}; Falling back to 'ascii'`
)
);
return iconv.encode(s, 'ascii');
}
}
appendMessage(message) {
//
// Each message has to:
// - Append to MESSAGES.DAT
// - Append to HEADERS.DAT if enabled
//
// If this is a personal (ie: non-network) packet:
// - Produce PERSONAL.NDX
// - Produce 000.NDX with pointers to the users personal "inbox" mail
// - Produce ####.NDX with pointers to the public/conference mail
// - Produce TOREADER.EXT if QWKE support is enabled
//
let fullMessageBody = '';
// Start of body is kludges if enabled
if (this.options.enableQWKE) {
if (message.toUserName.length > 25) {
fullMessageBody += `To: ${message.toUserName}\n`;
}
if (message.fromUserName.length > 25) {
fullMessageBody += `From: ${message.fromUserName}\n`;
}
if (message.subject.length > 25) {
fullMessageBody += `Subject: ${message.subject}\n`;
}
}
if (this.options.enableAtKludges) {
// Add in original kludges (perhaps in a different order) if
// they were originally imported
if (
Message.AddressFlavor.QWK ==
message.meta.System[Message.SystemMetaNames.ExternalFlavor]
) {
if (message.meta.QwkKludge) {
for (let [kludge, value] of Object.entries(message.meta.QwkKludge)) {
fullMessageBody += `${kludge}: ${value}\n`;
}
}
} else {
fullMessageBody += `@MSGID: ${this.makeMessageIdentifier(message)}\n`;
fullMessageBody += `@TZ: ${UTCOffsetToSMBTZ[moment().format('Z')]}\n`;
// :TODO: REPLY and REPLYTO
}
}
// Sanitize line feeds (e.g. CRLF -> LF, and possibly -> QWK style below)
splitTextAtTerms(message.message).forEach(line => {
fullMessageBody += `${line}\n`;
});
const encoding = this._getEncoding(message);
const encodedMessage = this._encodeWithFallback(fullMessageBody, encoding);
//
// QWK spec wants line feeds as 0xe3 for some reason, so we'll have
// to replace the \n's. If we're going against the spec and using UTF-8
// we can just leave them be.
//
if ('utf8' !== encoding) {
replaceCharInBuffer(encodedMessage, 0x0a, QWKLF);
}
// Messages must comprise of multiples of 128 bit blocks with the last
// block padded by spaces or nulls (we use nulls)
const fullBlocks = Math.trunc(encodedMessage.length / QWKMessageBlockSize);
const remainBytes =
QWKMessageBlockSize - (encodedMessage.length % QWKMessageBlockSize);
const totalBlocks = fullBlocks + 1 + (remainBytes ? 1 : 0);
// The first block is always a header
if (!this._writeMessageHeader(message, totalBlocks)) {
// we can't write this message
return;
}
this.messagesStream.write(encodedMessage);
if (remainBytes) {
this.messagesStream.write(Buffer.alloc(remainBytes, ' '));
}
this._updateIndexTracking(message);
if (this.options.enableHeadersExtension) {
this._appendHeadersExtensionData(message, encoding);
}
// next message starts at this block
this.currentMessageOffset += totalBlocks * QWKMessageBlockSize;
this.totalMessages += 1;
this.areaTagsSeen.add(message.areaTag);
}
_getEncoding(message) {
if (this.options.forceEncoding) {
return this.options.forceEncoding;
}
// If the system has stored an explicit encoding, use that.
let encoding = _.get(message.meta, 'System.explicit_encoding');
if (encoding) {
return encoding;
}
// If the message is already tagged with a supported encoding
// indicator such as FTN-style CHRS, try to use that.
encoding = _.get(message.meta, 'FtnKludge.CHRS');
if (encoding) {
// Convert from CHRS to something standard
encoding = getEncodingFromCharacterSetIdentifier(encoding);
if (encoding) {
return encoding;
}
}
// The to-spec default is CP437/ASCII. If it can be encoded as
// such then do so.
if (message.isCP437Encodable()) {
return 'cp437';
}
// Something more modern...
return 'utf8';
}
_messageAddressedToUser(message) {
if (_.isUndefined(this.cachedCompareNames)) {
if (this.options.user) {
this.cachedCompareNames = [this.options.user.username.toLowerCase()];
const realName = this.options.user.getProperty(UserProps.RealName);
if (realName) {
this.cachedCompareNames.push(realName.toLowerCase());
}
} else {
this.cachedCompareNames = [];
}
}
return this.cachedCompareNames.includes(message.toUserName.toLowerCase());
}
_updateIndexTracking(message) {
// index points at start of *message* not the header for... reasons?
const index = this.currentMessageOffset / QWKMessageBlockSize + 1;
if (message.isPrivate()) {
this.inboxIndex.push(index);
} else {
if (this._messageAddressedToUser(message)) {
// :TODO: add to both indexes???
this.personalIndex.push(index);
}
const areaTag = message.areaTag;
if (!this.publicIndex.has(areaTag)) {
this.publicIndex.set(areaTag, [index]);
} else {
this.publicIndex.get(areaTag).push(index);
}
}
}
appendNewFile() {}
finish(packetDirectory) {
async.series(
[
callback => {
this.messagesStream.on('close', () => {
return callback(null);
});
this.messagesStream.end();
},
callback => {
if (!this.headersDatStream) {
return callback(null);
}
this.headersDatStream.on('close', () => {
return callback(null);
});
this.headersDatStream.end();
},
callback => {
return this._createControlData(callback);
},
callback => {
return this._createIndexes(callback);
},
callback => {
return this._producePacketArchive(packetDirectory, callback);
},
],
err => {
this.temptmp.cleanup();
if (err) {
return this.emit('error', err);
}
this.emit('finished');
}
);
}
_getNextAvailPacketFileName(packetDirectory, cb) {
//
// According to http://wiki.synchro.net/ref:qwk filenames should
// start with .QWK -> .QW1 ... .QW9 -> .Q10 ... .Q99
//
let digits = 0;
async.doWhilst(
callback => {
let ext;
if (0 === digits) {
ext = 'QWK';
} else if (digits < 10) {
ext = `QW${digits}`;
} else if (digits < 100) {
ext = `Q${digits}`;
} else {
return callback(
Errors.UnexpectedState(
'Unable to choose a valid QWK output filename'
)
);
}
++digits;
const filename = `${this.options.bbsID}.${ext}`;
fs.stat(paths.join(packetDirectory, filename), err => {
if (err && 'ENOENT' === err.code) {
return callback(null, filename);
} else {
return callback(null, null);
}
});
},
(filename, callback) => {
return callback(null, filename ? false : true);
},
(err, filename) => {
return cb(err, filename);
}
);
}
_producePacketArchive(packetDirectory, cb) {
const archiveUtil = ArchiveUtil.getInstance();
fs.readdir(this.workDir, (err, files) => {
if (err) {
return cb(err);
}
this._getNextAvailPacketFileName(packetDirectory, (err, filename) => {
if (err) {
return cb(err);
}
const packetPath = paths.join(packetDirectory, filename);
archiveUtil.compressTo(
this.options.archiveFormat,
packetPath,
files,
this.workDir,
() => {
fs.stat(packetPath, (err, stats) => {
if (stats) {
this.emit('packet', { stats, path: packetPath });
}
return cb(err);
});
}
);
});
});
}
_qwkMessageStatus(message) {
// - Public vs Private
// - Look at message pointers for read status
// - If +op is exporting and this message is to +op
// -
// :TODO: this needs addressed - handle unread vs read, +op, etc.
// ....see getNewMessagesInAreaForUser(); Variant with just IDs, or just a way to get first new message ID per area?
if (message.isPrivate()) {
return QWKMessageStatusCodes.UnreadPrivate;
}
return QWKMessageStatusCodes.UnreadPublic;
}
_writeMessageHeader(message, totalBlocks) {
const asciiNum = (n, l) => {
if (isNaN(n)) {
return '';
}
return n.toString().substr(0, l);
};
const asciiTotalBlocks = asciiNum(totalBlocks, 6);
if (asciiTotalBlocks.length > 6) {
this.emit('warning', Errors.General('Message too large for packet'), message);
return false;
}
const conferenceNumber = this._getMessageConferenceNumberByAreaTag(
message.areaTag
);
if (isNaN(conferenceNumber)) {
this.emit(
'warning',
Errors.MissingConfig(
`No QWK conference mapping for areaTag ${message.areaTag}`
)
);
return false;
}
const header = Buffer.alloc(QWKMessageBlockSize, ' ');
header.write(this._qwkMessageStatus(message), 0, 1, 'ascii');
header.write(asciiNum(message.messageId), 1, 'ascii');
header.write(
message.modTimestamp.format(QWKHeaderTimestampFormat),
8,
13,
'ascii'
);
header.write(message.toUserName.substr(0, 25), 21, 'ascii');
header.write(message.fromUserName.substr(0, 25), 46, 'ascii');
header.write(message.subject.substr(0, 25), 71, 'ascii');
header.write(' '.repeat(12), 96, 'ascii'); // we don't use the password field
header.write(asciiNum(message.replyToMsgId), 108, 'ascii');
header.write(asciiTotalBlocks, 116, 'ascii');
header.writeUInt8(QWKMessageActiveStatus.Active, 122);
header.writeUInt16LE(conferenceNumber, 123);
header.writeUInt16LE(this.totalMessages + 1, 125);
header.write(QWKNetworkTagIndicator.NotPresent, 127, 1, 'ascii'); // :TODO: Present if for network output?
this.messagesStream.write(header);
return true;
}
_getMessageConferenceNumberByAreaTag(areaTag) {
if (Message.isPrivateAreaTag(areaTag)) {
return 0;
}
return this.areaTagConfMap[areaTag];
}
_getExportForUsername() {
return _.get(this.options, 'user.username', 'Any');
}
_getExportSysOpUsername() {
return StatLog.getSystemStat(SysProps.SysOpUsername) || 'SysOp';
}
_createControlData(cb) {
const areas = Array.from(this.areaTagsSeen).map(areaTag => {
if (Message.isPrivateAreaTag(areaTag)) {
return {
areaTag: Message.WellKnownAreaTags.Private,
name: 'Private',
desc: 'Private Messages',
};
}
return getMessageAreaByTag(areaTag);
});
const controlStream = fs.createWriteStream(
paths.join(this.workDir, 'control.dat')
);
controlStream.setDefaultEncoding('ascii');
controlStream.on('close', () => {
return cb(null);
});
controlStream.on('error', err => {
return cb(err);
});
const initialControlData = [
Config().general.boardName,
'Earth',
'XXX-XXX-XXX',
`${this._getExportSysOpUsername()}, Sysop`,
`0000,${this.options.bbsID}`,
moment().format('MM-DD-YYYY,HH:mm:ss'),
this._getExportForUsername(),
'', // name of Qmail menu
'0', // uh, OK
this.totalMessages.toString(),
// this next line is total conferences - 1:
// We have areaTag <> conference mapping, so the number should work out
(this.areaTagsSeen.size - 1).toString(),
];
initialControlData.forEach(line => {
controlStream.write(`${line}\r\n`);
});
// map areas as conf #\r\nDescription\r\n pairs
areas.forEach(area => {
const conferenceNumber = this._getMessageConferenceNumberByAreaTag(
area.areaTag
);
const conf = getMessageConferenceByTag(area.confTag);
const desc = `${conf.name} - ${area.name}`;
controlStream.write(`${conferenceNumber}\r\n`);
controlStream.write(`${desc}\r\n`);
});
// :TODO: do we ever care here?!
['HELLO', 'BBSNEWS', 'GOODBYE'].forEach(trailer => {
controlStream.write(`${trailer}\r\n`);
});
controlStream.end();
}
_createIndexes(cb) {
const appendIndexData = (stream, offset) => {
const msb = numToMbf32(offset);
stream.write(msb);
// technically, the conference #, but only as a byte, so pretty much useless
// AND the filename itself is the conference number... dafuq.
stream.write(Buffer.from([0x00]));
};
async.series(
[
callback => {
// Create PERSONAL.NDX
if (!this.personalIndex.length) {
return callback(null);
}
const indexStream = fs.createWriteStream(
paths.join(this.workDir, 'personal.ndx')
);
this.personalIndex.forEach(offset =>
appendIndexData(indexStream, offset)
);
indexStream.on('close', err => {
return callback(err);
});
indexStream.end();
},
callback => {
// 000.NDX of private mails
if (!this.inboxIndex.length) {
return callback(null);
}
const indexStream = fs.createWriteStream(
paths.join(this.workDir, '000.ndx')
);
this.inboxIndex.forEach(offset =>
appendIndexData(indexStream, offset)
);
indexStream.on('close', err => {
return callback(err);
});
indexStream.end();
},
callback => {
// ####.NDX
async.eachSeries(
this.publicIndex.keys(),
(areaTag, nextArea) => {
const offsets = this.publicIndex.get(areaTag);
const conferenceNumber =
this._getMessageConferenceNumberByAreaTag(areaTag);
const indexStream = fs.createWriteStream(
paths.join(
this.workDir,
`${conferenceNumber.toString().padStart(4, '0')}.ndx`
)
);
offsets.forEach(offset =>
appendIndexData(indexStream, offset)
);
indexStream.on('close', err => {
return nextArea(err);
});
indexStream.end();
},
err => {
return callback(err);
}
);
},
],
err => {
return cb(err);
}
);
}
_makeSynchronetTimestamp(ts) {
const syncTimestamp = ts.format('YYYYMMDDHHmmssZZ');
const syncTZ = UTCOffsetToSMBTZ[ts.format('Z')] || '0000'; // :TODO: what if we don't have a map?
return `${syncTimestamp} ${syncTZ}`;
}
_appendHeadersExtensionData(message, encoding) {
const messageData = {
// Synchronet style
Utf8: 'utf8' === encoding ? 'true' : 'false',
'Message-ID': this.makeMessageIdentifier(message),
WhenWritten: this._makeSynchronetTimestamp(message.modTimestamp),
// WhenImported : '', // :TODO: only if we have a imported time from another external system?
ExportedFrom: `${this.options.systemID} ${message.areaTag} ${message.messageId}`,
Sender: message.fromUserName,
// :TODO: if exporting for QWK-Net style/etc.
//SenderNetAddr
SenderIpAddr: '127.0.0.1', // no sir, that's private.
SenderHostName: this.options.systemDomain,
// :TODO: if exported:
//SenderProtocol
Organization: 'BBS',
//'Reply-To' : :TODO: "address to direct replies".... ?!
Subject: message.subject,
To: message.toUserName,
//ToNetAddr : :TODO: net addr to?!
// :TODO: Only set if not imported:
Tags: message.hashTags.join(' '),
// :TODO: Needs tested with Sync/etc.; Sync wants Conference *numbers*
Conference: message.isPrivate()
? '0'
: getMessageConfTagByAreaTag(message.areaTag),
// ENiGMA Headers
MessageUUID: message.messageUuid,
ModTimestamp: message.modTimestamp.format('YYYY-MM-DDTHH:mm:ss.SSSZ'),
AreaTag: message.areaTag,
};
const externalFlavor =
message.meta.System[Message.SystemMetaNames.ExternalFlavor];
if (externalFlavor === Message.AddressFlavor.FTN) {
// Add FTN properties if it came from such an origin
if (message.meta.FtnProperty) {
const ftnProp = message.meta.FtnProperty;
messageData['X-FTN-AREA'] = ftnProp[Message.FtnPropertyNames.FtnArea];
messageData['X-FTN-SEEN-BY'] =
ftnProp[Message.FtnPropertyNames.FtnSeenBy];
}
if (message.meta.FtnKludge) {
const ftnKludge = message.meta.FtnKludge;
messageData['X-FTN-PATH'] = ftnKludge.PATH;
messageData['X-FTN-MSGID'] = ftnKludge.MSGID;
messageData['X-FTN-REPLY'] = ftnKludge.REPLY;
messageData['X-FTN-PID'] = ftnKludge.PID;
messageData['X-FTN-FLAGS'] = ftnKludge.FLAGS;
messageData['X-FTN-TID'] = ftnKludge.TID;
messageData['X-FTN-CHRS'] = ftnKludge.CHRS;
}
} else {
messageData.WhenExported = this._makeSynchronetTimestamp(moment());
messageData.Editor = `ENiGMA 1/2 BBS FSE v${enigmaVersion}`;
}
this.headersDatStream.write(
this._encodeWithFallback(
`[${this.currentMessageOffset.toString(16)}]\r\n`,
encoding
)
);
for (let [name, value] of Object.entries(messageData)) {
if (value) {
this.headersDatStream.write(
this._encodeWithFallback(`${name}: ${value}\r\n`, encoding)
);
}
}
this.headersDatStream.write('\r\n');
}
}
module.exports = {
QWKPacketReader,
QWKPacketWriter,
};