'CHRS: ', 'charset' => 'CHARSET: ', 'codepage' => 'CODEPAGE: ', 'msgid' => 'MSGID: ', 'pid' => 'PID: ', 'replyid' => 'REPLY: ', 'tid' => 'TID: ', 'tzutc' => 'TZUTC: ', ]; // Flags for messages public const FLAG_PRIVATE = 1<<0; public const FLAG_CRASH = 1<<1; public const FLAG_RECD = 1<<2; public const FLAG_SENT = 1<<3; public const FLAG_FILEATTACH = 1<<4; public const FLAG_INTRANSIT = 1<<5; public const FLAG_ORPHAN = 1<<6; public const FLAG_KILLSENT = 1<<7; public const FLAG_LOCAL = 1<<8; public const FLAG_HOLD = 1<<9; public const FLAG_UNUSED_10 = 1<<10; public const FLAG_FREQ = 1<<11; public const FLAG_RETRECEIPT = 1<<12; public const FLAG_ISRETRECEIPT = 1<<13; public const FLAG_AUDITREQ = 1<<14; public const FLAG_FILEUPDATEREQ = 1<<15; public const FLAG_ECHOMAIL = 1<<16; // FTS-0001.016 Message header 32 bytes node, net, flags, cost, date private const HEADER_LEN = 0x20; // Length of message header private const header = [ // Struct of message header 'onode' => [0x00,'v',2], // Originating Node 'dnode' => [0x02,'v',2], // Destination Node 'onet' => [0x04,'v',2], // Originating Net 'dnet' => [0x06,'v',2], // Destination Net 'flags' => [0x08,'v',2], // Message Flags 'cost' => [0x0a,'v',2], // Send Cost 'date' => [0x0c,'A20',20] // Message Date FTS-0001.016 Date: upto 20 chars null terminated ]; private const USER_FROM_LEN = 36; // FTS-0001.016 From Name: upto 36 chars null terminated private const USER_TO_LEN = 36; // FTS-0001.016 To Name: upto 36 chars null terminated private const SUBJECT_LEN = 71; // FTS-0001.016 Subject: upto 72 chars null terminated private const AREATAG_LEN = 35; // private ?ValidatorResult $errors = NULL; // Packet validation private array $header; // Message Header private Collection $kludge; // Hold kludge items private string $user_from; // User message is From private string $user_to; // User message is To private string $subject; // Message subject private string $message; // The actual message content private string $origin; // FTS-0004.001 private ?string $echoarea = NULL; // FTS-0004.001 private array $zone; // Zone the message belongs to. (src/dst - for netmail) private array $point; // Point the message belongs to (Netmail) private array $netmail; // Netmail details private Collection $path; // FTS-0004.001 The message PATH lines private Collection $seenby; // FTS-0004.001 The message SEEN-BY lines private Collection $via; // The path the message has gone using Via lines (Netmail) private Collection $_other; // Temporarily hold attributes we dont process yet. private Collection $unknown; // Temporarily hold attributes we have no logic for. // Convert characters into printable chars // https://int10h.org/oldschool-pc-fonts/readme/#437_charset private const CP437 = [ 0x01 => 0x263a, 0x02 => 0x263b, 0x03 => 0x2665, 0x04 => 0x2666, 0x05 => 0x2663, 0x06 => 0x2660, 0x07 => 0x2022, 0x08 => 0x25d8, 0x09 => 0x25cb, 0x0a => 0x2509, 0x0b => 0x2642, 0x0c => 0x2640, 0x0d => 0x266a, 0x0e => 0x266b, 0x0f => 0x263c, 0x10 => 0x25ba, 0x11 => 0x25ca, 0x12 => 0x2195, 0x13 => 0x203c, 0x14 => 0x00b6, 0x15 => 0x00a7, 0x16 => 0x25ac, 0x17 => 0x21a8, 0x18 => 0x2191, 0x19 => 0x2193, 0x1a => 0x2192, 0x1b => 0x2190, 0x1c => 0x221f, 0x1d => 0x2194, 0x1e => 0x25bc, 0x1f => 0x25bc, 0x7f => 0x2302, 0x80 => 0x00c7, 0x81 => 0x00fc, 0x82 => 0x00e9, 0x83 => 0x00e2, 0x84 => 0x00e4, 0x85 => 0x00e0, 0x86 => 0x00e5, 0x87 => 0x00e7, 0x88 => 0x00ea, 0x89 => 0x00eb, 0x8a => 0x00e8, 0x8b => 0x00ef, 0x8c => 0x00ee, 0x8d => 0x00ec, 0x8e => 0x00c4, 0x8f => 0x00c5, 0x90 => 0x00c9, 0x91 => 0x00e6, 0x92 => 0x00c6, 0x93 => 0x00f4, 0x94 => 0x00f6, 0x95 => 0x00f2, 0x96 => 0x00fb, 0x97 => 0x00f9, 0x98 => 0x00ff, 0x99 => 0x00d6, 0x9a => 0x00dc, 0x9b => 0x00a2, 0x9c => 0x00a3, 0x9d => 0x00a5, 0x9e => 0x20a7, 0x9f => 0x0192, 0xa0 => 0x00e1, 0xa1 => 0x00ed, 0xa2 => 0x00f3, 0xa3 => 0x00fa, 0xa4 => 0x00f1, 0xa5 => 0x00d1, 0xa6 => 0x00aa, 0xa7 => 0x00ba, 0xa8 => 0x00bf, 0xa9 => 0x2310, 0xaa => 0x00ac, 0xab => 0x00bd, 0xac => 0x00bc, 0xad => 0x00a1, 0xae => 0x00ab, 0xaf => 0x00bb, 0xb0 => 0x2591, 0xb1 => 0x2592, 0xb2 => 0x2593, 0xb3 => 0x2502, 0xb4 => 0x2524, 0xb5 => 0x2561, 0xb6 => 0x2562, 0xb7 => 0x2556, 0xb8 => 0x2555, 0xb9 => 0x2563, 0xba => 0x2551, 0xbb => 0x2557, 0xbc => 0x255d, 0xbd => 0x255c, 0xbe => 0x255b, 0xbf => 0x2510, 0xc0 => 0x2514, 0xc1 => 0x2534, 0xc2 => 0x252c, 0xc3 => 0x251c, 0xc4 => 0x2500, 0xc5 => 0x253c, 0xc6 => 0x255e, 0xc7 => 0x255f, 0xc8 => 0x255a, 0xc9 => 0x2554, 0xca => 0x2569, 0xcb => 0x2566, 0xcc => 0x2560, 0xcd => 0x2550, 0xce => 0x256c, 0xcf => 0x2567, 0xd0 => 0x2568, 0xd1 => 0x2564, 0xd2 => 0x2565, 0xd3 => 0x2559, 0xd4 => 0x2558, 0xd5 => 0x2552, 0xd6 => 0x2553, 0xd7 => 0x256b, 0xd8 => 0x256a, 0xd9 => 0x2518, 0xda => 0x250c, 0xdb => 0x2588, 0xdc => 0x2584, 0xdd => 0x258c, 0xde => 0x2590, 0xdf => 0x2580, 0xe0 => 0x03b1, 0xe1 => 0x00df, 0xe2 => 0x0393, 0xe3 => 0x03c0, 0xe4 => 0x03a3, 0xe5 => 0x03c3, 0xe6 => 0x00b5, 0xe7 => 0x03c4, 0xe8 => 0x03a6, 0xe9 => 0x0398, 0xea => 0x03a9, 0xeb => 0x03b4, 0xec => 0x221e, 0xed => 0x03c6, 0xee => 0x03b5, 0xef => 0x2229, 0xf0 => 0x2261, 0xf1 => 0x00b1, 0xf2 => 0x2265, 0xf3 => 0x2264, 0xf4 => 0x2320, 0xf5 => 0x2321, 0xf6 => 0x00f7, 0xf7 => 0x2248, 0xf8 => 0x00b0, 0xf9 => 0x2219, 0xfa => 0x00b7, 0xfb => 0x221a, 0xfc => 0x207f, 0xfd => 0x00b2, 0xfe => 0x25a0, 0xff => 0x00a0, ]; public function __construct(string $msg) { $this->kludge = collect(); $this->path = collect(); $this->seenby = collect(); $this->via = collect(); $this->_other = collect(); $this->unknown = collect(); $this->zone = []; $this->point = []; $this->header = unpack($this->unpackheader(self::header),substr($msg,0,self::HEADER_LEN)); $ptr = 0; // To User $this->user_to = strstr(substr($msg,self::HEADER_LEN+$ptr),"\x00",TRUE); $ptr += strlen($this->user_to)+1; // From User $this->user_from = strstr(substr($msg,self::HEADER_LEN+$ptr),"\x00",TRUE); $ptr += strlen($this->user_from)+1; // Subject $this->subject = strstr(substr($msg,self::HEADER_LEN+$ptr),"\x00",TRUE); $ptr += strlen($this->subject)+1; // Check if this is an Echomail if (! strncmp(substr($msg,self::HEADER_LEN+$ptr),'AREA:',5)) { $this->echoarea = substr($msg,self::HEADER_LEN+$ptr+5,strpos($msg,"\r",self::HEADER_LEN+$ptr+5)-(self::HEADER_LEN+$ptr+5)); $ptr += strlen($this->echoarea)+5+1; } $this->parseMessage(substr($msg,self::HEADER_LEN+$ptr)); if (($x=$this->validate()->getMessageBag())->count()) Log::debug('Message fails validation',['result'=>$x]); } public function __get($key) { switch ($key) { // From Addresses case 'fz': return Arr::get($this->zone,'src',0); case 'fn': return Arr::get($this->header,'onet'); case 'ff': return Arr::get($this->header,'onode'); case 'fp': return Arr::get($this->point,'src'); // To Addresses // Echomail doesnt have a zone, so we'll use the source zone case 'tz': return Arr::get($this->zone,$this->echoarea ? 'src' : 'dst',0); case 'tn': return Arr::get($this->header,'dnet'); case 'tf': return Arr::get($this->header,'dnode'); case 'tp': return Arr::get($this->point,'dst'); case 'fftn': case 'tftn': return parent::__get($key); case 'date': return sprintf('%s (%s)',Arr::get($this->header,$key),$this->kludge->get('tzutc')); case 'flags': case 'cost': return Arr::get($this->header,$key); case 'msgid': return $this->kludge->get('msgid'); case 'message': return utf8_decode($this->{$key}); case 'subject': case 'user_to': case 'user_from': case 'kludge': case 'path': case 'seenby': case 'via': case 'errors': case 'echoarea': return $this->{$key}; /* case 'tearline': return '--- FTNHub'; */ default: throw new \Exception('Unknown key: '.$key); } } /** * Export an FTN message, ready for sending. * * @return string * @todo To rework */ public function __toString(): string { // if (f->net == 65535) { /* Point packet - Get Net from auxNet */ $return = ''; $return .= pack(join('',collect(self::header)->pluck(1)->toArray()), $this->ff, $this->tf, $this->fn, $this->tn, $this->flags, $this->cost ); // @todo use pack for this. $return .= $this->date->format('d M y H:i:s')."\00"; $return .= $this->to."\00"; $return .= $this->from."\00"; $return .= $this->subject."\00"; if ($this->type == 'echomail') $return .= "AREA:".$this->echoarea."\r"; // Add some kludges $return .= "\01MSGID ".$this->_fqfa." 1"."\r"; foreach ($this->_kludge as $k=>$v) { if ($x=$this->kludge->get($k)) $return .= chr(1).$v.$x."\r"; } $return .= $this->message."\r"; $return .= $this->tearline."\r"; $return .= $this->origin."\r"; switch ($this->type) { case 'echomail': break; case 'netmail': foreach ($this->via as $k=>$v) $return .= "\01Via: ".$v."\r"; // @todo Set product name/version as var $return .= sprintf('%sVia: %s @%s.UTC %s %i.%i', chr(1), '10:0/0', now('UTC')->format('Ymd.His'), 'FTNHub', 1,1)."\r"; break; } $return .= "\00"; return $return; } /** * Return an array of flag descriptions * * @return array * * http://ftsc.org/docs/fsc-0001.000 * AttributeWord bit meaning --- -------------------- 0 + Private 1 + s Crash 2 Recd 3 Sent 4 + FileAttached 5 InTransit 6 Orphan 7 KillSent 8 Local 9 s HoldForPickup 10 + unused 11 s FileRequest 12 + s ReturnReceiptRequest 13 + s IsReturnReceipt 14 + s AuditRequest 15 s FileUpdateReq s - this bit is supported by SEAdog only + - this bit is not zeroed before packeting */ /* public function flags(int $flags): array { return [ 'private' => $this->isFlagSet($flags,self::FLAG_PRIVATE), 'crash' => $this->isFlagSet($flags,self::FLAG_CRASH), 'recd' => $this->isFlagSet($flags,self::FLAG_RECD), 'sent' => $this->isFlagSet($flags,self::FLAG_SENT), 'killsent' => $this->isFlagSet($flags,self::FLAG_KILLSENT), 'local' => $this->isFlagSet($flags,self::FLAG_LOCAL), ]; } private function isFlagSet($value,$flag): bool { return (($value & $flag) == $flag); } */ /** * If this message doesnt have an AREATAG, then its a netmail. * * @return bool */ public function isNetmail(): bool { return ! $this->echoarea; } /** * Extract information out of the message text. * * @param string $message * @throws InvalidPacketException */ public function parseMessage(string $message): void { // Remove DOS \n\r $message = preg_replace("/\n\r/","\r",$message); // Split out the lines $result = collect(explode("\01",$message))->filter(); $this->message = ''; foreach ($result as $v) { // Search for \r - if that is the end of the line, then its a kludge $x = strpos($v,"\r"); $t = ''; // If there are more characters, then put the kludge back into the result, so that we process it. if ($x != strlen($v)-1) { /** * Anything after the origin line is also kludge data. */ if ($y = strpos($v,"\r * Origin: ")) { $this->message .= utf8_encode(substr($v,$x+1,$y-$x-1)); $this->parseOrigin(substr($v,$y)); // If this is netmail, the FQFA will have been set by the INTL line, we can skip the rest of this $matches = []; // Capture the fully qualified 4D name from the Origin Line - it tells us the ZONE. preg_match('/^.*\((.*)\)$/',$this->origin,$matches); // Double check we have an address in the origin line if (! Arr::get($matches,1)) throw new InvalidPacketException(sprintf('No address in Origin?',$matches)); // Double check, our src and origin match $ftn = Address::parseFTN($matches[1]); // We'll double check our FTN if (($ftn['n'] !== $this->fn) || ($ftn['f'] !== $this->ff)) { Log::error(sprintf('FTN [%s] doesnt match message header',$matches[1]),['ftn'=>$ftn]); } $this->zone['src'] = $ftn['z']; $this->point['src'] = $ftn['p']; // The message is the rest? } elseif (strlen($v) > $x+1) { $this->message .= utf8_encode(substr($v,$x+1)); } $v = substr($v,0,$x+1); } foreach ($this->_kludge as $a => $b) { if ($t = $this->kludge($b,$v)) { $this->kludge->put($a,$t); break; } } // There is more text. if ($t) continue; // From point: "FMPT if ($t = $this->kludge('FMPT ',$v)) $this->point['src'] = $t; /* * The INTL control paragraph shall be used to give information about * the zone numbers of the original sender and the ultimate addressee * of a message. * * "INTL "" " */ elseif ($t = $this->kludge('INTL ',$v)) { $this->netmail['intl'] = $t; // INTL kludge is in Netmail, so we'll do some validation: list($this->netmail['dst'],$this->netmail['src']) = explode(' ',$t); $src = Address::parseFTN($this->netmail['src']); if (($src['n'] !== $this->fn) || ($src['f'] !== $this->ff)) { Log::error(sprintf('INTL src address [%s] doesnt match packet',$this->netmail['src'])); } else { // We'll set our source zone $this->zone['src'] = $src['z']; } $dst = Address::parseFTN($this->netmail['dst']); if (($dst['n'] !== $this->tn) || ($dst['f'] !== $this->tf)) { Log::error(sprintf('INTL dst address [%s] doesnt match packet',$this->netmail['dst'])); } else { // We'll set our source zone $this->zone['dst'] = $dst['z']; } } elseif ($t = $this->kludge('PATH: ',$v)) $this->path->push($t); // To Point: TOPT elseif ($t = $this->kludge('TOPT ',$v)) $this->point['dst'] = $t; // Via @YYYYMMDD.HHMMSS[.Precise][.Time Zone] [Serial Number] elseif ($t = $this->kludge('Via ',$v)) $this->via->push($t); // We got a kludge line we dont know about else $this->unknown->push(chop($v,"\r")); } } /** * Process the data after the ORIGIN * There may be kludge lines after the origin - notably SEEN-BY * * @param string $message */ private function parseOrigin(string $message) { // Split out each line $result = collect(explode("\r",$message))->filter(); foreach ($result as $v) { foreach ($this->_kludge as $a => $b) { if ($t = $this->kludge($b,$v)) { $this->kludge->put($a,$t); break; } } if ($t = $this->kludge('SEEN-BY: ', $v)) $this->seenby->push($t); elseif ($t = $this->kludge('PATH: ', $v)) $this->path->push($t); elseif ($t = $this->kludge(' \* Origin: ',$v)) $this->origin = $t; // We got unknown Kludge lines in the origin else $this->unknown->push($v); } } /** * Translate the string into something printable via the web * * @param string $string * @param array $skip * @return string */ public static function tr(string $string,array $skip=[0x0a,0x0d]): string { $tr = []; foreach (self::CP437 as $k=>$v) { if (in_array($k,$skip)) continue; $tr[chr($k)] = '&#'.$v; } return strtr($string,$tr); } /** * Validate details about this message * * @return \Illuminate\Contracts\Validation\Validator */ private function validate(): ValidatorResult { // Check lengths $validator = Validator::make([ 'user_from' => $this->user_from, 'user_to' => $this->user_to, 'subject' => $this->subject, 'onode' => $this->fn, 'dnode' => $this->ff, 'onet' => $this->tn, 'dnet' => $this->tf, 'flags' => $this->flags, 'cost' => $this->cost, 'echoarea' => $this->echoarea, ],[ 'user_from' => 'required|min:1|max:'.self::USER_FROM_LEN, 'user_to' => 'required|min:1|max:'.self::USER_TO_LEN, 'subject' => 'required|max:'.self::SUBJECT_LEN, 'onode' => ['required',new TwoByteInteger], 'dnode' => ['required',new TwoByteInteger], 'onet' => ['required',new TwoByteInteger], 'dnet' => ['required',new TwoByteInteger], 'flags' => 'required|numeric', 'cost' => 'required|numeric', 'echoarea' => 'nullable|max:'.self::AREATAG_LEN, ]); if ($validator->fails()) $this->errors = $validator; return $validator; } }