clrghouz/app/Classes/FTN/Packet.php

536 lines
15 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\Notification;
use Symfony\Component\HttpFoundation\File\File;
use App\Classes\FTN as FTNBase;
use App\Exceptions\InvalidPacketException;
use App\Models\{Address,Domain,Echomail,Netmail,Software,Zone};
use App\Notifications\Netmails\EchomailBadAddress;
/**
* Represents a Fidonet Packet, that contains an array of messages.
*
* Thus this object is iterable as an array of Echomail::class or Netmail::class.
*/
abstract class Packet extends FTNBase implements \Iterator, \Countable
{
private const LOGKEY = 'PKT';
protected const PACKED_MSG_LEAD = "\02\00";
protected const PACKED_END = "\00\00";
// @todo Rename this regex to something more descriptive, ie: FILENAME_REGEX
public const regex = '([[:xdigit:]]{4})(?:-(\d{4,10}))?-(.+)';
/**
* Packet types we support, in specific order for auto-detection to work
*
* @var string[]
*/
public const PACKET_TYPES = [
'2.2' => FTNBase\Packet\FSC45::class,
'2+' => FTNBase\Packet\FSC48::class,
'2e' => FTNBase\Packet\FSC39::class,
'2.0' => FTNBase\Packet\FTS1::class,
];
protected array $header; // Packet Header
protected ?string $name = NULL; // Packet name
public File $file; // Packet filename
protected Address $fftn_p; // Address the packet is from (when packing messages)
protected Address $tftn_p; // Address the packet is to (when packing messages)
protected Collection $messages; // Messages in the Packet
public Collection $errors; // Messages that fail validation
protected int $index; // Our array index
protected $pass_p = NULL; // Overwrite the packet password (when packing messages)
/* ABSTRACT */
/**
* This function is intended to be implemented in child classes to test if the packet
* is defined by the child object
*
* @see self::PACKET_TYPES
* @param string $header
* @return bool
*/
abstract public static function is_type(string $header): bool;
abstract protected function header(): string;
/* STATIC */
/**
* Size of the packet header
*
* @return int
*/
public static function header_len(): int
{
return collect(static::HEADER)->sum(function($item) { return Arr::get($item,2); });
}
/**
* Process a packet file
*
* @param mixed $f File handler returning packet data
* @param string $name
* @param int $size
* @param Domain|null $domain
* @return Packet
* @throws InvalidPacketException
*/
public static function process(mixed $f,string $name,int $size,Domain $domain=NULL): self
{
Log::debug(sprintf('%s:+ Opening Packet [%s] with size [%d]',self::LOGKEY,$name,$size));
$o = FALSE;
$header = '';
$read_ptr = 0;
// Determine the type of packet
foreach (self::PACKET_TYPES as $type) {
$header_len = $type::header_len();
// PKT Header
if ($read_ptr < $header_len) {
$header .= fread($f,$header_len-$read_ptr);
$read_ptr = ftell($f);
}
// Could not read header
if (strlen($header) !== $header_len)
throw new InvalidPacketException(sprintf('Length of header [%d] too short',strlen($header)));
if ($type::is_type($header)) {
$o = new $type($header);
break;
}
}
if (! $o)
throw new InvalidPacketException('Cannot determine type of packet.');
$o->name = $name;
$x = fread($f,2);
if (strlen($x) === 2) {
// End of Packet?
if ($x === "\00\00")
return $o;
// Messages start with self::PACKED_MSG_LEAD
elseif ($x !== self::PACKED_MSG_LEAD)
throw new InvalidPacketException('Not a valid packet: '.bin2hex($x));
// No message attached
} else
throw new InvalidPacketException('Not a valid packet, not EOP or SOM:'.bin2hex($x));
Log::info(sprintf('%s:- Packet [%s] is a [%s] packet, dated [%s]',self::LOGKEY,$o->name,get_class($o),$o->date));
// Work out the packet zone
if ($o->fz && ($o->fd || $domain)) {
$o->zone = Zone::select('zones.*')
->join('domains',['domains.id'=>'zones.domain_id'])
->where('zone_id',$o->fz)
->where('name',$o->fd ?: $domain->name)
->single();
}
// If zone is not set, then we need to use a default zone - the messages may not be from this zone.
if (empty($o->zone)) {
Log::alert(sprintf('%s:! We couldnt work out the packet zone, so we have fallen back to the default for [%d]',self::LOGKEY,$o->fz));
$o->zone = Zone::where('zone_id',$o->fz)
->where('default',TRUE)
->singleOrFail();
}
$message = ''; // Current message we are building
$msgbuf = '';
$leader = Message::header_len()+strlen(self::PACKED_MSG_LEAD);
// We loop through reading from the buffer, to find our end of message tag
while ((! feof($f) && ($readbuf=fread($f,$leader)))) {
$read_ptr = ftell($f);
$msgbuf .= $readbuf;
// See if we have our EOM/EOP marker
if ((($end=strpos($msgbuf,"\x00".self::PACKED_MSG_LEAD,$leader)) !== FALSE)
|| (($end=strpos($msgbuf,"\x00".self::PACKED_END,$leader)) !== FALSE))
{
// Parse our message
$o->parseMessage(substr($msgbuf,0,$end));
$msgbuf = substr($msgbuf,$end+3);
continue;
// If we have more to read
} elseif ($read_ptr < $size) {
continue;
}
// If we get here
throw new InvalidPacketException(sprintf('Cannot determine END of message/packet: %s|%s',get_class($o),hex_dump($message)));;
}
if ($msgbuf)
throw new InvalidPacketException(sprintf('Unprocessed data in packet: %s|%s',get_class($o),hex_dump($msgbuf)));
return $o;
}
/**
* @param string|null $header
* @throws \Exception
*/
public function __construct(string $header=NULL)
{
$this->messages = collect();
$this->errors = collect();
if ($header)
$this->header = unpack(self::unpackheader(static::HEADER),$header);
}
/**
* @throws \Exception
*/
public function __get($key)
{
Log::debug(sprintf('%s:/ Requesting key for Packet::class [%s]',self::LOGKEY,$key));
switch ($key) {
// From Addresses
case 'fz': return Arr::get($this->header,'ozone');
case 'fn': return Arr::get($this->header,'onet');
case 'ff': return Arr::get($this->header,'onode');
case 'fp': return Arr::get($this->header,'opoint');
case 'fd': return rtrim(Arr::get($this->header,'odomain',"\x00"));
// To Addresses
case 'tz': return Arr::get($this->header,'dzone');
case 'tn': return Arr::get($this->header,'dnet');
case 'tf': return Arr::get($this->header,'dnode');
case 'tp': return Arr::get($this->header,'dpoint');
case 'td': return rtrim(Arr::get($this->header,'ddomain',"\x00"));
case 'date':
return Carbon::create(
Arr::get($this->header,'y'),
Arr::get($this->header,'m')+1,
Arr::get($this->header,'d'),
Arr::get($this->header,'H'),
Arr::get($this->header,'M'),
Arr::get($this->header,'S')
);
case 'password':
return rtrim(Arr::get($this->header,$key),"\x00");
case 'fftn_t':
case 'fftn':
case 'tftn_t':
case 'tftn':
return parent::__get($key);
case 'software':
$code = Arr::get($this->header,'prodcode-hi')<<8|Arr::get($this->header,'prodcode-lo');
Software::unguard();
$o = Software::singleOrNew(['code'=>$code,'type'=>Software::SOFTWARE_TOSSER]);
Software::reguard();
return $o;
case 'software_ver':
return sprintf('%d.%d',Arr::get($this->header,'prodrev-maj'),Arr::get($this->header,'prodrev-min'));
case 'capability':
// This needs to be defined in child classes, since not all children have it
return NULL;
// Packet Type
case 'type':
return static::TYPE;
// Packet name:
case 'name':
return $this->{$key} ?: sprintf('%08x',timew());
case 'messages':
return $this->{$key};
default:
throw new \Exception('Unknown key: '.$key);
}
}
/**
* Return the packet
*
* @return string
* @throws \Exception
*/
public function __toString(): string
{
if (empty($this->messages))
throw new InvalidPacketException('Refusing to make an empty packet');
if (empty($this->tftn_p) || empty($this->fftn_p))
throw new InvalidPacketException('Cannot generate a packet without a destination address');
$return = $this->header();
foreach ($this->messages as $o)
$return .= self::PACKED_MSG_LEAD.$o->packet($this->tftn_p);
$return .= "\00\00";
return $return;
}
/* INTERFACE */
/**
* Number of messages in this packet
*/
public function count(): int
{
return $this->messages->count();
}
public function current(): Echomail|Netmail
{
return $this->messages->get($this->index);
}
public function key(): mixed
{
return $this->index;
}
public function next(): void
{
$this->index++;
}
public function rewind(): void
{
$this->index = 0;
}
public function valid(): bool
{
return (! is_null($this->key())) && $this->messages->has($this->key());
}
/* METHODS */
/**
* When creating a new packet, set the header.
*
* @param Address $oo
* @param Address $o
* @param string|null $passwd Override the password used in the packet
* @deprecated Use Packet::generate(), which should generate a packet of the right type
*/
public function addressHeader(Address $oo,Address $o,string $passwd=NULL): void
{
Log::debug(sprintf('%s:+ Creating packet for [%s]',self::LOGKEY,$o->ftn));
$date = Carbon::now();
// Create Header
$this->header = [
'ozone' => $oo->zone->zone_id, // Orig Zone
'dzone' => $o->zone->zone_id, // Dest Zone
'onet' => $oo->host_id ?: $oo->region_id, // Orig Net
'dnet' => $o->host_id ?: $o->region_id, // Dest Net
'onode' => $oo->node_id, // Orig Node
'dnode' => $o->node_id, // Dest Node
'opoint' => $oo->point_id, // Orig Point
'dpoint' => $o->point_id, // Dest Point
'odomain' => $oo->zone->domain->name, // Orig Domain
'ddomain' => $o->zone->domain->name, // Dest Domain
'y' => $date->format('Y'), // Year
'm' => $date->format('m')-1, // Month
'd' => $date->format('d'), // Day
'H' => $date->format('H'), // Hour
'M' => $date->format('i'), // Minute
'S' => $date->format('s'), // Second
'password' => strtoupper((! is_null($passwd)) ? $passwd : $o->session('pktpass')), // Packet Password
];
}
/**
* Add a message to this packet
*
* @param Message $o
* @deprecated No longer used when Address::class is updated
*/
public function addMail(Message $o): void
{
$this->messages->push($o);
}
public function for(Address $ao): self
{
$this->tftn_p = $ao;
$this->fftn_p = our_address($ao);
return $this;
}
/**
* Generate a packet
*
* @return string
*/
public function generate(): string
{
return (string)$this;
}
public function mail(Collection $msgs): self
{
$this->messages = $msgs;
return $this;
}
/**
* Parse a message in a mail packet
*
* @param string $message
* @throws InvalidPacketException|\Exception
*/
private function parseMessage(string $message): void
{
Log::info(sprintf('%s:+ Processing packet message [%d] bytes',self::LOGKEY,strlen($message)));
$msg = Message::parseMessage($message,$this->zone);
// @todo If the message from domain (eg: $msg->fftn->zone->domain) is different to the packet address domain ($pkt->fftn->zone->domain), we'll skip this message
Log::debug(sprintf('%s:^ Message [%s] - Packet from domain [%d], Message domain [%d]',self::LOGKEY,$msg->msgid,$this->fftn->zone->domain_id,$msg->fftn->zone->domain_id));
// If the message is invalid, we'll ignore it
if ($msg->errors) {
Log::info(sprintf('%s:- Message [%s] has errors',self::LOGKEY,$msg->msgid));
// If the messages is not for the right zone, we'll ignore it
if ($msg->errors->messages()->has('invalid-zone')) {
Log::alert(sprintf('%s:! Message [%s] is from an invalid zone [%s], packet is from [%s] - ignoring it',self::LOGKEY,$msg->msgid,$msg->fftn->zone->zone_id,$this->fftn->zone->zone_id));
if (! $msg->rescanned->count())
Notification::route('netmail',$this->fftn)->notify(new EchomailBadAddress($msg));
return;
}
// @todo If the $msg->fftn doesnt exist, we'll need to create it
// @todo If the $msg->tftn doesnt exist (netmail), we'll need to create it (ergo intransit)
/*
// If the to address doenst exist, we'll create a new entry
if ($msg->errors->messages()->has('to') && $msg->tzone) {
try {
// @todo Need to work out the correct region for the host_id
Address::unguard();
$ao = Address::firstOrNew([
'zone_id' => $msg->tzone->id,
//'region_id' => 0,
'host_id' => $msg->tn,
'node_id' => $msg->tf,
'point_id' => $msg->tp,
'active' => TRUE, // @todo This should be false, as it hasnt been assigned to the node
]);
Address::reguard();
if (is_null($ao->region_id))
$ao->region_id = $ao->host_id;
} catch (\Exception $e) {
Log::error(sprintf('%s:! Error finding/creating TO address [%s] for message',self::LOGKEY,$msg->tboss),['error'=>$e->getMessage()]);
$this->errors->push($msg);
return;
}
$so = System::createUnknownSystem();
$so->addresses()->save($ao);
Log::alert(sprintf('%s:- To FTN is not defined, creating new entry for [%s] (%d)',self::LOGKEY,$msg->tboss,$ao->id));
}
// If the from address doenst exist, we'll create a new entry
if ($msg->errors->messages()->has('from') && $msg->tzone) {
try {
// @todo Need to work out the correct region for the host_id
Address::unguard();
$ao = Address::firstOrNew([
'zone_id' => $msg->fzone->id,
//'region_id' => 0,
'host_id' => $msg->fn,
'node_id' => $msg->ff,
'point_id' => $msg->fp,
'active'=> TRUE, // @todo This should be FALSE as it hasnt been assigned to the node
]);
Address::reguard();
if (is_null($ao->region_id))
$ao->region_id = $ao->host_id;
} catch (\Exception $e) {
Log::error(sprintf('%s:! Error finding/creating FROM address [%s] for message',self::LOGKEY,$msg->fboss),['error'=>$e->getMessage()]);
$this->errors->push($msg);
return;
}
$so = System::createUnknownSystem();
$so->addresses()->save($ao);
Log::alert(sprintf('%s:- From FTN is not defined, creating new entry for [%s] (%d)',self::LOGKEY,$msg->fboss,$ao->id));
}
*/
// If the from/to user is missing
if ($msg->errors->messages()->has('from') || $msg->errors->messages()->has('to')) {
Log::error(sprintf('%s:! Skipping message [%s] due to errors (%s)...',self::LOGKEY,$msg->msgid,join(',',$msg->errors->messages()->keys())));
$this->errors->push($msg);
return;
}
}
$this->messages->push($msg);
}
/**
* Overwrite the packet password
*
* @param string|null $password
* @return self
*/
public function password(string $password=NULL): self
{
if ($password && (strlen($password) < 9))
$this->pass_p = $password;
return $this;
}
/** @deprecated Is this used? */
public function pluck(string $key): Collection
{
throw new \Exception(sprintf('%s:! This function is deprecated - [%s]',self::LOGKEY,$key));
return $this->messages->pluck($key);
}
}