/* 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 = self.getAsString('From'); localInfo.node = Object.keys(config.nodes).find( nodeAddr => Address.fromString(nodeAddr).isPatternMatch(from) ); 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 sysem 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 proecessed by software that does not recognise 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); }); } };