clrghouz/app/Classes/FTN/Message.php

692 lines
20 KiB
PHP

<?php
namespace App\Classes\FTN;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Validator as ValidatorResult;
use App\Classes\FTN as FTNBase;
use App\Models\{Address,Domain};
use App\Rules\TwoByteInteger;
/**
* Class Message
* NOTE: FTN Echomail Messages are ZONE agnostic.
*
* @package App\Classes
*/
class Message extends FTNBase
{
private const cast_utf8 = [
'message',
];
// Single value kludge items
private array $_kludge = [
'chrs' => 'CHRS: ',
'charset' => 'CHARSET: ',
'codepage' => 'CODEPAGE: ',
'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 $msgid; // MSG ID
private string $echoarea; // FTS-0004.001
private string $intl; // Netmail details
private string $message; // The actual message content
private string $tearline;
private string $origin; // 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 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 $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(Domain $domain=NULL)
{
$this->domain = $domain;
$this->kludge = collect();
$this->path = collect();
$this->seenby = collect();
$this->via = collect();
$this->unknown = collect();
$this->zone = [];
$this->point = [];
$this->tearline = '';
$this->origin = '';
$this->msgid = '';
$this->echoarea = '';
$this->intl = '';
}
/**
* Parse a message from a packet
*
* @param string $msg
* @param Domain|null $domain
* @return static
* @throws InvalidPacketException
*/
public static function parseMessage(string $msg,Domain $domain=NULL): self
{
$o = new self($domain);
$o->header = unpack(self::unpackheader(self::header),substr($msg,0,self::HEADER_LEN));
$ptr = 0;
// To User
$o->user_to = strstr(substr($msg,self::HEADER_LEN+$ptr),"\x00",TRUE);
$ptr += strlen($o->user_to)+1;
// From User
$o->user_from = strstr(substr($msg,self::HEADER_LEN+$ptr),"\x00",TRUE);
$ptr += strlen($o->user_from)+1;
// Subject
$o->subject = strstr(substr($msg,self::HEADER_LEN+$ptr),"\x00",TRUE);
$ptr += strlen($o->subject)+1;
// Check if this is an Echomail
if (! strncmp(substr($msg,self::HEADER_LEN+$ptr),'AREA:',5)) {
$o->echoarea = substr($msg,self::HEADER_LEN+$ptr+5,strpos($msg,"\r",self::HEADER_LEN+$ptr+5)-(self::HEADER_LEN+$ptr+5));
$ptr += strlen($o->echoarea)+5+1;
}
$o->unpackMessage(substr($msg,self::HEADER_LEN+$ptr));
if (($x=$o->validate($domain))->fails()) {
Log::debug('Message fails validation',['result'=>$x->errors()]);
throw new \Exception('Message validation fails:'.join(' ',$x->errors()->all()));
}
return $o;
}
/**
* 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);
}
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 'fftn_o':
return Address::findFTN($this->fftn);
case 'tftn_o':
return Address::findFTN($this->tftn);
case 'date':
return Carbon::createFromFormat('d M y H:i:s O',
sprintf('%s %s',chop(Arr::get($this->header,$key)),($x=$this->kludge->get('tzutc')) < 0 ? $x : '+'.$x));
case 'flags':
case 'cost': return Arr::get($this->header,$key);
case 'message':
case 'subject':
case 'user_to':
case 'user_from':
case 'kludge':
case 'path':
case 'seenby':
case 'via':
case 'msgid':
case 'errors':
case 'echoarea':
return $this->{$key};
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function __set($key,$value)
{
switch ($key) {
case 'echoarea':
case 'header':
case 'intl':
case 'message':
case 'msgid':
case 'subject':
case 'user_from':
case 'user_to':
case 'via':
$this->{$key} = $value;
break;
default:
throw new \Exception('Unknown key: '.$key);
}
}
/**
* When we serialise this object, we'll need to utf8_encode some values
*
* @return array
*/
public function __serialize(): array
{
$values = [];
$properties = (new \ReflectionClass($this))->getProperties();
$class = get_class($this);
foreach ($properties as $property) {
if ($property->isStatic()) {
continue;
}
$property->setAccessible(true);
if (! $property->isInitialized($this)) {
continue;
}
$name = $property->getName();
$encode = in_array($name,self::cast_utf8);
if ($property->isPrivate()) {
$name = "\0{$class}\0{$name}";
} elseif ($property->isProtected()) {
$name = "\0*\0{$name}";
}
$property->setAccessible(true);
$value = $property->getValue($this);
$values[$name] = $encode ? utf8_encode($value) : $value;
}
return $values;
}
/**
* When we unserialize, we'll restore (utf8_decode) some values
*
* @param array $values
*/
public function __unserialize(array $values): void
{
$properties = (new \ReflectionClass($this))->getProperties();
$class = get_class($this);
foreach ($properties as $property) {
if ($property->isStatic()) {
continue;
}
$name = $property->getName();
$decode = in_array($name,self::cast_utf8);
if ($property->isPrivate()) {
$name = "\0{$class}\0{$name}";
} elseif ($property->isProtected()) {
$name = "\0*\0{$name}";
}
if (! array_key_exists($name, $values)) {
continue;
}
$property->setAccessible(true);
$property->setValue(
$this, $decode ? utf8_decode($values[$name]) : $values[$name]
);
}
}
/**
* Export an FTN message, ready for sending.
*
* @return string
*/
public function __toString(): string
{
$return = '';
$return .= pack(join('',collect(self::header)->pluck(1)->toArray()),
$this->ff,
$this->tf,
$this->fn,
$this->tn,
$this->flags,
$this->cost,
$this->date->format('d M y H:i:s'),
);
$return .= $this->user_to."\00";
$return .= $this->user_from."\00";
$return .= $this->subject."\00";
if ($this->isNetmail())
$return .= sprintf("\01INTL %s\r",$this->intl);
else
$return .= "AREA:".$this->echoarea."\r";
// Add some kludges
$return .= sprintf("\01MSGID: %s\r",$this->msgid);
foreach ($this->_kludge as $k=>$v) {
if ($x=$this->kludge->get($k))
$return .= sprintf("\01%s %s\r",$v,$x);
}
$return .= $this->message."\r";
if ($this->tearline)
$return .= $this->tearline."\r";
if ($this->origin)
$return .= $this->origin."\r";
if ($this->isNetmail()) {
foreach ($this->via as $v)
$return .= sprintf("\01Via %s\r",$v);
}
$return .= "\00";
return $return;
}
/**
* If this message doesnt have an AREATAG, then its a netmail.
*
* @return bool
*/
public function isNetmail(): bool
{
return ! $this->echoarea;
}
/**
* 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);
}
*/
/**
* 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);
}
}
/**
* Extract information out of the message text.
*
* @param string $message
* @throws InvalidPacketException
*/
public function unpackMessage(string $message): void
{
// Remove DOS \n\r
$message = preg_replace("/\n\r/","\r",$message);
// Split out the <SOH> 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 .= 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('No address in Origin?');
// 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]);
// http://ftsc.org/docs/fsc-0068.001
// MSGID should be the basis of the source
$this->zone['src'] = $ftn['z'];
$this->point['src'] = $ftn['p'];
// The message is the rest?
} elseif (strlen($v) > $x+1) {
$this->message .= 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: <SOH>"FMPT <point number><CR>
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.
*
* <SOH>"INTL "<destination address>" "<origin address><CR>
*/
elseif ($t = $this->kludge('INTL ',$v)) {
$this->intl = $t;
// INTL kludge is in Netmail, so we'll do some validation:
list($dst,$src) = explode(' ',$t);
$ftn = Address::parseFTN($src);
if (($ftn['n'] !== $this->fn) || ($ftn['f'] !== $this->ff)) {
Log::error(sprintf('INTL src address [%s] doesnt match packet',$src));
} else {
// We'll set our source zone
$this->zone['src'] = $ftn['z'];
}
$ftn = Address::parseFTN($dst);
if (($ftn['n'] !== $this->tn) || ($ftn['f'] !== $this->tf)) {
Log::error(sprintf('INTL dst address [%s] doesnt match packet',$dst));
} else {
// We'll set our source zone
$this->zone['dst'] = $ftn['z'];
}
}
elseif ($t = $this->kludge('MSGID: ',$v))
$this->msgid = $t;
elseif ($t = $this->kludge('PATH: ',$v))
$this->path->push($t);
// To Point: <SOH>TOPT <point number><CR>
elseif ($t = $this->kludge('TOPT ',$v))
$this->point['dst'] = $t;
// <SOH>Via <FTN Address> @YYYYMMDD.HHMMSS[.Precise][.Time Zone] <Program Name> <Version> [Serial Number]<CR>
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"));
}
}
/**
* Validate details about this message
*
* @return \Illuminate\Contracts\Validation\Validator
*/
public function validate(Domain $domain=NULL): 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,
'ozone' => $this->fz,
'dzone' => $this->tz,
],[
'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,
'ozone' => ['required',$this->domain ? 'in:'.$x=join(',',$this->domain->zones->pluck('zone_id')->toArray()): ''],
'dzone' => ['required',$this->domain ? 'in:'.$x : '']
]);
if ($domain) {
$validator->after(function($validator) {
if (! Address::findFTN($this->fftn))
$validator->errors()->add('from',sprintf('Undefined Node [%s] sent packet.',$this->fftn));
if (! Address::findFTN($this->tftn))
$validator->errors()->add('to',sprintf('Undefined Node [%s] for destination.',$this->fftn));
});
}
if ($validator->fails())
$this->errors = $validator;
return $validator;
}
}