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-2020 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, };