enigma-bbs/core/tic_file_info.js

320 lines
10 KiB
JavaScript

/* jslint node: true */
'use strict';
// ENiGMA½
const Address = require('./ftn_address.js');
const Errors = require('./enig_error.js').Errors;
const EnigAssert = require('./enigma_assert.js');
// deps
const fs = require('graceful-fs');
const CRC32 = require('./crc.js').CRC32;
const _ = require('lodash');
const async = require('async');
const paths = require('path');
const crypto = require('crypto');
//
// Class to read and hold information from a TIC file
//
// * FTS-5006.001 @ http://www.filegate.net/ftsc/FTS-5006.001
// * FSP-1039.001 @ http://ftsc.org/docs/old/fsp-1039.001
// * FSC-0087.001 @ http://ftsc.org/docs/fsc-0087.001
//
module.exports = class TicFileInfo {
constructor() {
this.entries = new Map();
}
static get requiredFields() {
return [
'Area',
'Origin',
'From',
'File',
'Crc',
// :TODO: validate this:
//'Path', 'Seenby' // these two are questionable; some systems don't send them?
];
}
get(key) {
return this.entries.get(key.toLowerCase());
}
getAsString(key, joinWith) {
const value = this.get(key);
if (value) {
//
// We call toString() on values to ensure numbers, addresses, etc. are converted
//
joinWith = joinWith || '';
if (Array.isArray(value)) {
return value.map(v => v.toString()).join(joinWith);
}
return value.toString();
}
}
get filePath() {
return paths.join(paths.dirname(this.path), this.getAsString('File'));
}
get longFileName() {
return (
this.getAsString('Lfile') ||
this.getAsString('Fullname') ||
this.getAsString('File')
);
}
hasRequiredFields() {
const req = TicFileInfo.requiredFields;
return req.every(f => this.get(f));
}
validate(config, cb) {
// config.nodes
// config.defaultPassword (optional)
// config.localAreaTags
EnigAssert(config.nodes && config.localAreaTags);
const self = this;
async.waterfall(
[
function initial(callback) {
if (!self.hasRequiredFields()) {
return callback(
Errors.Invalid('One or more required fields missing from TIC')
);
}
const area = self.getAsString('Area').toUpperCase();
const localInfo = {
areaTag: config.localAreaTags.find(
areaTag => areaTag.toUpperCase() === area
),
};
if (!localInfo.areaTag) {
return callback(
Errors.Invalid(`No local area for "Area" of ${area}`)
);
}
const from = Address.fromString(self.getAsString('From'));
if (!from.isValid()) {
return callback(
Errors.Invalid(
`Invalid "From" address: ${self.getAsString('From')}`
)
);
}
// note that our config may have wildcards, such as "80:774/*"
localInfo.node = Object.keys(config.nodes).find(nodeAddrWildcard =>
from.isPatternMatch(nodeAddrWildcard)
);
if (!localInfo.node) {
return callback(Errors.Invalid('TIC is not from a known node'));
}
// if we require a password, "PW" must match
const passActual =
_.get(config.nodes, [localInfo.node, 'tic', 'password']) ||
config.defaultPassword;
if (!passActual) {
return callback(null, localInfo); // no pw validation
}
const passTic = self.getAsString('Pw');
if (passTic !== passActual) {
return callback(Errors.Invalid('Bad TIC password'));
}
return callback(null, localInfo);
},
function checksumAndSize(localInfo, callback) {
const crcTic = self.get('Crc');
const stream = fs.createReadStream(self.filePath);
const crc = new CRC32();
let sizeActual = 0;
let sha256Tic = self.getAsString('Sha256');
let sha256;
if (sha256Tic) {
sha256Tic = sha256Tic.toLowerCase();
sha256 = crypto.createHash('sha256');
}
stream.on('data', data => {
sizeActual += data.length;
// sha256 if possible, else crc32
if (sha256) {
sha256.update(data);
} else {
crc.update(data);
}
});
stream.on('end', () => {
// again, use sha256 if possible
if (sha256) {
const sha256Actual = sha256.digest('hex');
if (sha256Tic != sha256Actual) {
return callback(
Errors.Invalid(
`TIC "Sha256" of ${sha256Tic} does not match actual SHA-256 of ${sha256Actual}`
)
);
}
localInfo.sha256 = sha256Actual;
} else {
const crcActual = crc.finalize();
if (crcActual !== crcTic) {
return callback(
Errors.Invalid(
`TIC "Crc" of ${crcTic} does not match actual CRC-32 of ${crcActual}`
)
);
}
localInfo.crc32 = crcActual;
}
const sizeTic = self.get('Size');
if (_.isUndefined(sizeTic)) {
return callback(null, localInfo);
}
if (sizeTic !== sizeActual) {
return callback(
Errors.Invalid(
`TIC "Size" of ${sizeTic} does not match actual size of ${sizeActual}`
)
);
}
return callback(null, localInfo);
});
stream.on('error', err => {
return callback(err);
});
},
],
(err, localInfo) => {
return cb(err, localInfo);
}
);
}
isToAddress(address, allowNonExplicit) {
//
// FSP-1039.001:
// "This keyword specifies the FTN address of the system where to
// send the file to be distributed and the accompanying TIC file.
// Some File processors (Allfix) only insert a line with this
// keyword when the file and the associated TIC file are to be
// file routed through a third system instead of being processed
// by a file processor on that system. Others always insert it.
// Note that the To keyword may cause problems when the TIC file
// is processed by software that does not recognize it and
// passes the line "as is" to other systems.
//
// Example: To 292/854
//
// This is an optional keyword."
//
const to = this.get('To');
if (!to) {
return allowNonExplicit;
}
return address.isEqual(to);
}
static createFromFile(path, cb) {
fs.readFile(path, 'utf8', (err, ticData) => {
if (err) {
return cb(err);
}
const ticFileInfo = new TicFileInfo();
ticFileInfo.path = path;
//
// Lines in a TIC file should be separated by CRLF (DOS)
// may be separated by LF (UNIX)
//
const lines = ticData.split(/\r\n|\n/g);
let keyEnd;
let key;
let value;
let entry;
lines.forEach(line => {
keyEnd = line.search(/\s/);
if (keyEnd < 0) {
keyEnd = line.length;
}
key = line.substr(0, keyEnd).toLowerCase();
if (0 === key.length) {
return;
}
value = line.substr(keyEnd + 1);
// don't trim Ldesc; may mess with FILE_ID.DIZ type descriptions
if ('ldesc' !== key) {
value = value.trim();
}
// convert well known keys to a more reasonable format
switch (key) {
case 'origin':
case 'from':
case 'seenby':
case 'to':
value = Address.fromString(value);
break;
case 'crc':
value = parseInt(value, 16);
break;
case 'size':
value = parseInt(value, 10);
break;
default:
break;
}
entry = ticFileInfo.entries.get(key);
if (entry) {
if (!Array.isArray(entry)) {
entry = [entry];
ticFileInfo.entries.set(key, entry);
}
entry.push(value);
} else {
ticFileInfo.entries.set(key, value);
}
});
return cb(null, ticFileInfo);
});
}
};