WIP: Lots of progress with QWK reader/writer
This commit is contained in:
parent
d8f0601914
commit
8a81b34ed0
|
@ -49,7 +49,8 @@ const SYSTEM_META_NAMES = {
|
||||||
const ADDRESS_FLAVOR = {
|
const ADDRESS_FLAVOR = {
|
||||||
Local : 'local', // local / non-remote addressing
|
Local : 'local', // local / non-remote addressing
|
||||||
FTN : 'ftn', // FTN style
|
FTN : 'ftn', // FTN style
|
||||||
Email : 'email',
|
Email : 'email', // From email
|
||||||
|
QWK : 'qwk', // QWK packet
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATE_FLAGS0 = {
|
const STATE_FLAGS0 = {
|
||||||
|
@ -94,14 +95,6 @@ const QWKPropertyNames = {
|
||||||
InReplyToNum : 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available
|
InReplyToNum : 'qwk_in_reply_to_num', // note that we prefer the 'InReplyToMsgId' kludge if available
|
||||||
};
|
};
|
||||||
|
|
||||||
const QWKKludgeNames = {
|
|
||||||
Via : 'via',
|
|
||||||
MessageId : 'msg_id',
|
|
||||||
InReplyToMsgId : 'in_reply_to_msg_id',
|
|
||||||
SyncTZ : 'synchronet_timezone',
|
|
||||||
ReplyTo : 'reply_to',
|
|
||||||
};
|
|
||||||
|
|
||||||
// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)!
|
// :TODO: this is a ugly hack due to bad variable names - clean it up & just _.camelCase(k)!
|
||||||
const MESSAGE_ROW_MAP = {
|
const MESSAGE_ROW_MAP = {
|
||||||
reply_to_message_id : 'replyToMsgId',
|
reply_to_message_id : 'replyToMsgId',
|
||||||
|
|
|
@ -29,6 +29,7 @@ exports.getDefaultMessageAreaTagByConfTag = getDefaultMessageAreaTagByConfTag;
|
||||||
exports.getSuitableMessageConfAndAreaTags = getSuitableMessageConfAndAreaTags;
|
exports.getSuitableMessageConfAndAreaTags = getSuitableMessageConfAndAreaTags;
|
||||||
exports.getMessageConferenceByTag = getMessageConferenceByTag;
|
exports.getMessageConferenceByTag = getMessageConferenceByTag;
|
||||||
exports.getMessageAreaByTag = getMessageAreaByTag;
|
exports.getMessageAreaByTag = getMessageAreaByTag;
|
||||||
|
exports.getMessageConfTagByAreaTag = getMessageConfTagByAreaTag;
|
||||||
exports.changeMessageConference = changeMessageConference;
|
exports.changeMessageConference = changeMessageConference;
|
||||||
exports.changeMessageArea = changeMessageArea;
|
exports.changeMessageArea = changeMessageArea;
|
||||||
exports.hasMessageConfAndAreaRead = hasMessageConfAndAreaRead;
|
exports.hasMessageConfAndAreaRead = hasMessageConfAndAreaRead;
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
|
|
||||||
|
|
||||||
const ArchiveUtil = require('./archive_util');
|
const ArchiveUtil = require('./archive_util');
|
||||||
const { Errors } = require('./enig_error');
|
const { Errors } = require('./enig_error');
|
||||||
const Message = require('./message');
|
const Message = require('./message');
|
||||||
const { splitTextAtTerms } = require('./string_util');
|
const { splitTextAtTerms } = require('./string_util');
|
||||||
|
const { getMessageConfTagByAreaTag } = require('./message_area');
|
||||||
|
const StatLog = require('./stat_log');
|
||||||
|
const Config = require('./config').get;
|
||||||
|
const SysProps = require('./system_property');
|
||||||
|
|
||||||
const { EventEmitter } = require('events');
|
const { EventEmitter } = require('events');
|
||||||
const temptmp = require('temptmp').createTrackedSession('qwk_packet');
|
const temptmp = require('temptmp');
|
||||||
const async = require('async');
|
const async = require('async');
|
||||||
const fs = require('graceful-fs');
|
const fs = require('graceful-fs');
|
||||||
const paths = require('path');
|
const paths = require('path');
|
||||||
|
@ -16,27 +18,64 @@ const moment = require('moment');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const IniConfigParser = require('ini-config-parser');
|
const IniConfigParser = require('ini-config-parser');
|
||||||
|
|
||||||
const SMBTZToUTCOffset = (smbTZ) => {
|
const enigmaVersion = require('../package.json').version;
|
||||||
// convert a Synchronet smblib TZ to a UTC offset
|
|
||||||
// see https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h
|
|
||||||
return {
|
|
||||||
// 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
|
// Synchronet smblib TZ to a UTC offset
|
||||||
// :TODO: FINISH ME!
|
// 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
|
||||||
|
|
||||||
}[smbTZ];
|
// 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 QWKMessageBlockSize = 128;
|
const UTCOffsetToSMBTZ = _.invert(SMBTZToUTCOffset);
|
||||||
|
|
||||||
|
const QWKMessageBlockSize = 128;
|
||||||
|
const QWKHeaderTimestampFormat = 'MM-DD-YYHH:mm';
|
||||||
|
const QWKLF = 0xe3;
|
||||||
|
|
||||||
// See the following:
|
// See the following:
|
||||||
// - http://fileformats.archiveteam.org/wiki/QWK
|
// - http://fileformats.archiveteam.org/wiki/QWK
|
||||||
|
@ -95,14 +134,29 @@ const MessageHeaderParser = new Parser()
|
||||||
.uint16('relNum')
|
.uint16('relNum')
|
||||||
.uint8('netTag');
|
.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 {
|
class QWKPacketReader extends EventEmitter {
|
||||||
constructor(packetPath, mode=QWKPacketReader.Modes.Guess, options = { keepTearAndOrigin : true } ) {
|
constructor(
|
||||||
|
packetPath,
|
||||||
|
{ mode = QWKPacketReader.Modes.Guess, keepTearAndOrigin = true } = { mode : QWKPacketReader.Modes.Guess, keepTearAndOrigin : true })
|
||||||
|
{
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.packetPath = packetPath;
|
this.packetPath = packetPath;
|
||||||
this.mode = mode;
|
this.options = { mode, keepTearAndOrigin };
|
||||||
this.options = options;
|
this.temptmp = temptmp.createTrackedSession('qwkpacketreader');
|
||||||
}
|
}
|
||||||
|
|
||||||
static get Modes() {
|
static get Modes() {
|
||||||
|
@ -137,7 +191,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
},
|
},
|
||||||
// create a temporary location to do processing
|
// create a temporary location to do processing
|
||||||
(archiveType, callback) => {
|
(archiveType, callback) => {
|
||||||
temptmp.mkdir( { prefix : 'enigqwkpacket-'}, (err, tempDir) => {
|
this.temptmp.mkdir( { prefix : 'enigqwkreader-'}, (err, tempDir) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
}
|
}
|
||||||
|
@ -172,20 +226,20 @@ class QWKPacketReader extends EventEmitter {
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'MESSAGES.DAT' : // QWK
|
case 'MESSAGES.DAT' : // QWK
|
||||||
if (this.mode === QWKPacketReader.Modes.Guess) {
|
if (this.options.mode === QWKPacketReader.Modes.Guess) {
|
||||||
this.mode = QWKPacketReader.Modes.QWK;
|
this.options.mode = QWKPacketReader.Modes.QWK;
|
||||||
}
|
}
|
||||||
if (this.mode === QWKPacketReader.Modes.QWK) {
|
if (this.options.mode === QWKPacketReader.Modes.QWK) {
|
||||||
out.messages = { filename };
|
out.messages = { filename };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ID.MSG' :
|
case 'ID.MSG' :
|
||||||
if (this.mode === QWKPacketReader.Modes.Guess) {
|
if (this.options.mode === QWKPacketReader.Modes.Guess) {
|
||||||
this.mode = Modes.REP;
|
this.options.mode = Modes.REP;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mode === QWKPacketReader.Modes.REP) {
|
if (this.options.mode === QWKPacketReader.Modes.REP) {
|
||||||
out.messages = { filename };
|
out.messages = { filename };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -258,12 +312,9 @@ class QWKPacketReader extends EventEmitter {
|
||||||
(callback) => {
|
(callback) => {
|
||||||
return this.processPacketFiles(callback);
|
return this.processPacketFiles(callback);
|
||||||
},
|
},
|
||||||
(tempDir, callback) => {
|
|
||||||
return callback(null);
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
err => {
|
err => {
|
||||||
temptmp.cleanup();
|
this.temptmp.cleanup();
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
return this.emit('error', err);
|
return this.emit('error', err);
|
||||||
|
@ -386,14 +437,19 @@ class QWKPacketReader extends EventEmitter {
|
||||||
const path = paths.join(this.packetInfo.tempDir, this.packetInfo.headers.filename);
|
const path = paths.join(this.packetInfo.tempDir, this.packetInfo.headers.filename);
|
||||||
fs.readFile(path, { encoding : 'utf8' }, (err, iniData) => {
|
fs.readFile(path, { encoding : 'utf8' }, (err, iniData) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid (${err.message})`));
|
this.emit('warning', Errors.Invalid(`Problem reading HEADERS.DAT: ${err.message}`));
|
||||||
return cb(null); // non-fatal
|
return cb(null); // non-fatal
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.packetInfo.headers.ini = IniConfigParser.parse(iniData);
|
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) {
|
} catch (e) {
|
||||||
this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid (${e.message})`));
|
this.emit('warning', Errors.Invalid(`HEADERS.DAT file appears to be invalid: ${e.message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
return cb(null);
|
return cb(null);
|
||||||
|
@ -401,7 +457,6 @@ class QWKPacketReader extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
readMessages(cb) {
|
readMessages(cb) {
|
||||||
// :TODO: update to use proper encoding: if headers.dat specifies UTF-8, use that, else CP437
|
|
||||||
if (!this.packetInfo.messages) {
|
if (!this.packetInfo.messages) {
|
||||||
return cb(Errors.DoesNotExist('No messages file found within QWK packet'));
|
return cb(Errors.DoesNotExist('No messages file found within QWK packet'));
|
||||||
}
|
}
|
||||||
|
@ -420,7 +475,6 @@ class QWKPacketReader extends EventEmitter {
|
||||||
const FTNPropertyMapping = {
|
const FTNPropertyMapping = {
|
||||||
'X-FTN-AREA' : Message.FtnPropertyNames.FtnArea,
|
'X-FTN-AREA' : Message.FtnPropertyNames.FtnArea,
|
||||||
'X-FTN-SEEN-BY' : Message.FtnPropertyNames.FtnSeenBy,
|
'X-FTN-SEEN-BY' : Message.FtnPropertyNames.FtnSeenBy,
|
||||||
'X-FTN-FLAGS' : Message.FtnPropertyNames
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FTNKludgeMapping = {
|
const FTNKludgeMapping = {
|
||||||
|
@ -452,6 +506,10 @@ class QWKPacketReader extends EventEmitter {
|
||||||
Reply : '@REPLY:',
|
Reply : '@REPLY:',
|
||||||
TZ : '@TZ:', // https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h
|
TZ : '@TZ:', // https://github.com/kvadevack/synchronet/blob/master/src/smblib/smbdefs.h
|
||||||
ReplyTo : '@REPLYTO:',
|
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 blockCount = 0;
|
||||||
|
@ -480,7 +538,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
if (0 === blockCount) {
|
if (0 === blockCount) {
|
||||||
// first 128 bytes is a space padded ID
|
// first 128 bytes is a space padded ID
|
||||||
const id = buffer.toString('ascii').trim();
|
const id = buffer.toString('ascii').trim();
|
||||||
this.emit('generator', id);
|
this.emit('creator', id);
|
||||||
state = 'header';
|
state = 'header';
|
||||||
} else {
|
} else {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
@ -493,7 +551,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
header[field] = iconv.decode(header[field], encodingToSpec).trim();
|
header[field] = iconv.decode(header[field], encodingToSpec).trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
header.timestamp = moment(header.timestamp, 'MM-DD-YYHH:mm');
|
header.timestamp = moment(header.timestamp, QWKHeaderTimestampFormat);
|
||||||
|
|
||||||
currMessage = {
|
currMessage = {
|
||||||
header,
|
header,
|
||||||
|
@ -523,7 +581,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
|
|
||||||
case 'message' :
|
case 'message' :
|
||||||
if (!currMessage.body) {
|
if (!currMessage.body) {
|
||||||
currMessage.body = buffer;
|
currMessage.body = Buffer.from(buffer);
|
||||||
} else {
|
} else {
|
||||||
currMessage.body = Buffer.concat([currMessage.body, buffer]);
|
currMessage.body = Buffer.concat([currMessage.body, buffer]);
|
||||||
}
|
}
|
||||||
|
@ -534,16 +592,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
// First, replace QWK style line feeds (0xe3) unless the message is UTF-8.
|
// 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 the message is UTF-8, we assume it's using standard line feeds.
|
||||||
if (encoding !== 'utf8') {
|
if (encoding !== 'utf8') {
|
||||||
let i = 0;
|
replaceCharInBuffer(currMessage.body, QWKLF, 0x0a);
|
||||||
const QWKLF = Buffer.from([0xe3]);
|
|
||||||
while (i < currMessage.body.length) {
|
|
||||||
i = currMessage.body.indexOf(QWKLF, i);
|
|
||||||
if (-1 === i) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
currMessage.body[i] = 0x0a;
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
@ -583,15 +632,15 @@ class QWKPacketReader extends EventEmitter {
|
||||||
} else if (line.startsWith(Kludges.Subject)) {
|
} else if (line.startsWith(Kludges.Subject)) {
|
||||||
currMessage.subject = line.substring(Kludges.Subject.length).trim();
|
currMessage.subject = line.substring(Kludges.Subject.length).trim();
|
||||||
} else if (line.startsWith(Kludges.Via)) {
|
} else if (line.startsWith(Kludges.Via)) {
|
||||||
qwkKludge.via = line;
|
qwkKludge['@VIA'] = line;
|
||||||
} else if (line.startsWith(Kludges.MsgID)) {
|
} else if (line.startsWith(Kludges.MsgID)) {
|
||||||
qwkKludge.msg_id = line.substring(Kludges.MsgID.length).trim();
|
qwkKludge['@MSGID'] = line.substring(Kludges.MsgID.length).trim();
|
||||||
} else if (line.startsWith(Kludges.Reply)) {
|
} else if (line.startsWith(Kludges.Reply)) {
|
||||||
qwkKludge.in_reply_to_msg_id = line.substring(Kludges.Reply.length).trim();
|
qwkKludge['@REPLY'] = line.substring(Kludges.Reply.length).trim();
|
||||||
} else if (line.startsWith(Kludges.TZ)) {
|
} else if (line.startsWith(Kludges.TZ)) {
|
||||||
qwkKludge.synchronet_timezone = line.substring(Kludges.TZ.length).trim();
|
qwkKludge['@TZ'] = line.substring(Kludges.TZ.length).trim();
|
||||||
} else if (line.startsWith(Kludges.ReplyTo)) {
|
} else if (line.startsWith(Kludges.ReplyTo)) {
|
||||||
qwkKludge.reply_to = line.substring(Kludges.ReplyTo.length).trim();
|
qwkKludge['@REPLYTO'] = line.substring(Kludges.ReplyTo.length).trim();
|
||||||
} else {
|
} else {
|
||||||
bodyState = 'body'; // past this point and up to any tear/origin/etc., is the real message body
|
bodyState = 'body'; // past this point and up to any tear/origin/etc., is the real message body
|
||||||
bodyLines.push(line);
|
bodyLines.push(line);
|
||||||
|
@ -620,12 +669,12 @@ class QWKPacketReader extends EventEmitter {
|
||||||
const ext = currMessage.headersExtension;
|
const ext = currMessage.headersExtension;
|
||||||
|
|
||||||
// to and subject can be overridden yet again if entries are present
|
// to and subject can be overridden yet again if entries are present
|
||||||
currMessage.toName = ext.To || currMessage.toName
|
currMessage.toName = ext.To || currMessage.toName;
|
||||||
currMessage.subject = ext.Subject || currMessage.subject;
|
currMessage.subject = ext.Subject || currMessage.subject;
|
||||||
currMessage.from = ext.Sender || currMessage.from; // why not From? Who the fuck knows.
|
currMessage.from = ext.Sender || currMessage.fromName; // why not From? Who the fuck knows.
|
||||||
|
|
||||||
// possibly override message ID kludge
|
// possibly override message ID kludge
|
||||||
qwkKludge.msg_id = ext['Message-ID'] || qwkKludge.msg_id;
|
qwkKludge['@MSGID'] = ext['Message-ID'] || qwkKludge['@MSGID'];
|
||||||
|
|
||||||
// WhenWritten contains a ISO-8601-ish timestamp and a Synchronet/SMB style TZ offset:
|
// WhenWritten contains a ISO-8601-ish timestamp and a Synchronet/SMB style TZ offset:
|
||||||
// 20180101174837-0600 4168
|
// 20180101174837-0600 4168
|
||||||
|
@ -640,7 +689,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ext.Tags) {
|
if (ext.Tags) {
|
||||||
currMessage.hashTags = ext.Tags.split(' ');
|
currMessage.hashTags = (ext.Tags).toString().split(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// FTN style properties/kludges represented as X-FTN-XXXX
|
// FTN style properties/kludges represented as X-FTN-XXXX
|
||||||
|
@ -668,6 +717,9 @@ class QWKPacketReader extends EventEmitter {
|
||||||
hashTags : currMessage.hashTags,
|
hashTags : currMessage.hashTags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Indicate this message was imported from a QWK packet
|
||||||
|
message.meta.System[Message.SystemMetaNames.ExternalFlavor] = Message.AddressFlavor.QWK;
|
||||||
|
|
||||||
if (!_.isEmpty(qwkKludge)) {
|
if (!_.isEmpty(qwkKludge)) {
|
||||||
message.meta.QwkKludge = qwkKludge;
|
message.meta.QwkKludge = qwkKludge;
|
||||||
}
|
}
|
||||||
|
@ -692,8 +744,8 @@ class QWKPacketReader extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the timestamp if we have a valid TZ
|
// Update the timestamp if we have a valid TZ
|
||||||
if (useTZKludge && _.has(message, 'meta.QwkKludge.synchronet_timezone')) {
|
if (useTZKludge && qwkKludge['@TZ']) {
|
||||||
const tzOffset = SMBTZToUTCOffset(message.meta.QwkKludge.synchronet_timezone);
|
const tzOffset = SMBTZToUTCOffset[qwkKludge['@TZ']];
|
||||||
if (tzOffset) {
|
if (tzOffset) {
|
||||||
message.modTimestamp.utcOffset(tzOffset);
|
message.modTimestamp.utcOffset(tzOffset);
|
||||||
}
|
}
|
||||||
|
@ -704,7 +756,7 @@ class QWKPacketReader extends EventEmitter {
|
||||||
qwk_in_reply_to_num : currMessage.header.replyToNum,
|
qwk_in_reply_to_num : currMessage.header.replyToNum,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.mode === QWKPacketReader.Modes.QWK) {
|
if (this.options.mode === QWKPacketReader.Modes.QWK) {
|
||||||
message.meta.QwkProperty.qwk_msg_num = currMessage.header.num;
|
message.meta.QwkProperty.qwk_msg_num = currMessage.header.num;
|
||||||
message.meta.QwkProperty.qwk_conf_num = currMessage.header.confNum;
|
message.meta.QwkProperty.qwk_conf_num = currMessage.header.confNum;
|
||||||
} else {
|
} else {
|
||||||
|
@ -735,7 +787,379 @@ class QWKPacketReader extends EventEmitter {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class QWKPacketWriter extends EventEmitter {
|
||||||
|
constructor(
|
||||||
|
{
|
||||||
|
enableQWKE = true,
|
||||||
|
enableHeadersExtension = true,
|
||||||
|
enableAtKludges = true,
|
||||||
|
encoding = 'cp437',
|
||||||
|
systemDomain = 'enigma-bbs',
|
||||||
|
bbsID = '',
|
||||||
|
toUser = '',
|
||||||
|
} = QWKPacketWriter.DefaultOptions)
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
enableQWKE,
|
||||||
|
enableHeadersExtension,
|
||||||
|
enableAtKludges,
|
||||||
|
systemDomain,
|
||||||
|
bbsID,
|
||||||
|
toUser,
|
||||||
|
encoding : encoding.toLowerCase(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.temptmp = temptmp.createTrackedSession('qwkpacketwriter');
|
||||||
|
}
|
||||||
|
|
||||||
|
static get DefaultOptions() {
|
||||||
|
return {
|
||||||
|
enableQWKE : true,
|
||||||
|
enableHeadersExtension : true,
|
||||||
|
enableAtKludges : true,
|
||||||
|
encoding : 'cp437',
|
||||||
|
systemDomain : 'enigma-bbs',
|
||||||
|
bbsID : '',
|
||||||
|
toUser : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
async.series(
|
||||||
|
[
|
||||||
|
(callback) => {
|
||||||
|
return StatLog.init(callback);
|
||||||
|
},
|
||||||
|
(callback) => {
|
||||||
|
this.temptmp.mkdir( { prefix : 'enigqwkwriter-'}, (err, workDir) => {
|
||||||
|
this.workDir = workDir;
|
||||||
|
return callback(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(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-2020 Bryan Ashby`;
|
||||||
|
this.messagesStream.write(id.padEnd(QWKMessageBlockSize, ' '), 'ascii');
|
||||||
|
this.currentMessageOffset = QWKMessageBlockSize;
|
||||||
|
|
||||||
|
this.totalMessages = 0;
|
||||||
|
this.areaTagsSeen = new Set();
|
||||||
|
|
||||||
|
return callback(null);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
err => {
|
||||||
|
if (err) {
|
||||||
|
return this.emit('error', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
makeMessageIdentifier(message) {
|
||||||
|
return `<${message.messageId}.${message.messageUuid}@${this.options.systemDomain}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual message contents
|
||||||
|
fullMessageBody += message.message;
|
||||||
|
|
||||||
|
// :TODO: sanitize line feeds -> \n ????
|
||||||
|
|
||||||
|
// splitTextAtTerms(message.message).forEach(line => {
|
||||||
|
// appendBodyLine(line);
|
||||||
|
// });
|
||||||
|
|
||||||
|
const encodedMessage = iconv.encode(fullMessageBody, this.options.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' !== this.options.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);
|
||||||
|
|
||||||
|
// The first block is always a header
|
||||||
|
this._writeMessageHeader(
|
||||||
|
message,
|
||||||
|
fullBlocks + 1 + (remainBytes ? 1 : 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.messagesStream.write(encodedMessage);
|
||||||
|
|
||||||
|
|
||||||
|
if (remainBytes) {
|
||||||
|
this.messagesStream.write(Buffer.alloc(remainBytes, 0x00));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.enableHeadersExtension) {
|
||||||
|
this._appendHeadersExtensionData(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentMessageOffset += fullBlocks * QWKMessageBlockSize;
|
||||||
|
|
||||||
|
if (remainBytes)
|
||||||
|
{
|
||||||
|
this.currentMessageOffset += QWKMessageBlockSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.totalMessages += 1;
|
||||||
|
this.areaTagsSeen.add(message.areaTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendNewFile() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(packetPath) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
],
|
||||||
|
err => {
|
||||||
|
this.temptmp.cleanup();
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return this.emit('error', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('finished');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeMessageHeader(message, totalBlocks) {
|
||||||
|
const asciiNum = (n, l) => {
|
||||||
|
if (isNaN(n)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return n.toString().substr(0, l);
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = 'FIXME';
|
||||||
|
const totalBlocksStr = asciiNum(totalBlocks, 6);//totalBlocks.toString().padEnd(6, ' ');
|
||||||
|
const messageStatus = 255; // :TODO: ever anything different?
|
||||||
|
const confNumber = 1004; // :TODO: areaTag -> conf mapping
|
||||||
|
const netTag = ' '; // :TODO:
|
||||||
|
|
||||||
|
if (totalBlocksStr.length > 6) {
|
||||||
|
return this.emit('warning', Errors.General('Message too large for packet'), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = Buffer.alloc(QWKMessageBlockSize, ' ');
|
||||||
|
header.write(status[0], 0, 1, 'ascii');
|
||||||
|
header.write(asciiNum(message.messageId), 1, 'ascii'); // :TODO: It seems Sync puts the relative, as in # of messages we've called appendMessage()?!
|
||||||
|
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(asciiNum(totalBlocks, 6), 116, 'ascii');
|
||||||
|
header.writeUInt8(messageStatus, 122);
|
||||||
|
header.writeUInt16LE(confNumber, 123);
|
||||||
|
header.writeUInt16LE(0, 125); // :TODO: Check if others actually populate this
|
||||||
|
header.write(netTag[0], 127, 1, 'ascii');
|
||||||
|
|
||||||
|
this.messagesStream.write(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createControlData(cb) {
|
||||||
|
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 controlData = [
|
||||||
|
Config().general.boardName,
|
||||||
|
'Earth',
|
||||||
|
'XXX-XXX-XXX',
|
||||||
|
`${StatLog.getSystemStat(SysProps.SysOpUsername)}, Sysop`,
|
||||||
|
`0000,${this.options.bbsID}`,
|
||||||
|
moment().format('MM-DD-YYYY,HH:mm:ss'),
|
||||||
|
this.options.toUser,
|
||||||
|
'', // 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(),
|
||||||
|
|
||||||
|
// :TODO: append all areaTag->conf number/IDs and names (13 chars max)
|
||||||
|
'0', 'First Conf',
|
||||||
|
'HELLO',
|
||||||
|
'BBSNEWS',
|
||||||
|
'GOODBYE',
|
||||||
|
];
|
||||||
|
|
||||||
|
controlData.forEach(line => {
|
||||||
|
controlStream.write(`${line}\r\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
controlStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
_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) {
|
||||||
|
const messageData = {
|
||||||
|
// Synchronet style
|
||||||
|
Utf8 : ('utf8' === this.options.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 : getMessageConfTagByAreaTag(message.areaTag),
|
||||||
|
|
||||||
|
// ENiGMA Headers
|
||||||
|
MessageUUID : message.uuid,
|
||||||
|
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'] = fntProp[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'] = fntKludge.REPLY;
|
||||||
|
messageData['X-FTN-PID'] = fntKludge.PID;
|
||||||
|
messageData['X-FTN-FLAGS'] = ftnKludge.FLAGS;
|
||||||
|
messageData['X-FTN-TID'] = fntKludge.TID;
|
||||||
|
messageData['X-FTN-CHRS'] = fntKludge.CHRS;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messageData.WhenExported = this._makeSynchronetTimestamp(moment());
|
||||||
|
messageData.Editor = `ENiGMA 1/2 BBS FSE v${enigmaVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.headersDatStream.write(iconv.encode(`[${this.currentMessageOffset.toString(16)}]\r\n`, this.options.encoding));
|
||||||
|
|
||||||
|
for (let [name, value] of Object.entries(messageData)) {
|
||||||
|
if (value) {
|
||||||
|
this.headersDatStream.write(iconv.encode(`${name}: ${value}\r\n`, this.options.encoding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.headersDatStream.write('\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
QWKPacketReader,
|
QWKPacketReader,
|
||||||
// QWKPacketWriter,
|
QWKPacketWriter,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue