clrghouz/app/Models/Address.php
Deon George e963675fd3
All checks were successful
Create Docker Image / Build Docker Image (x86_64) (push) Successful in 26s
Create Docker Image / Build Docker Image (arm64) (push) Successful in 1m31s
Create Docker Image / Final Docker Image Manifest (push) Successful in 8s
Continue to show all common addresses in Items Waiting tab, Add Address Clear Queue job to delete anything in the queue for an address
2024-11-04 15:59:00 +11:00

1385 lines
36 KiB
PHP

<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Classes\FTN\{Message,Packet};
use App\Exceptions\InvalidFTNException;
use App\Traits\{QueryCacheableConfig,ScopeActive};
/**
* This represents an FTN AKA.
*
* If an address is active, it belongs to the system. There can only be 1 active FTN (z:h/n.p@d)
* If an address is not active, it belonged to the system at a point in time in the past.
*
* If an address is validated, we know that the system is using the address (we've confirmed that during a session).
* validated is update/removed is during a mailer session, confirming if the node has the address
* @todo Remove validated, if that system hasnt used the address for a defined period (eg: 30)
*
* We'll only trigger a poll to a system that we have mail for if active && validated, unless "forced".
*
* Any mail for that address will be delivered, if active && validated.
*
* Address status:
* + Active (active=true/validated=true) - mail can flow and in one of our networks (we have session details)
* + Pending (active=true/validated=false) - remote hasnt configured address during a session and in one of our networks
* + Known (active=true/validated=true) - the node presented this address, but we didnt auth it, and its a network we are not in
* + Unconfirm (active=true/validated=true) - the node presented this address, but we dont manage the address (it uses a different hub)
* + Nodelist (active=true/validated=true) - the node presented this address, but we dont manage the address and its in a recent nodelist
* + Delisted (active=false/validated=true) - this node shouldnt use this address any more
* + Freed (active=false/validated=false) - this node shouldnt is known to not be using this address any more
*
* Other Status
* + citizen - The address belongs to one of our jurisdiction (hub_id = our address, NC,RC,ZC)
* + foreign - The address doesnt belong to our jurisdiction
*
* @see \App\Http\Requests\AddressAdd::class for rules about AKA and role
*/
class Address extends Model
{
use QueryCacheableConfig,ScopeActive,SoftDeletes;
private const LOGKEY = 'MA-';
// http://ftsc.org/docs/frl-1028.002
public const ftn_regex = '(\d+):(\d+)/(\d+)(?:\.(\d+))?(?:@([a-zA-Z0-9\-_~]{0,8}))?';
public const NODE_ZC = 1<<0; // Zone
public const NODE_RC = 1<<1; // Region
public const NODE_NC = 1<<2; // Host
public const NODE_HC = 1<<3; // Hub
public const NODE_NN = 1<<4; // Node
public const NODE_PVT = 1<<5; // Pvt (we dont have address information) @todo
public const NODE_HOLD = 1<<6; // Hold (user has requested hold, we havent heard from the node for 7 days
public const NODE_DOWN = 1<<7; // Down we havent heard from the node for 30 days
public const NODE_POINT = 1<<8; // Point
public const NODE_KEEP = 1<<9; // Dont mark an address hold/down or de-list automatically
public const NODE_UNKNOWN = 1<<15; // Unknown
public const NODE_ALL = 0x811f; // Mask to catch all nodes, excluding their status
// http://ftsc.org/docs/frl-1002.001
public const ADDRESS_FIELD_MAX = 0x7fff; // Maximum value for a field in the address
protected $visible = ['zone_id','region_id','host_id','hub_id','node_id','point_id','security'];
/* STATIC */
public static function boot()
{
parent::boot();
// For indexes when deleting, we need to change active to FALSE
static::softDeleted(function($model) {
Log::debug(sprintf('%s:Deleting [%d], updating active to FALSE',self::LOGKEY,$model->id));
$model->active = FALSE;
$model->save();
});
}
/**
* Create an FTN address associated with a system
*
* @param string $address
* @param System $so
* @return Address|null
* @throws \Exception
*/
public static function createFTN(string $address,System $so): ?self
{
$ftn = self::parseFTN($address);
if (! $ftn['d']) {
// See if we have a default domain for this domain
if ($ftn['z']) {
$zo = Zone::where('zone_id',$ftn['z'])->where('default',TRUE)->single();
if ($zo)
$ftn['d'] = $zo->domain->name;
else {
Log::alert(sprintf('%s:! Refusing to create address [%s] no domain available',self::LOGKEY,$address));
return NULL;
}
}
}
Log::debug(sprintf('%s:- Creating AKA [%s] for [%s]',self::LOGKEY,$address,$so->name));
// Check Domain exists
Domain::unguard();
$do = Domain::singleOrNew(['name'=>$ftn['d']]);
Domain::reguard();
if (! $do->exists) {
$do->public = TRUE;
$do->active = TRUE;
$do->notes = 'Auto created';
$do->save();
}
// If the zone is zero, see if this is a flatten domain, and if so, find an NC
if (($ftn['z'] === 0) && $do->flatten) {
$nc = self::findZone($do,$ftn['n'],0,0);
if ($nc) {
Log::info(sprintf('%s:- Setting zone to [%d]',self::LOGKEY,$nc->zone->zone_id));
$ftn['z'] = $nc->zone->zone_id;
}
}
// Create zone
Zone::unguard();
$zo = Zone::singleOrNew(['domain_id'=>$do->id,'zone_id'=>$ftn['z']]);
Zone::reguard();
if (! $zo->exists) {
$zo->active = TRUE;
$zo->notes = 'Auto created';
$zo->system_id = System::createUnknownSystem()->id;
$do->zones()->save($zo);
}
if (! $zo->active || ! $do->active) {
Log::alert(sprintf('%s:! Refusing to create address [%s] in disabled zone or domain',self::LOGKEY,$address));
return NULL;
}
// Create Address, assigned to $so
$o = new self;
$o->active = TRUE;
$o->zone_id = $zo->id;
$o->region_id = $ftn['r'];
$o->host_id = $ftn['n'];
$o->node_id = $ftn['f'];
$o->point_id = $ftn['p'];
try {
$so->addresses()->save($o);
} catch (QueryException $e) {
Log::error(sprintf('%s:! ERROR creating address [%s] (%s)',self::LOGKEY,$o->ftn,get_class($e)));
return NULL;
}
return $o;
}
/**
* Find a record in the DB for a node string, eg: 10:1/1.0
*
* @param string $address
* @param bool $trashed
* @param bool $recent
* @return Address|null
* @throws \Exception
*/
public static function findFTN(string $address,bool $trashed=FALSE,bool $recent=FALSE): ?self
{
$ftn = self::parseFTN($address);
$o = NULL;
$query = (new self)
->select('addresses.*')
->join('zones',['zones.id'=>'addresses.zone_id'])
->join('domains',['domains.id'=>'zones.domain_id'])
->when($trashed,function($query) use ($recent) {
return $query->withTrashed()
->orderBy('updated_at','DESC')
->when($recent,fn($query)=>$query->where(fn($query)=>$query
->where('deleted_at','>=',Carbon::now()->subMonth())->orWhereNull('deleted_at')));
},function($query) {
$query->active();
})
->where('zones.zone_id',$ftn['z'])
->where('node_id',$ftn['f'])
->where('point_id',$ftn['p'])
->when($ftn['d'],function($query,$domain) {
$query->where('domains.name',$domain);
},function($query) {
$query->where('zones.default',TRUE);
})
->orderBy('created_at','DESC');
$q = $query->clone();
// Are we looking for a region address
if (($ftn['f'] === 0) && ($ftn['p'] === 0))
$o = $query
->where('region_id',$ftn['n'])
->where('host_id',$ftn['n'])
->with(['system:id,active,name,address,pkt_msgs,last_session,hold'])
->first();
// Look for a normal address
if (! $o)
$o = $q
->where(function($q) use ($ftn) {
return $q
->where(function($q) use ($ftn) {
return $q
->where('region_id',$ftn['n'])
->where('host_id',$ftn['n']);
})
->orWhere('host_id',$ftn['n']);
})
->with(['system:id,active,name,address,pkt_msgs,last_session,hold'])
->first();
// Check and see if we are a flattened domain, our address might be available with a different zone.
// This occurs when we are parsing 2D addresses from SEEN-BY, but we have the zone
if (! $o && ($ftn['p'] === 0)) {
if ($ftn['d'])
$do = Domain::select('flatten')->where(['name'=>$ftn['d']])->single();
else {
$zo = Zone::where('zone_id',$ftn['z'])->where('default',TRUE)->with(['domain:id,flatten'])->single();
$do = $zo?->domain;
}
if ($do && $do->flatten && (($ftn['z'] === 0) || $do->zones->pluck('zone_id')->contains($ftn['z'])))
$o = self::findZone($do,$ftn['n'],$ftn['f'],$ftn['p'],$trashed);
}
return ($o && ($trashed || $o->system->active)) ? $o : NULL;
}
public static function newFTN(string $address): self
{
$ftn = self::parseFTN($address);
$do = $ftn['d'] ? Domain::where('name',$ftn['d'])->single() : NULL;
$o = new self;
$o->region_id = $ftn['r'];
$o->host_id = $ftn['n'];
$o->node_id = $ftn['f'];
$o->point_id = $ftn['p'];
$zo = Zone::where('zone_id',$ftn['z'])
->when($do,fn($query)=>$query->where('domain_id',$do->id))
->single();
$o->zone_id = $zo?->id;
if (($ftn['z'] === 0) || (! $zo)) {
Log::alert(sprintf('%s:! newFTN was parsed an FTN [%s] with a zero zone, adding empty zone in domain',self::LOGKEY,$address));
$zo = new Zone;
$zo->domain_id = $do?->id;
}
$o->zone()->associate($zo);
return $o;
}
/**
* This is to find an address for a domain (like fidonet), which is technically 2D even though it uses multiple zones.
*
* This was implemented to identify seenby and path kludges
*
* @param Domain $do
* @param int $host
* @param int $node
* @param int $point
* @param bool $trashed
* @return self|null
* @throws \Exception
*/
public static function findZone(Domain $do,int $host,int $node,int $point,bool $trashed=FALSE): ?self
{
if (! $do->flatten)
throw new \Exception(sprintf('Domain is not set with flatten: %d',$do->id));
$zones = $do->zones->pluck('zone_id');
$o = (new self)
->select('addresses.*')
->join('zones',['zones.id'=>'addresses.zone_id'])
->when($trashed,function($query) {
$query->withTrashed();
},function($query) {
$query->active();
})
->whereIN('zones.zone_id',$zones)
->where(function($q) use ($host) {
return $q
->where(function($q) use ($host) {
return $q->where('region_id',$host)
->where('host_id',$host);
})
->orWhere('host_id',$host);
})
->where('node_id',$node)
->where('point_id',$point)
->where('zones.domain_id',$do->id)
->single();
return $o;
}
/**
* Parse a string and split it out as an FTN array
*
* @param string $ftn
* @return array
* @throws \Exception
*/
public static function parseFTN(string $ftn): array
{
if (! preg_match(sprintf('#^%s$#',self::ftn_regex),strtolower($ftn),$matches))
throw new InvalidFTNException(sprintf('Invalid FTN: [%s] - regex failed',serialize($ftn)));
// Check our numbers are correct.
foreach ([1,2,3] as $i)
if ((! is_numeric($matches[$i])) || ($matches[$i] > self::ADDRESS_FIELD_MAX))
throw new InvalidFTNException(sprintf('Invalid FTN: [%s] - zone, host, or node address invalid [%d]',$ftn,$matches[$i]));
if ((! empty($matches[4])) AND ((! is_numeric($matches[$i])) || ($matches[4] > self::ADDRESS_FIELD_MAX)))
throw new InvalidFTNException(sprintf('Invalid FTN: [%s] - point address invalid [%d]',$ftn,$matches[4]));
// Work out region
$region_id = NULL;
$zone_id = NULL;
// We can only work out region/zone if we have a domain - this is for 2D parsing
if ($matches[5] ?? NULL) {
$o = new self;
$o->host_id = $matches[2];
$o->node_id = $matches[3];
$o->point_id = empty($matches[4]) ? 0 : (int)$matches[4];
if ($matches[1] !== "0") {
$zo = Zone::select('zones.*')
->where('zone_id',$matches[1])
->join('domains',['domains.id'=>'zones.domain_id'])
->where('domains.name',$matches[5])
->single();
// Try and find out the zone from the host_id
} else {
$zo = Zone::select('zones.*')
->where(fn($query)=>$query->where('host_id',$matches[2])->orWhere('region_id',$matches[2]))
->join('domains',['domains.id'=>'zones.domain_id'])
->join('addresses',['addresses.zone_id'=>'zones.id'])
->where('domains.name',$matches[5])
->first();
}
$o->zone_id = $zo?->id;
$parent = $o->parent();
$region_id = $parent?->region_id;
$zone_id = $parent?->zone->zone_id;
}
return [
'z' => (int)($zone_id ?: $matches[1]),
'r' => (int)$region_id,
'n' => (int)$matches[2],
'f' => (int)$matches[3],
'p' => empty($matches[4]) ? 0 : (int)$matches[4],
'd' => $matches[5] ?? NULL
];
}
/* SCOPES */
/**
* An FTN is active only if the address, zone, domain is also active
*
* @param $query
* @return mixed
* @note zones and domains needs to be joined in the base call, or use FTN()
*/
public function scopeActiveFTN($query)
{
return $query
->where('zones.active',TRUE)
->where('domains.active',TRUE)
->active();
}
/**
* Select to support returning FTN address
*
* @param $query
* @return void
*/
public function scopeFTN($query)
{
return $query
->select(['addresses.id','region_id','host_id','node_id','point_id','addresses.zone_id','addresses.active','role','security','addresses.system_id','addresses.active','validated','deleted_at'])
->join('zones',['zones.id'=>'addresses.zone_id'])
->join('domains',['domains.id'=>'zones.domain_id'])
->orderBy('domains.name')
->orderBy('region_id')
->orderBy('host_id')
->orderBy('node_id')
->orderBy('point_id')
->with([
'zone:zones.id,domain_id,zone_id,active',
'zone.domain:domains.id,name,active,public',
]);
}
/** @deprecated use FTN() */
public function scopeFTNOrder($query)
{
return $query
->orderBy('region_id')
->orderBy('host_id')
->orderBy('node_id')
->orderBy('point_id');
}
public function scopeFTN2DOrder($query)
{
return $query
->orderBy('host_id')
->orderBy('node_id')
->orderBy('point_id');
}
/**
* Return a list of addresses and the amount of uncollected echomail
*
* @param $query
* @return mixed
*/
public function scopeUncollectedEchomail($query)
{
return $query
->join('echomail_seenby',['echomail_seenby.address_id'=>'addresses.id'])
->join('echomails',['echomails.id'=>'echomail_seenby.echomail_id'])
->whereNotNull('export_at')
->whereNull('sent_at')
->whereNull('echomails.deleted_at')
->groupBy('addresses.id')
->dontCache();
}
public function scopeUncollectedEchomailTotal($query)
{
return $query
->select([
'addresses.id',
'zone_id',
'host_id',
'node_id',
'point_id',
'system_id',
DB::raw('count(addresses.id) as uncollected_echomail'),
DB::raw('0 as uncollected_netmail'),
DB::raw('0 as uncollected_files'),
])
->UncollectedEchomail();
}
/**
* Return a list of addresses and the amount of uncollected files
*
* @param $query
* @return mixed
*/
public function scopeUncollectedFiles($query)
{
return $query
->join('file_seenby',['file_seenby.address_id'=>'addresses.id'])
->join('files',['files.id'=>'file_seenby.file_id'])
->whereNotNull('export_at')
->whereNull('sent_at')
->whereNull('files.deleted_at')
->groupBy('addresses.id')
->dontCache();
}
public function scopeUncollectedFilesTotal($query)
{
return $query
->select([
'addresses.id',
'zone_id',
'host_id',
'node_id',
'point_id',
'system_id',
DB::raw('0 as uncollected_echomail'),
DB::raw('0 as uncollected_netmail'),
DB::raw('count(addresses.id) as uncollected_files')
])
->UncollectedFiles();
}
public function scopeUncollectedNetmail($query)
{
return $query
->join('netmails',['netmails.tftn_id'=>'addresses.id'])
->where(function($query) {
return $query->whereRaw(sprintf('(flags & %d) > 0',Message::FLAG_INTRANSIT))
->orWhereRaw(sprintf('(flags & %d) > 0',Message::FLAG_LOCAL));
})
->whereRaw(sprintf('(flags & %d) = 0',Message::FLAG_SENT))
->whereNull('sent_pkt')
->whereNull('sent_at')
->whereNull('netmails.deleted_at')
->groupBy('addresses.id')
->dontCache();
}
/**
* Return a list of addresses and the amount of uncollected netmail
*
* @param $query
* @return mixed
*/
public function scopeUncollectedNetmailTotal($query)
{
return $query
->select([
'addresses.id',
'zone_id',
'host_id',
'node_id',
'point_id',
'system_id',
DB::raw('0 as uncollected_echomail'),
DB::raw('count(addresses.id) as uncollected_netmail'),
DB::raw('0 as uncollected_files')
])
->UncollectedNetmail();
}
/* RELATIONS */
public function domain()
{
return $this->hasOneThrough(Domain::class,Zone::class,'id','id','zone_id','domain_id');
}
public function dynamics()
{
return $this->hasMany(Dynamic::class);
}
/**
* Echoareas this address is subscribed to
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function echoareas()
{
return $this->belongsToMany(Echoarea::class)
->using(AddressEchoarea::class)
->orderBy('name')
->withPivot(['subscribed']);
}
public function echomail_from()
{
return $this->hasMany(Echomail::class,'fftn_id','id')
->orderBy('datetime','DESC')
->limit(10);
}
/**
* Echomails that this address has seen
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
* @todo Rework echomail_seenby to not have a specific seenby recorded for the fftn_id, but automatically include it when generating seenbys.
*/
public function echomail_seen()
{
return $this->belongsToMany(Echomail::class,'echomail_seenby')
->withPivot(['export_at','sent_at','sent_pkt']);
}
/**
* Files that this address has seen
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function file_seen(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(File::class,'file_seenby')
->withPivot(['sent_at','export_at']);
}
/**
* Fileareas this address is subscribed to
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function fileareas(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Filearea::class)
->orderBy('name')
->withPivot(['subscribed']);
}
/**
* If we are a hub, if role === NODE_HC and child entries have us as their hub_id
*
* @return HasMany
*/
public function nodes_hub(): HasMany
{
return $this->hasMany(Address::class,'hub_id','id')
->select(['id','addresses.zone_id','host_id','node_id','point_id','system_id'])
->active()
->FTNorder()
->with([
'zone:zones.id,domain_id,zone_id',
'zone.domain:domains.id,name',
]);
}
/**
* Return the nodes that belong to this NC/RC/ZC
*
* @return HasMany
*/
public function nodes_net(): HasMany
{
return HasMany::noConstraints(
fn()=>$this->newHasMany(
(new self)->newQuery()
->where('zone_id',$this->zone_id)
->where('host_id',$this->host_id)
->where('point_id',0)
->whereNot('id',$this->id)
->active()
->FTNorder()
->with(['zone:id,domain_id,zone_id']),
$this,
NULL,
($this->role_id === self::NODE_NC) ? 'id' : NULL)
);
}
/**
* If we are a boss node, return our children.
*
* @return HasMany
*/
public function nodes_point(): HasMany
{
return HasMany::noConstraints(
fn()=>$this->newHasMany(
(new self)->newQuery()
->where('zone_id',$this->zone_id)
->where('host_id',$this->host_id)
->where('node_id',$this->node_id)
->where('point_id','>',0)
->whereNot('id',$this->id)
->active()
->FTNorder()
->with(['zone:id,domain_id,zone_id']),
$this,
NULL,
($this->role_id !== self::NODE_POINT) ? 'id' : NULL)
);
}
public function nodes_region(): HasMany
{
return HasMany::noConstraints(
fn()=>$this->newHasMany(
(new self)->newQuery()
->where('zone_id',$this->zone_id)
->where('region_id',$this->region_id)
->whereNot('id',$this->id)
->active()
->FTNorder()
->with(['zone:id,domain_id,zone_id']),
$this,
NULL,
($this->role_id === self::NODE_RC) ? 'id' : NULL)
);
}
public function nodes_zone(): HasMany
{
return HasMany::noConstraints(
fn()=>$this->newHasMany(
(new self)->newQuery()
->where('zone_id',$this->zone_id)
->whereNot('id',$this->id)
->active()
->FTNorder()
->with(['zone:id,domain_id,zone_id']),
$this,
NULL,
($this->role_id === self::NODE_ZC) ? 'id' : NULL)
);
}
public function system()
{
return $this->belongsTo(System::class);
}
public function uplink_hub()
{
return $this->belongsTo(Address::class,'hub_id','id');
}
public function zone()
{
return $this->belongsTo(Zone::class);
}
/* ATTRIBUTES */
/**
* Return if this address is active
*
* @param bool $value
* @return bool
*/
public function getActiveAttribute(bool $value): bool
{
return $value && $this->getActiveDomainAttribute();
}
public function getActiveDomainAttribute(): bool
{
return $this->zone->active && $this->zone->domain->active;
}
/**
* Render the node name in full 5D
*
* @return string
*/
public function getFTNAttribute(): string
{
if (! $this->relationLoaded('zone'))
$this->load(['zone:id,domain_id,zone_id','zone.domain:domains.id,name']);
return sprintf('%s@%s',$this->getFTN4DAttribute(),$this->zone->domain->name);
}
public function getFTN2DAttribute(): string
{
return sprintf('%d/%d',$this->host_id ?: $this->region_id,$this->node_id);
}
public function getFTN3DAttribute(): string
{
if (! $this->relationLoaded('zone'))
$this->load(['zone:id,domain_id,zone_id']);
if (! $this->zone)
throw new InvalidFTNException(sprintf('Invalid Zone for FTN address [%d/%d.%d@%s]',$this->host_id ?: $this->region_id,$this->node_id,$this->point_id,$this->domain?->name));
return sprintf('%d:%s',$this->zone->zone_id,$this->getFTN2DAttribute());
}
public function getFTN4DAttribute(): string
{
if (! $this->relationLoaded('zone'))
$this->load(['zone:id,domain_id,zone_id']);
return sprintf('%s.%d',$this->getFTN3DAttribute(),$this->point_id);
}
public function getIsDefaultRouteAttribute(): bool
{
return (! is_null($x=$this->session('default'))) && $x;
}
public function getIsDownAttribute(): bool
{
return $this->role & self::NODE_DOWN;
}
public function getIsHostedAttribute(): bool
{
return strlen($this->getPassSessionAttribute()) > 0;
}
public function getIsHoldAttribute(): bool
{
return $this->role & self::NODE_HOLD;
}
public function getIsOwnedAttribute(): bool
{
return $this->system->is_owned;
}
public function getIsPrivateAttribute(): bool
{
return (! $this->system->address);
}
/**
* Determine this address' role (without status)
*
* @return int
* @see \App\Http\Requests\AddressAdd::class
*/
public function getRoleIdAttribute(): int
{
static $warn = FALSE;
$val = ($this->role & self::NODE_ALL);
$role = $this->ftn_role();
if ($this->isRoleOverride($role)) {
if (! $warn) {
$warn = TRUE;
Log::alert(sprintf('%s:! Address ROLE [%d] is not consistent with what is expected [%d] for [%s]',self::LOGKEY,$val,$role,$this->ftn));
}
return $val;
} else
return $role;
}
/**
* Return a name for the role (without status)
*
* @return string
*/
public function getRoleNameAttribute(): string
{
if ($this->getIsDownAttribute())
return 'DOWN';
if ($this->getIsHoldAttribute())
return 'HOLD';
if ($this->getIsPrivateAttribute())
return 'PVT';
switch ($this->role_id) {
case self::NODE_ZC:
return 'ZC';
case self::NODE_RC:
return 'RC';
case self::NODE_NC:
return 'NC';
case self::NODE_HC:
return 'HUB';
case self::NODE_NN:
return 'NODE';
case self::NODE_POINT:
return 'POINT';
default:
return $this->role_id;
}
}
public function getPassFixAttribute(): ?string
{
return strtoupper($this->session('fixpass'));
}
public function getPassPacketAttribute(): ?string
{
return strtoupper($this->session('pktpass'));
}
public function getPassSessionAttribute(): ?string
{
return $this->session('sespass');
}
public function getPassTicAttribute(): ?string
{
return strtoupper($this->session('ticpass'));
}
/* METHODS */
/**
* Check the user's activation code for this address is correct
*
* @param User $uo
* @param string $code
* @return bool
*/
public function activation_check(User $uo,string $code): bool
{
try {
Log::info(sprintf('%s:Checking Activation code [%s] is valid for user [%d]',self::LOGKEY,$code,$uo->id));
return ($code === $this->activation_set($uo));
} catch (\Exception $e) {
Log::error(sprintf('%s:! Activation code [%s] invalid for user [%d]',self::LOGKEY,$code,$uo->id));
return FALSE;
}
}
/**
* Create an activation code for this address
*
* @param User $uo
* @return string
*/
public function activation_set(User $uo): string
{
return sprintf('%x:%s',
$this->id,
substr(md5(sprintf('%d:%x',$uo->id,timew($this->updated_at))),0,10)
);
}
/**
* Find the immediate children dependent on this record
*
* @return Collection
*/
public function children(): Collection
{
// If we are a point, our parent is the boss
switch ($this->role_id) {
case self::NODE_NN: // Normal Nodes -> Points
return $this->nodes_point;
case self::NODE_HC: // Hubs -> Normal Nodes
return $this->nodes_hub;
case self::NODE_NC: // Nets -> Normal Nodes, excluding Hub's Nodes
return $this->nodes_net->diff($this
->nodes_net
->filter(function($item) { return $item->role_id === Address::NODE_HC; })
->transform(function($item) { return $item->children(); })
->flatten());
case self::NODE_RC: // Regions, excluding NC's Nodes
return $this->nodes_region->diff($this
->nodes_region
->filter(function($item) { return $item->role_id === Address::NODE_NC; })
->transform(function($item) { return $item->nodes_net; })
->flatten());
case self::NODE_ZC: // Zones, excluding RC's Nodes
return $this->nodes_zone->diff($this
->nodes_zone
->filter(function($item) { return $item->role_id === Address::NODE_RC; })
->transform(function($item) { return $item->nodes_region; })
->flatten());
}
return new Collection;
}
/**
* Find who we should forward mail onto, taking into account session details that we have
*
* @return Collection
* @throws \Exception
*/
public function downlinks(): Collection
{
// We have no session data for this address, by definition it has no children
if (! $this->is_hosted && (! our_address()->pluck('id')->contains($this->id)))
return new Collection;
// If this system is not marked to default route for this address
if (! $this->is_default_route) {
$children = $this->children();
// We route everything for this domain
} else {
$children = self::select('addresses.*')
->join('zones',['zones.id'=>'addresses.zone_id'])
->where('addresses.id','<>',$this->id)
->where('domain_id',$this->zone->domain_id)
->with(['zone.domain'])
->active()
->FTNorder()
->get();
}
// If there are no children
if (! $children->count())
return new Collection;
// Exclude links and their children.
$exclude = collect();
foreach (our_nodes($this->zone->domain)->merge(our_address($this->zone->domain)) as $o) {
// If this address is in our list, remove it and it's children
if ($children->contains($o)) {
$exclude = $exclude->merge($o->children());
$exclude->push($o);
}
}
return $children->filter(function($item) use ($exclude) { return ! $exclude->pluck('id')->contains($item->id);});
}
/**
* List of all our nodes and their children
*
* @return \Illuminate\Support\Collection
* @throws \Exception
*/
public function downstream(): \Illuminate\Support\Collection
{
return $this->downlinks()->transform(function($item) {
return $item->nodes()->push($item);
})->flatten();
}
/**
* Files waiting to be sent to this system
*
* @return Collection
*/
public function dynamicWaiting(): Collection
{
return $this->dynamics()
->where('next_at','<=',Carbon::now())
->where('active',TRUE)
->get();
}
/**
* Echomail waiting to be sent to this system
*
* @return Builder
*/
public function echomailWaiting(): Builder
{
return Echomail::select('echomails.*')
->join('echomail_seenby',['echomail_seenby.echomail_id'=>'echomails.id'])
->where('address_id',$this->id)
->whereNull('echomails.deleted_at')
->whereNotNull('export_at')
->whereNull('sent_at')
->orderby('id')
->with([
'tagline:id,value',
'tearline:id,value',
'origin:id,value',
'echoarea:id,name,domain_id',
'echoarea.domain:id,name',
'fftn:id,zone_id,host_id,node_id,point_id',
'fftn.zone:id,domain_id,zone_id',
'fftn.zone.domain:id,name',
])
->dontCache();
}
/**
* Files waiting to be sent to this system
*
* @return Collection
*/
public function filesWaiting(): Collection
{
return File::select('files.*')
->join('file_seenby',['file_seenby.file_id'=>'files.id'])
->where('address_id',$this->id)
->whereNull('files.deleted_at')
->whereNotNull('export_at')
->whereNull('sent_at')
->orderby('id')
->with([
'filearea:id,name,domain_id',
'filearea.domain:id,name',
'fftn:id,zone_id,host_id,node_id,point_id',
'fftn.zone:id,domain_id,zone_id',
'fftn.zone.domain:id,name',
])
->get();
}
/**
* Work out what role this FTN should have
*
* @return int|null
*/
private function ftn_role(): ?int
{
$role = NULL;
// If we have a point address, we're a point
if ($this->point_id)
$role = self::NODE_POINT;
// If we have a node_id, we're either a Node or a Hub
elseif ($this->node_id) {
$role = ($this->nodes_hub->count())
? self::NODE_HC
: ((($this->role & Address::NODE_ALL) === self::NODE_HC) ? self::NODE_HC : self::NODE_NN);
// point_id and node_id are zero
// If our region_id !== host_id, and are not zero, and node_id/point_id === 0, we are an NC
} elseif (($this->region_id !== $this->host_id) && $this->host_id) {
$role = self::NODE_NC;
// point_id and node_id are zero
} elseif (($this->region_id === $this->host_id) && $this->host_id) {
$role = self::NODE_RC;
// point_id and node_id are zero
} elseif (($this->region_id === $this->host_id) && (! $this->host_id)) {
$role = self::NODE_ZC;
}
if (is_null($role))
Log::alert(sprintf('%s:! Address ROLE [%d] could not be determined for [%s]',self::LOGKEY,($this->role & Address::NODE_ALL),$this->ftn));
return $role;
}
/**
* Get echomail for this node
*
* @return Packet|null
* @throws \Exception
* @todo If we export to uplink hubs without our address in the seenby, they should send the message back to
* us with their seenby's.
*/
public function getEchomail(): ?Packet
{
if ($count=($num=$this->echomailWaiting())->count()) {
Log::info(sprintf('%s:= Got [%d] echomails for [%s] for sending',self::LOGKEY,$count,$this->ftn));
// Limit to max messages
if ($count > $this->system->pkt_msgs)
Log::notice(sprintf('%s:= Only sending [%d] echomails for [%s]',self::LOGKEY,$this->system->pkt_msgs,$this->ftn));
return $this->system->packet($this)->mail($num->take($this->system->pkt_msgs)->get());
}
return NULL;
}
/**
* Get netmail for this node (including it's children)
*
* @return Packet|null
* @throws \Exception
*/
public function getNetmail(): ?Packet
{
if ($count=($num=$this->netmailAlertWaiting())->count()) {
Log::info(sprintf('%s:= Packaging [%d] netmail alerts to [%s]',self::LOGKEY,$count,$this->ftn));
$msgs = $num->get();
// Find any message that defines a packet password
$pass = $msgs
->map(function($item) {
$passpos = strpos($item->subject,':');
return (($passpos > 0) && ($passpos <= 8)) ? substr($item->subject,0,$passpos) : NULL;
})
->filter()
->pop();
Log::debug(sprintf('%s:= Overwriting system packet password with [%s] for [%s]',self::LOGKEY,$pass,$this->ftn));
return $this->system->packet($this,$pass)->mail(
$msgs->filter(fn($item)=>preg_match("/^{$pass}:/",$item->subject))
->transform(function($item) use ($pass) {
$item->subject = preg_replace("/^{$pass}:/",'',$item->subject);
return $item;
})
);
}
if ($count=($num=$this->netmailWaiting())->count()) {
Log::info(sprintf('%s:= Got [%d] netmails for [%s] for sending',self::LOGKEY,$count,$this->ftn));
// Limit to max messages
if ($count > $this->system->pkt_msgs)
Log::alert(sprintf('%s:= Only sending [%d] netmails for [%s]',self::LOGKEY,$this->system->pkt_msgs,$this->ftn));
return $this->system->packet($this)->mail($num->take($this->system->pkt_msgs)->get());
}
return NULL;
}
public function isRoleOverride(int $role=NULL): bool
{
$val = ($this->role & self::NODE_ALL);
$role = $role ?: $this->ftn_role();
return ($val && ($role !== $val)) || (! $role);
}
/**
* Netmail waiting to be sent to this system
*
* @return Builder
* @throws \Exception
*/
public function netmailWaiting(): Builder
{
// Addresses that our downstream of this address, except anybody that has session details with us
$ours = our_nodes($this->zone->domain)->pluck('id');
$addresses = $this->downstream()
->filter(fn($item)=>! $ours->contains($item->id))
->merge($this->system->match($this->zone,Address::NODE_ALL));
$netmails = $this
->UncollectedNetmail()
->select('netmails.id')
->whereIn('addresses.id',$addresses->pluck('id'))
->groupBy(['netmails.id'])
->get();
return Netmail::whereIn('id',$netmails->pluck('id'));
}
/**
* Netmail alerts waiting to be sent to this system
*
* @return Builder
* @throws \Exception
* @note The packet password to use is on the subject line for these alerts
*/
public function netmailAlertWaiting(): Builder
{
$netmails = $this
->UncollectedNetmail()
->whereRaw(sprintf('(flags & %d) > 0',Message::FLAG_LOCAL))
->whereRaw(sprintf('(flags & %d) > 0',Message::FLAG_PKTPASSWD))
->whereRaw(sprintf('(flags & %d) = 0',Message::FLAG_SENT))
->select('netmails.id')
->whereIn('addresses.id',$this->downlinks()->add($this)->pluck('id'))
->groupBy(['netmails.id'])
->get();
return Netmail::whereIn('id',$netmails->pluck('id'));
}
public function nodes(): Collection
{
switch ($this->role_id) {
case self::NODE_NN:
return $this->nodes_point;
case self::NODE_HC:
return $this->nodes_hub;
case self::NODE_NC:
return $this->nodes_net;
case self::NODE_RC:
return $this->nodes_region;
case self::NODE_ZC:
return $this->nodes_zone;
default:
return new Collection;
}
}
/**
* Find the immediate parent for this node.
*
* @return Address|null
* @throws \Exception
*/
public function parent(): ?Address
{
// If we are a point, our parent is the boss
switch ($this->role_id) {
case self::NODE_POINT: // BOSS Node
return Address::active()
->where('zone_id',$this->zone_id)
->where('host_id',$this->host_id)
->where('node_id',$this->node_id)
->where('point_id',0)
->single();
case self::NODE_NN: // HUB if it exists, otherwise NC
if ($this->uplink_hub)
return $this->uplink_hub;
// Else fall through
case self::NODE_HC: // RC
return Address::active()
->where('zone_id',$this->zone_id)
->where('host_id',$this->host_id)
->where('node_id',0)
->where('point_id',0)
->single();
case self::NODE_NC: // RC
return Address::active()
->where('zone_id',$this->zone_id)
->where('region_id',$this->region_id)
->where('host_id',$this->region_id)
->where('node_id',0)
->where('point_id',0)
->single();
case self::NODE_RC: // ZC
return Address::active()
->where('zone_id',$this->zone_id)
->where('region_id',0)
->where('node_id',0)
->where('point_id',0)
->single();
default:
return NULL;
}
}
/**
* Retrieve the address session details/passwords
*
* @param string $type
* @return string|null
*/
private function session(string $type): ?string
{
return ($this->exists && ($x=$this->system->sessions->where('id',$this->zone_id)->first())) ? ($x->pivot->{$type} ?: '') : NULL;
}
/**
* Get the appropriate parent for this address, taking into account who we have session information with
*
* @return Address|$this|null
* @throws \Exception
*/
public function uplink(): ?Address
{
// If it is our address
if (our_address()->contains($this))
return NULL;
// If we have session password, then we are the parent
if ($this->is_hosted)
return $this;
if ($x=$this->parent()?->uplink()) {
return $x;
} else {
$sz = SystemZone::whereIn('zone_id',$this->domain->zones->pluck('id'))
->where('default',TRUE)
->single();
return $sz?->system->addresses->sortBy('security')->last();
}
}
}