Compare commits

...

2 Commits
master ... bbs

Author SHA1 Message Date
2dd7a6ebd3 Move ANSI* commands to BBS/
All checks were successful
Create Docker Image / Build Docker Image (x86_64) (push) Successful in 34s
Create Docker Image / Build Docker Image (arm64) (push) Successful in 1m41s
Create Docker Image / Final Docker Image Manifest (push) Successful in 10s
2024-05-28 12:46:06 +10:00
c9688ef373 BBS ported from vbbs 2024-05-28 12:44:55 +10:00
37 changed files with 5340 additions and 0 deletions

101
app/Classes/BBS/Control.php Normal file
View File

@ -0,0 +1,101 @@
<?php
namespace App\Classes\BBS;
use App\Classes\BBS\Control\EditFrame;
use App\Classes\BBS\Control\Register;
use App\Classes\BBS\Control\Telnet;
abstract class Control
{
const prefix = 'App\Classes\Control\\';
// Has this control class finished with input
protected bool $complete = FALSE;
// The server object that is running this control class
protected Server $so;
/**
* What is the state of the server outside of this control.
* Should only contain
* + mode = Mode to follow outside of the control method
* + action = Action to run after leaving the control method
*
* @var array
*/
public array $state = [];
abstract public function handle(string $read): string;
public static function factory(string $name,Server $so,array $args=[])
{
switch ($name) {
case 'editframe':
return new EditFrame($so,$args);
case 'register':
return new Register($so);
case 'telnet':
return new Telnet($so);
default:
$c = (class_exists($name)) ? $name : self::prefix.$name;
$o = class_exists($c) ? new $c($so,$args) : NULL;
$so->log('debug',sprintf(($o ? 'Executing: %s' : 'Class doesnt exist: %s'),$c));
return $o;
}
}
public function __construct(Server $so,array $args=[])
{
$this->so = $so;
// Boot control, preparing anything before keyboard entry
$this->boot();
$this->so->log('info',sprintf('Initialised control %s',get_class($this)));
}
public function __get(string $key): mixed
{
switch ($key) {
case 'complete':
return $this->complete;
case 'name':
return get_class($this);
default:
throw new \Exception(sprintf('%s:! Unknown key: %s',static::LOGKEY,$key));
}
}
// Default boot method if a child class doesnt have one.
protected function boot()
{
$this->state['mode'] = FALSE;
}
/**
* Has control completed?
* @deprecated use $this->complete;
*/
public function complete()
{
return $this->complete;
}
/**
* If completing an Action frame, this will be called to submit the data.
*
* Ideally this should be overridden in a child class.
*/
public function process()
{
$this->complete = TRUE;
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace App\Classes\BBS\Control;
use Illuminate\Support\Arr;
use App\Classes\BBS\Control;
use App\Classes\BBS\Frame;
use App\Classes\BBS\Server;
/**
* Class Edit Frame handles frame editing
*
* @package App\Classes\Control
*/
class EditFrame extends Control
{
private $x = 1;
private $y = 1;
// The frame applicable for this control (not the current rendered frame, thats in $so)
protected $fo = NULL;
public function __construct(Server $so,array $args=[])
{
if (! $args OR ! Arr::get($args,'fo') OR (! $args['fo'] instanceof Frame))
throw new \Exception('Missing frame to Edit');
$this->fo = $args['fo'];
parent::__construct($so);
}
protected function boot()
{
// Clear screen and setup edit.
$this->so->co->send(CLS.HOME.DOWN.CON);
// @todo Add page number + "EDIT" (prob only required for login pages which dont show page num)
$this->so->co->send($this->fo->raw().$this->so->moveCursor(1,2));
$this->updateBaseline();
}
public function handle(string $read): string
{
static $esc = FALSE;
static $brace = FALSE;
static $out = '';
static $key = '';
$out .= $read;
switch ($read)
{
case 'A':
if ($esc AND $brace)
{
$this->y--;
if ($this->y < 1) {
$this->y = 1;
$out = '';
}
$brace = $esc = FALSE;
}
break;
case 'B':
if ($esc AND $brace)
{
$this->y++;
if ($this->y > $this->fo->frame_length()) {
$this->y = $this->fo->frame_length();
$out = '';
}
$brace =$esc = FALSE;
}
break;
case 'C':
if ($esc AND $brace)
{
$this->x++;
if ($this->x > $this->fo->frame_width()) {
$this->x = $this->fo->frame_width();
$out = '';
}
$brace =$esc = FALSE;
}
break;
case 'D':
if ($esc AND $brace)
{
$this->x--;
if ($this->x < 1) {
$this->x = 1;
$out = '';
}
$brace = $esc = FALSE;
}
break;
case '[':
if ($esc)
$brace = TRUE;
break;
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '0':
if ($esc AND $brace) {
$key .= $read;
} else {
$this->x++;
}
break;
case '~':
if ($esc AND $brace)
{
switch ($key)
{
// F9 Pressed
case 20:
break;
// F10 Pressed
case 21:
$this->complete = TRUE;
$this->state = ['action'=>ACTION_GOTO,'mode'=>NULL];
break;
}
$brace = $esc = FALSE;
$key = '';
}
break;
case ESC;
$esc = TRUE;
break;
case LF: $this->y++; break;
case CR; $this->x = 1; break;
default:
if ($esc)
$esc = FALSE;
$this->x++;
}
if (! $esc)
{
printf(" . SENDING OUT: %s\n",$out);
$this->so->co->send($out);
$this->updateBaseline();
$out = '';
}
printf(" . X:%d,Y:%d,C:%s,ESC:%s\n",
$this->x,
$this->y,
(ord($read) < 32 ? '.' : $read),
($esc AND $brace) ? 'TRUE' : 'FALSE');
return $read;
}
public function updateBaseline()
{
$this->so->sendBaseline(
$this->so->co,
sprintf('%02.0f:%02.0f]%s'.RESET.'[',
$this->y,
$this->x,
($this->fo->attr($this->x,$this->y) != '-' ? ESC.'['.$this->fo->attr($this->x,$this->y) : '').$this->fo->char($this->x,$this->y),
)
);
}
public function process()
{
dump(__METHOD__);
}
}

View File

@ -0,0 +1,158 @@
<?php
namespace App\Classes\BBS\Control;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use App\Classes\BBS\Control;
use App\Mail\SendToken;
use App\Models\User;
/**
* Class Register handles registration
*
* @todo REMOVE the force .WHITE at the end of each sendBaseline()
* @package App\Classes\Control
*/
class Register extends Control
{
private $data = [];
protected function boot()
{
$this->so->sendBaseline($this->so->co,GREEN.'Select User Name'.WHITE);
}
/**
* Handle Registration Form Input
*
* This function assumes the form has 7 fields in a specific order.
*
* @todo Make this form more dynamic, or put some configuration in a config file, so that there is flexibility
* in field placement.
* @param string $read
* @param array $current
* @return string
*/
public function handle(string $read,array $current=[]): string
{
// Ignore LF (as a result of pressing ENTER)
if ($read == LF)
return '';
// If we got a # we'll be completing field input.
if ($read == HASH OR $read == CR) {
// Does our field have data...
if ($x=$this->so->fo->getFieldCurrentInput()) {
switch ($this->so->fo->getFieldId()) {
// Username
case 0:
// See if the requested username already exists
if (User::where('login',$x)->exists()) {
$this->so->sendBaseline($this->so->co,RED.'USER ALREADY EXISTS'.WHITE);
return '';
}
$this->so->sendBaseline($this->so->co,GREEN.'Enter Real Name'.WHITE);
break;
// Real Name
case 1:
//$this->data['name'] = $x;
$this->so->sendBaseline($this->so->co,GREEN.'Enter Email Address'.WHITE);
break;
// Email Address
case 2:
if (Validator::make(['email'=>$x],[
'email'=>'email',
])->fails()) {
$this->so->sendBaseline($this->so->co,RED.'INVALID EMAIL ADDRESS'.WHITE);
return '';
};
// See if the requested email already exists
if (User::where('email',$x)->exists()) {
$this->so->sendBaseline($this->so->co,RED.'USER ALREADY EXISTS'.WHITE);
return '';
}
$this->data['email'] = $x;
$this->data['token'] = sprintf('%06.0f',rand(0,999999));
$this->so->sendBaseline($this->so->co,YELLOW.'PROCESSING...'.WHITE);
Mail::to($this->data['email'])->sendNow(new SendToken($this->data['token']));
if (Mail::failures()) {
dump('Failure?');
dump(Mail::failures());
}
$this->so->sendBaseline($this->so->co,GREEN.'Enter Password'.WHITE);
break;
// Enter Password
case 3:
$this->data['password'] = $x;
$this->so->sendBaseline($this->so->co,GREEN.'Confirm Password'.WHITE);
break;
// Confirm Password
case 4:
if ($this->data['password'] !== $x) {
$this->so->sendBaseline($this->so->co,RED.'PASSWORD DOESNT MATCH, *09 TO START AGAIN'.WHITE);
return '';
}
$this->so->sendBaseline($this->so->co,GREEN.'Enter Location'.WHITE);
break;
// Enter Location
case 5:
$this->so->sendBaseline($this->so->co,GREEN.'Enter TOKEN emailed to you'.WHITE);
break;
// Enter Token
case 6:
if ($this->data['token'] !== $x) {
$this->so->sendBaseline($this->so->co,RED.'TOKEN DOESNT MATCH, *09 TO START AGAIN'.WHITE);
return '';
}
$this->complete = TRUE;
break;
default:
$this->so->sendBaseline($this->so->co,RED.'HUH?');
}
} else {
// If we are MODE_BL, we need to return the HASH, otherwise nothing.
if (in_array($this->state['mode'],[MODE_BL,MODE_SUBMITRF,MODE_RFNOTSENT])) {
return $read;
} else {
$this->so->sendBaseline($this->so->co,RED.'FIELD REQUIRED...'.WHITE);
return '';
}
}
}
return $read;
}
}

View File

@ -0,0 +1,199 @@
<?php
namespace App\Classes\BBS\Control;
use App\Classes\BBS\Control;
/**
* Class Telnet
*
* This class looks after any telnet session commands
*
* TELNET http://pcmicro.com/netfoss/telnet.html
*
* @package App\Classes\Control
*/
final class Telnet extends Control
{
protected const LOGKEY = 'CT-';
/** @var int Data Byte */
public const TCP_IAC = 0xff;
/** @var int Indicates the demand that the other party stop performing, or confirmation that you are no
longer expecting the other party to perform, the indicated option */
public const TCP_DONT = 0xfe;
/** @var int Indicates the request that the other party perform, or confirmation that you are expecting
the other party to perform, the indicated option. */
public const TCP_DO = 0xfd;
/** @var int Indicates the refusal to perform, or continue performing, the indicated option. */
public const TCP_WONT = 0xfc;
/** @var int Indicates the desire to begin performing, or confirmation that you are now performing, the indicated option. */
public const TCP_WILL = 0xfb;
/** @var int Indicates that what follows is sub-negotiation of the indicated option. */
public const TCP_SB = 0xfa;
/** @var int The GA signal. */
public const TCP_GA = 0xf9;
/** @var int Erase Line. */
public const TCP_EL = 0xf8;
/** @var int Erase character. */
public const TCP_EC = 0xf7;
/** @var int Are you there? */
public const TCP_AYT = 0xf6;
/** @var int About output */
public const TCP_AO = 0xf5;
/** @var int Interrupt Process. */
public const TCP_IP = 0xf4;
/** @var int Break. */
public const TCP_BREAK = 0xf3;
/** @var int The data stream portion of a Synch. This should always be accompanied by a TCP Urgent notification. */
public const TCP_DM = 0xf2;
/** @var int No operation. */
public const TCP_NOPT = 0xf1;
/** @var int End of sub-negotiation parameters. */
public const TCP_SE = 0xf0;
public const TCP_BINARY = 0x00;
public const TCP_OPT_ECHO = 0x01;
public const TCP_OPT_SUP_GOAHEAD = 0x03;
public const TCP_OPT_TERMTYPE = 0x18;
public const TCP_OPT_WINDOWSIZE = 0x1f;
public const TCP_OPT_LINEMODE = 0x22;
private bool $option = FALSE;
private string $note;
private string $terminal = '';
public static function send_iac($key): string
{
$send = chr(self::TCP_IAC);
switch ($key) {
case 'are_you_there':
$send .= chr(self::TCP_AYT);
break;
case 'do_echo':
$send .= chr(self::TCP_DO).chr(self::TCP_OPT_ECHO);
break;
case 'dont_echo':
$send .= chr(self::TCP_DONT).chr(self::TCP_OPT_ECHO);
break;
case 'will_echo':
$send .= chr(self::TCP_WILL).chr(self::TCP_OPT_ECHO);
break;
case 'wont_echo':
$send .= chr(self::TCP_WONT).chr(self::TCP_OPT_ECHO);
break;
case 'do_opt_termtype':
$send .= chr(self::TCP_DO).chr(self::TCP_OPT_TERMTYPE);
break;
case 'do_suppress_goahead':
$send .= chr(self::TCP_DO).chr(self::TCP_OPT_SUP_GOAHEAD);
break;
case 'sn_end':
$send .= chr(self::TCP_SE);
break;
case 'sn_start':
$send .= chr(self::TCP_SB);
break;
case 'wont_linemode':
$send .= chr(self::TCP_WONT).chr(self::TCP_OPT_LINEMODE);
break;
case 'will_xmit_binary':
$send .= chr(self::TCP_WILL).chr(self::TCP_BINARY);
break;
default:
throw new \Exception(sprintf('%s:! Unknown key: %s',$key));
}
return $send;
}
public function handle(string $read): string
{
$this->so->log('debug',sprintf('%s:+ Session Char [%02x] (%c)',self::LOGKEY,ord($read),$read),['complete'=>$this->complete,'option'=>$this->option]);
switch (ord($read)) {
// Command being sent.
case self::TCP_IAC:
$this->complete = FALSE;
$this->note = 'IAC ';
break;
case self::TCP_SB:
$this->option = TRUE;
break;
case self::TCP_SE:
$this->option = FALSE;
$this->complete = TRUE;
$this->so->log('debug',sprintf('%s:%% Session Terminal: %s',self::LOGKEY,$this->terminal));
break;
case self::TCP_DO:
$this->note .= 'DO ';
break;
case self::TCP_WILL:
$this->note .= 'WILL ';
break;
case self::TCP_WONT:
$this->note .= 'WONT ';
break;
case self::TCP_OPT_TERMTYPE:
break;
case self::TCP_OPT_ECHO:
$this->note .= 'ECHO';
$this->complete = TRUE;
$this->so->log('debug',sprintf('%s:%% Session Note: [%s]',self::LOGKEY,$this->note));
break;
case self::TCP_OPT_SUP_GOAHEAD:
$this->note .= 'SUPPRESS GO AHEAD';
$this->complete = TRUE;
$this->so->log('debug',sprintf('%s:%% Session Note: [%s]',self::LOGKEY,$this->note));
break;
case self::TCP_OPT_WINDOWSIZE:
$this->note .= 'WINDOWSIZE';
$this->complete = TRUE;
$this->so->log('debug',sprintf('%s:%% Session Note: [%s]',self::LOGKEY,$this->note));
break;
default:
if ($this->option && $read)
$this->terminal .= $read;
else
$this->so->log('debug',sprintf('%s:= Unhandled char in session_init: [%02x] (%c)',self::LOGKEY,ord($read),$read));
}
if ($this->complete)
$this->so->log('debug',sprintf('%s:= TELNET control COMPLETE',self::LOGKEY));
return '';
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Classes\BBS\Control;
use App\Classes\BBS\Control;
/**
* Class Test
*
* This is a test class for Control Validation Processing
*
* @package App\Classes\Control
*/
class Test extends Control
{
public function boot()
{
$this->so->co->send(CLS.HOME.DOWN.CON);
$this->so->co->send('Press 1, or 2, or 4, 0 to end.');
}
// @todo *00/09 doesnt work
public function handle(string $read): string
{
switch ($read)
{
case 0:
$this->complete = TRUE;
$read = '';
break;
case 1:
$this->so->co->send('You pressed ONE.');
$read = '';
break;
case 2:
$this->so->co->send('You pressed TWO.');
$read = '';
break;
case 3:
$this->so->co->send('You pressed THREE.');
$read = '';
break;
case 4:
$this->so->co->send('You pressed FOUR.');
$read = '';
break;
}
return $read;
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Classes\BBS\Exceptions;
use Exception;
class ActionMissingInputsException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Classes\BBS\Exceptions;
use Exception;
class InvalidPasswordException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Classes\BBS\Exceptions;
use Exception;
class NoRouteException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Classes\BBS\Exceptions;
use Exception;
class ParentNotFoundException extends Exception
{
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Classes\BBS\Frame;
use Illuminate\Support\Collection;
use App\Classes\BBS\Exceptions\ActionMissingInputsException;
use App\Classes\BBS\Frame\Action\{Login,Register};
use App\Classes\BBS\Server;
use App\Models\User;
abstract class Action
{
private Collection $fields_input;
protected User $uo;
public const actions = [
'login' => Login::class,
'register' => Register::class,
];
protected const fields = [];
abstract public function handle(): bool;
abstract public function preSubmitField(Server $server,Field $field): ?string;
public static function factory(string $class): self
{
if (array_key_exists($class,self::actions)) {
$class = self::actions[$class];
return new $class;
}
throw new \Exception(sprintf('Call to action [%s] doesnt have a class to execute',$class));
}
public function __get(string $key): mixed
{
switch ($key) {
case 'fields_input':
return $this->{$key};
default:
if (($x=$this->fields_input->search(function($item) use ($key) { return $item->name === $key; })) !== FALSE)
return $this->fields_input->get($x)->value;
else
return NULL;
}
}
public function __set(string $key,mixed $value): void
{
switch ($key) {
case 'fields_input':
$this->{$key} = $value;
break;
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function init(): void
{
if (! isset($this->fields_input))
throw new \Exception(sprintf('Missing fields_input in [%s]',get_class($this)));
// First field data element is user, the second is the password
if (count($x=collect(static::fields)->diff($this->fields_input->pluck('name'))))
throw new ActionMissingInputsException(sprintf('Login missing %s',$x->join(',')));
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Classes\BBS\Frame\Action;
use Illuminate\Support\Facades\Hash;
use App\Classes\BBS\Exceptions\{ActionMissingInputsException,InvalidPasswordException};
use App\Classes\BBS\Frame\{Action,Field};
use App\Classes\BBS\Server;
use App\Models\User;
class Login extends Action
{
protected const fields = ['USER','PASS'];
public function __get(string $key): mixed
{
switch ($key) {
case 'user': return $this->uo;
default:
return parent::__get($key);
}
}
/**
* Handle user logins
*
* @return bool
* @throws ActionMissingInputsException
* @throws InvalidPasswordException
*/
public function handle(): bool
{
parent::init();
$this->uo = User::where('name',$this->USER)->orWhere('alias',$this->USER)->firstOrFail();
if (! Hash::check($this->PASS,$this->uo->password))
throw new InvalidPasswordException(sprintf('Password doesnt match for [%s]',$this->USER));
return TRUE;
}
public function preSubmitField(Server $server,Field $field): ?string
{
// Noop
return NULL;
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Classes\BBS\Frame\Action;
use Carbon\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use App\Classes\BBS\Frame\{Action,Field};
use App\Classes\BBS\Exceptions\ActionMissingInputsException;
use App\Classes\BBS\Server;
use App\Mail\BBS\SendToken;
use App\Models\User;
/**
* Class Register
* This handles the data received for account registration
*
* @package App\Classes\Frame\Action
*/
class Register extends Action
{
protected const fields = ['EMAIL','USER','PASS','FULLNAME','TOKEN'];
private string $token = '';
public function __get(string $key): mixed
{
switch ($key) {
case 'user': return $this->uo;
default:
return parent::__get($key);
}
}
/**
* Handle user logins
*
* @return bool
* @throws ActionMissingInputsException
*/
public function handle(): bool
{
parent::init();
$this->uo = new User;
$this->uo->name = $this->fields_input->where('name','FULLNAME')->first()->value;
$this->uo->email = $this->fields_input->where('name','EMAIL')->first()->value;
$this->uo->email_verified_at = Carbon::now();
$this->uo->password = Hash::make($x=$this->fields_input->where('name','PASS')->first()->value);
$this->uo->active = TRUE;
$this->uo->last_on = Carbon::now();
$this->uo->alias = $this->fields_input->where('name','USER')->first()->value;
$this->uo->save();
return TRUE;
}
public function preSubmitField(Server $server,Field $field): ?string
{
switch ($field->name) {
// Send a token
case 'EMAIL':
// Make sure we got an email address
if (Validator::make(['email'=>$field->value],[
'email'=>'email',
])->fails()) {
return 'INVALID EMAIL ADDRESS';
}
// See if the requested email already exists
if (User::where('email',$field->value)->exists())
return 'USER ALREADY EXISTS';
Log::info(sprintf('Sending token to [%s]',$field->value));
$server->sendBaseline(RED.'SENDING TOKEN...');
$this->token = sprintf('%06.0f',rand(0,999999));
$sent = Mail::to($field->value)->send(new SendToken($this->token));
$server->sendBaseline(RED.'SENT');
break;
case 'USER':
if (str_contains($field->value,' '))
return 'NO SPACES IN USER NAMES';
// See if the requested username already exists
if (User::where('alias',$field->value)->exists())
return 'USER ALREADY EXISTS';
// Clear the baseline from EMAIL entry
$server->sendBaseline('');
break;
case 'TOKEN':
if ($field->value !== $this->token)
return 'INVALID TOKEN';
break;
}
return NULL;
}
}

View File

@ -0,0 +1,290 @@
<?php
namespace App\Classes\BBS\Frame;
use App\Classes\BBS\Page\{Ansi,Viewdata};
use App\Models\BBS\Mode;
class Char {
/** @var int|null Attributes for the character (ie: color) */
private ?int $attr;
/** @var string|null Character to be shown */
private ?string $ch;
public function __construct(string $ch=NULL,int $attr=NULL)
{
$this->ch = $ch;
$this->attr = $attr;
}
public function __get(string $key): mixed
{
switch ($key) {
case 'attr': return $this->attr;
case 'ch': return $this->ch;
default:
throw new \Exception('Unknown key:'.$key);
}
}
public function __isset($key): bool
{
return isset($this->{$key});
}
public function __set(string $key,mixed $value): void
{
switch ($key) {
case 'ch':
if (strlen($value) !== 1)
throw new \Exception(sprintf('CH can only be 1 char: [%s]',$value));
$this->{$key} = $value;
break;
default:
throw new \Exception('Unknown key:'.$key);
}
}
public function __toString()
{
return sprintf('%04x [%s]|',$this->attr,$this->ch);
}
/**
* Return the color codes required to draw the current character
*
* @param Mode $mo Service we are rendering for
* @param int|null $last last rendered char
* @param bool $debug debug mode
* @return string|NULL
* @throws \Exception
*/
public function attr(Mode $mo,int $last=NULL,bool $debug=FALSE): string|NULL
{
$ansi = collect();
if ($debug)
dump('- last:'.$last.', this:'.$this->attr);
switch ($mo->name) {
case 'ansi':
if ($debug) {
dump(' - this BG_BLACK:'.($this->attr & Ansi::BG_BLACK));
dump(' - last BG_BLACK:'.($last & Ansi::BG_BLACK));
dump(' - this HIGH:'.($this->attr & Ansi::HIGH));
dump(' - last HIGH:'.($last & Ansi::HIGH));
dump(' - this BLINK:'.($this->attr & Ansi::BLINK));
dump(' - last BLINK:'.($last & Ansi::BLINK));
}
// If high was in the last, and we dont have high now, we need 0, but we need to turn back on flash if it was there
// If flash was in the last, and we dont have flash now, we need to 0 but we need to turn on high if it was there
$reset = FALSE;
if ((($this->attr & Ansi::BG_BLACK) && (! ($last & Ansi::BG_BLACK)))
|| ((! ($this->attr & Ansi::BLINK)) && ($last & Ansi::BLINK))
|| ((! ($this->attr & Ansi::HIGH)) && ($last & Ansi::HIGH)))
{
$ansi->push(Ansi::I_CLEAR_CODE);
$reset = TRUE;
$last = Ansi::BG_BLACK|Ansi::LIGHTGRAY;
}
if (($this->attr & Ansi::HIGH)
&& ((($this->attr & Ansi::HIGH) !== ($last & Ansi::HIGH)) || ($reset && ($last & Ansi::HIGH)))) {
$ansi->push(Ansi::I_HIGH_CODE);
}
if (($this->attr & Ansi::BLINK)
&& ((($this->attr & Ansi::BLINK) !== ($last & Ansi::BLINK)) || ($reset && ($last & Ansi::BLINK)))) {
$ansi->push(Ansi::I_BLINK_CODE);
}
$c = ($this->attr & 0x07);
$l = ($last & 0x07);
// Foreground
switch ($c) {
case Ansi::BLACK:
$r = Ansi::FG_BLACK_CODE;
break;
case Ansi::RED:
$r = Ansi::FG_RED_CODE;
break;
case Ansi::GREEN:
$r = Ansi::FG_GREEN_CODE;
break;
case Ansi::BROWN:
$r = Ansi::FG_BROWN_CODE;
break;
case Ansi::BLUE:
$r = Ansi::FG_BLUE_CODE;
break;
case Ansi::MAGENTA:
$r = Ansi::FG_MAGENTA_CODE;
break;
case Ansi::CYAN:
$r = Ansi::FG_CYAN_CODE;
break;
case Ansi::LIGHTGRAY:
$r = Ansi::FG_LIGHTGRAY_CODE;
break;
}
if ($r && ($c !== $l))
$ansi->push($r);
// Background
if ($this->attr & 0x70) {
$c = ($this->attr & 0x70);
$l = ($last & 0x70);
switch ($this->attr & 0x70) {
case Ansi::BG_BLACK:
$r = Ansi::BG_BLACK_CODE;
break;
case Ansi::BG_RED:
$r = Ansi::BG_RED_CODE;
break;
case Ansi::BG_GREEN:
$r = Ansi::BG_GREEN_CODE;
break;
case Ansi::BG_BROWN:
$r = Ansi::BG_BROWN_CODE;
break;
case Ansi::BG_BLUE:
$r = Ansi::BG_BLUE_CODE;
break;
case Ansi::BG_MAGENTA:
$r = Ansi::BG_MAGENTA_CODE;
break;
case Ansi::BG_CYAN:
$r = Ansi::BG_CYAN_CODE;
break;
case Ansi::BG_LIGHTGRAY:
$r = Ansi::BG_LIGHTGRAY_CODE;
break;
}
if ($r && ($c !== $l))
$ansi->push($r);
}
if ($debug)
dump([' - ansi:' =>$ansi]);
return $ansi->count() ? sprintf('%s[%sm',($debug ? '': "\x1b"),$ansi->join(';')) : NULL;
case 'viewdata':
if ($debug)
dump(sprintf('Last: %02x, Attr: %02x',$last,$this->attr));
switch ($this->attr) {
// \x08
case Viewdata::BLINK:
$r = Viewdata::I_BLINK_CODE;
break;
// \x09
case Viewdata::STEADY:
$r = Viewdata::I_STEADY;
break;
// \x0c
case Viewdata::NORMAL:
$r = Viewdata::I_NORMAL;
break;
// \x0d
case Viewdata::DOUBLE:
$r = Viewdata::I_DOUBLE_CODE;
break;
// \x18
case Viewdata::CONCEAL:
$r = Viewdata::I_CONCEAL;
break;
// \x19
case Viewdata::BLOCKS:
$r = Viewdata::I_BLOCKS;
break;
// \x1a
case Viewdata::SEPARATED:
$r = Viewdata::I_SEPARATED;
break;
// \x1c
case Viewdata::BLACKBACK:
$r = Viewdata::I_BLACKBACK;
break;
// \x1d
case Viewdata::NEWBACK:
$r = Viewdata::I_NEWBACK;
break;
// \x1e
case Viewdata::HOLD:
$r = Viewdata::I_HOLD;
break;
// \x1f
case Viewdata::RELEASE:
$r = Viewdata::I_REVEAL;
break;
// Not handled
// \x0a-b,\x0e-f,\x1b
case 0xff00:
dump($this->attr);
break;
default:
$mosiac = ($this->attr & Viewdata::MOSIAC);
$c = ($this->attr & 0x07);
if ($debug)
dump(sprintf('Last: %02x, Attr: %02x, Color: %02x',$last,$this->attr,$c));
// Color control \x00-\x07, \x10-\x17
switch ($c) {
/*
case Viewdata::BLACK:
$r = Viewdata::FG_BLACK_CODE;
break;
*/
case Viewdata::RED:
$r = $mosiac ? Viewdata::MOSIAC_RED_CODE : Viewdata::FG_RED_CODE;
break;
case Viewdata::GREEN:
$r = $mosiac ? Viewdata::MOSIAC_GREEN_CODE : Viewdata::FG_GREEN_CODE;
break;
case Viewdata::YELLOW:
$r = $mosiac ? Viewdata::MOSIAC_YELLOW_CODE : Viewdata::FG_YELLOW_CODE;
break;
case Viewdata::BLUE:
$r = $mosiac ? Viewdata::MOSIAC_BLUE_CODE : Viewdata::FG_BLUE_CODE;
break;
case Viewdata::MAGENTA:
$r = $mosiac ? Viewdata::MOSIAC_MAGENTA_CODE : Viewdata::FG_MAGENTA_CODE;
break;
case Viewdata::CYAN:
$r = $mosiac ? Viewdata::MOSIAC_CYAN_CODE : Viewdata::FG_CYAN_CODE;
break;
case Viewdata::WHITE:
$r = $mosiac ? Viewdata::MOSIAC_WHITE_CODE : Viewdata::FG_WHITE_CODE;
break;
default:
if ($debug)
dump('Not a color?:'.$c);
return NULL;
}
}
if ($debug)
dump(sprintf('= result: ESC[%s](%02x) for [%s]',chr($r),$r,$this->ch));
return chr($r);
default:
throw new \Exception($this->type.': has not been implemented');
}
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Classes\BBS\Frame;
use Illuminate\Support\Arr;
final class Field
{
private array $attributes = [];
private const attributes = [
'attribute', // Color attribute when rendering values
'pad', // Pad character remaining characters up to length
'size', // Size of the field
'name', // Field name
'type', // Type of field
'value', // Current value
'x', // X position in the frame
'y', // Y position in the frame
];
/** @var string[] Attributes that should be masked */
private const mask = [
'p',
];
private const mask_attribute = '*';
public function __construct(array $values)
{
array_walk($values,function($value,$key) {
$this->{$key} = $value;
});
}
public function __get($key): mixed
{
switch ($key) {
case 'can_add':
return strlen($this->value) < $this->size;
case 'mask':
return in_array($this->type,self::mask) ? '*' : NULL;
case 'X':
return $this->x+strlen($this->value);
default:
return Arr::get($this->attributes,$key);
}
}
public function __isset($key): bool
{
return isset($this->attributes[$key]);
}
public function __set($key,$value): void
{
if (! in_array($key,self::attributes))
throw new \Exception('Unknown attribute key:'.$key);
$this->attributes[$key] = $value;
}
/**
* Append a char to the value, only if there is space to do so
*
* @param string $char
* @return bool
*/
public function append(string $char): bool
{
if (is_null($this->value))
$this->clear();
if ($this->can_add) {
$this->value .= $char;
return TRUE;
}
return FALSE;
}
/**
* Clear the field value
*
* @return void
*/
public function clear(): void
{
$this->value = '';
}
/**
* Delete a character from the value, only if there are chars to do so
*
* @return bool
*/
public function delete(): bool
{
if (strlen($this->value)) {
$this->value = substr($this->value,0,-1);
return TRUE;
}
return FALSE;
}
}

628
app/Classes/BBS/Page.php Normal file
View File

@ -0,0 +1,628 @@
<?php
namespace App\Classes\BBS;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use App\Classes\BBS\Exceptions\{NoRouteException,ParentNotFoundException};
use App\Classes\BBS\Frame\{Action,Field};
use App\Models\BBS\{Frame,Mode};
use App\Models\User;
/**
* The current page object
*
* @property page The full page number requested
*/
abstract class Page
{
/**
* Color attributes can fit in an int
* + Bit 0-2 off = Black foreground
* + Foreground colors bits (0-2)
* + High Intensity 1 bit (3)
* + Bit 4-6 off = Black background
* + Background colors bits (4-6)
* + Flash 1 bit (7)
*/
public const BLINK = 1<<7; /* blink bit */
public const HIGH = 1<<3; /* high intensity (bright) foreground bit */
/* foreground colors */
public const BLACK = 0; /* dark colors (HIGH bit unset) */
public const BLUE = 1;
public const GREEN = 2;
public const CYAN = 3;
public const RED = 4;
public const MAGENTA = 5;
public const BROWN = 6;
public const LIGHTGRAY = 7;
public const DARKGRAY = self::HIGH | self::BLACK; /* light colors (HIGH bit set) */
public const LIGHTBLUE = self::HIGH | self::BLUE;
public const LIGHTGREEN = self::HIGH | self::GREEN;
public const LIGHTCYAN = self::HIGH | self::CYAN;
public const LIGHTRED = self::HIGH | self::RED;
public const LIGHTMAGENTA = self::HIGH | self::MAGENTA;
public const YELLOW = self::HIGH | self::BROWN;
public const WHITE = self::HIGH | self::LIGHTGRAY;
public const BG_BLACK = 0x100; /* special value for ansi() */
public const BG_BLUE = (self::BLUE<<4);
public const BG_GREEN = (self::GREEN<<4);
public const BG_CYAN = (self::CYAN<<4);
public const BG_RED = (self::RED<<4);
public const BG_MAGENTA = (self::MAGENTA<<4);
public const BG_BROWN = (self::BROWN<<4);
public const BG_LIGHTGRAY = (self::LIGHTGRAY<<4);
public const FRAMETYPE_INFO = 'i';
public const FRAMETYPE_ACTION = 'a';
public const FRAMETYPE_RESPONSE = 'r';
public const FRAMETYPE_LOGIN = 'l';
public const FRAMETYPE_TERMINATE = 't';
public const FRAMETYPE_EXTERNAL = 'x';
private int $frame;
private string $index;
/** @var Mode Our BBS mode model object */
protected Mode $mo;
/** @var Frame|null Our frame model object */
private ?Frame $fo = NULL;
/** @var Collection Users page retrieval history */
private Collection $history;
/* Our window layout */
protected Window $layout;
private Window $content;
private Window $header;
private Window $provider;
private Window $pagenum;
private Window $unit;
private bool $showheader = FALSE;
/** @var array Our compiled page */
protected array $build;
/* Fields */
// Current field being edited
private ?int $field_active = NULL;
/** @var Collection Dynamic fields that are pre-populated with system data */
protected Collection $fields_dynamic;
/** @var Collection Input fields take input from the user */
protected Collection $fields_input;
protected bool $debug;
abstract public function attr(array $field): string;
abstract public function parse(string $contents,int $width,int $yoffset=0,int $xoffset=0,?int $debug=NULL): array;
abstract public static function strlenv($text): int;
public function __construct(int $frame,string $index='a',bool $debug=FALSE)
{
$this->debug = $debug;
$this->layout = new Window(1,1,static::FRAME_WIDTH,static::FRAME_HEIGHT+1,'LAYOUT',NULL,$debug);
$this->header = new Window(1,1,static::FRAME_WIDTH,1,'HEADER',$this->layout,$debug);
//dump(['this'=>get_class($this),'header_from'=>$this->header->x,'header_to'=>$this->header->bx,'width'=>$this->header->width]);
// Provider can use all its space
$this->provider = new Window(1,1,static::FRAME_PROVIDER_LENGTH,1,'PROVIDER',$this->header,$debug);
//dump(['this'=>get_class($this),'provider_from'=>$this->provider->x,'provider_to'=>$this->provider->bx,'width'=>$this->provider->width]);
// Page number is prefixed with a color change (if required, otherwise a space)
$this->pagenum = new Window($this->provider->bx+1,1,static::FRAME_PAGE_LENGTH,1,'#',$this->header,$debug);
//dump(['this'=>get_class($this),'pagenum_from'=>$this->pagenum->x,'pagenum_to'=>$this->pagenum->bx,'width'=>$this->pagenum->width]);
// Unit is prefixed with a color change (required, since a different color to page)
$this->unit = new Window($this->pagenum->bx+1,1,static::FRAME_COST_LENGTH,1,'$',$this->header,$debug);
//dump(['this'=>get_class($this),'unit_from'=>$this->unit->x,'unit_to'=>$this->unit->bx,'width'=>$this->unit->width]);
$this->content = new Window(1,2,static::FRAME_WIDTH,static::FRAME_HEIGHT,'CONTENT',$this->layout,$debug);
$this->resetHistory();
$this->clear();
$this->goto($frame,$index);
}
public function __get(string $key): mixed
{
switch ($key) {
case 'access' :
case 'id' :
case 'cls':
case 'cost':
case 'created_at':
case 'public' :
case 'type' :
return $this->fo?->{$key};
case 'cug': return $this->fo?->cug_id;
case 'frame':
case 'index':
return $this->{$key};
case 'next': return ($this->index < 'z') ? chr(ord($this->index)+1) : $this->index;
case 'prev': return ($this->index > 'a') ? chr(ord($this->index)-1) : $this->index;
case 'page': return sprintf('%d%s',$this->frame,$this->index);
case 'height': return $this->layout->height;
case 'width': return $this->layout->width;
case 'fields_input': return $this->fields_input;
case 'field_current': return (! is_null($this->field_active)) ? $this->fields_input->get($this->field_active): NULL;
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function __set(string $key,mixed $value): void
{
switch ($key) {
case 'showheader':
$this->{$key} = $value;
break;
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function __toString(): string
{
return $this->display()->join("");
}
/* METHODS */
/**
* Return a list of alternative versions of this frame.
*
* @todo: Need to adjust to not include access=0 frames unless owner
*/
public function alts(): Collection
{
return Frame::where('frame',$this->frame)
->where('index',$this->index)
->where('id','<>',$this->fo->id)
->where('mode_id',$this->id)
->where('access',1)
->limit(9)
->get();
}
private function atcode(string $name,int $length,mixed $pad=' '): string
{
switch ($name) {
case 'NODE':
$result = '00010001';
break;
case 'DATETIME':
$result = Carbon::now()->toRfc822String();
break;
case 'DATE':
$result = Carbon::now()->format('Y-m-d');
break;
case 'TIME':
$result = Carbon::now()->format('H:ia');
break;
default:
$result = $name;
}
if (strlen($result) < abs($length) && $pad)
$result = ($length < 0)
? Str::padLeft($result,abs($length),$pad)
: Str::padRight($result,abs($length),$pad);
return $result;
}
/**
* History go back to previous page
*
* @return bool
*/
public function back(): bool
{
if ($this->history->count() > 1) {
$this->history->pop();
$this->fo = $this->history->last();
return TRUE;
}
return FALSE;
}
/**
* Parse a page, extracting fields and formatting into our Window objects
*
* @param bool $force
* @return array
* @throws \Exception
*/
public function build(bool $force=FALSE): array
{
if ($this->build && ! $force)
throw new \Exception('Refusing to build without force.');
$this->load();
$test = FALSE;
$this->provider->content = $this->parse(($test ? chr(0x02).'T'.chr(0x03).'B'.chr(0x04) : 'TB').'A'.($test ? ' - 12345678901234567890123456789012345678901234567890123456' : ''),static::FRAME_PROVIDER_LENGTH,$this->provider->y,$this->provider->x);
$this->pagenum->content = $this->parse($this->color_page.($test ? '123456789012345a' : $this->page),static::FRAME_SPACE+static::FRAME_PAGE_LENGTH,$this->pagenum->y,$this->pagenum->x);
$this->unit->content = $this->parse($this->color_unit.Str::padLeft(($this->cost+($test ? 1234 : 0)).'c',static::FRAME_COST_LENGTH-1,' '),static::FRAME_SPACE+static::FRAME_COST_LENGTH,$this->unit->y,$this->unit->x);
$this->content->content = $this->parse($this->fo->content,static::FRAME_WIDTH,$this->content->y,$this->content->x);
$this->header->visible = ($this->showheader || $test);
$this->build_system_fields();
$this->build = $this->layout->build(1,1,$this->debug);
// Add our dynamic values
$fields = $this->fields_dynamic->filter(function($item) { return $item->value; });
Log::channel('bbs')->debug(sprintf('There are [%d] dynamic fields to populate',$fields->count()));
if ($fields->count())
$this->fields_insert($fields);
// Add our input fields
$fields = $this->fields_input->filter(function($item) { return is_null($item->value); });
Log::channel('bbs')->debug(sprintf('There are [%d] input fields to setup',$fields->count()));
if ($fields->count())
$this->fields_insert($fields);
return $this->build;
}
// @todo To complete - some of these came from SBBS and are not valid here
private function build_system_fields(): void
{
// Fields we can process automatically
$auto = ['NODE','DATETIME','DATE','TIME','REALNAME','BBS'];
$df = $this->fields_dynamic->filter(function($item) { return is_null($item->value); });
if (! $df->count())
return;
foreach ($df as $field) {
if (in_array($field->name,$auto))
$this->field_dynamic($field->name,$this->atcode($field->name,$field->size,$field->pad));
}
}
private function clear(): void
{
$this->build = [];
$this->fields_dynamic = collect();
$this->fields_input = collect();
$this->fieldReset();
}
// Insert our *_field data (if it is set)
public function display(): Collection
{
if (! $this->build)
throw new \Exception('Page not ready');
// build
$display = $this->build;
// populate dynamic fields - refresh dynamic fields if 09, otherwise show previous compiled with 00
// check if there are any dynamic fields with no values
switch ($this->mo->name) {
case 'ansi':
$new_line = NULL;
$shownullchars = TRUE;
break;
case 'viewdata':
$new_line = static::BG_BLACK|static::WHITE;
$shownullchars = FALSE;
break;
default:
throw new \Exception(sprintf('Dont know how to display a [%s] page',$this->mo->name));
}
$result = collect();
$last = $new_line;
if ($this->debug)
dump(['page-width'=>$this->width,'page-height'=>$this->height]);
// render
for ($y=1;$y<=$this->height;$y++) {
$line = '';
if ($new_line)
$last = $new_line;
if ($this->debug)
dump('============== ['.$y.'] ===============');
$x = 1;
while ($x <= $this->width) {
if ($this->debug)
dump('* CELL : y:'.$y.', x:'.$x);
// The current char value
$char = (isset($display[$y]) && isset($display[$y][$x])) ? $display[$y][$x] : NULL;
if ($this->debug)
dump(' - CHAR : '.(! is_null($char) ? $char->ch : 'undefined').', ATTR:'.(! is_null($char) ? $char->attr : 'undefined').', LAST:'.$last);
if ($this->debug) {
dump('-------- ['.$x.'] ------');
dump('y:'.$y.',x:'.$x.', attr:'.(! is_null($char) ? $char->attr : 'undefined'));
}
// Only write a new attribute if it has changed (and not Videotex)
if ($last !== $char->attr) {
// The current attribute for this character
$attr = is_null($char) ? NULL : $char->attr($this->mo,$last,$this->debug);
switch ($this->mo->name) {
case 'ansi':
// If the attribute is null, we'll write our default attribute
if (is_null($attr))
$line .= ''; #static::BG_BLACK|static::LIGHTGRAY;
else
$line .= (! is_null($attr)) ? $attr : '';
break;
case 'viewdata':
// If the attribute is null, we'll ignore it since we are drawing a character
if (! is_null($attr)) {
if ($this->debug)
dump(sprintf('= SEND attr:%02x, last: %02x [%s] (%s)',ord($attr),$last,$char->ch,serialize($attr)));
$line .= "\x1b".$attr;
//$x++;
}
break;
default:
throw new \Exception(sprintf('[%s] has not been implemented',$this->mo->name));
}
}
if (! is_null($char->ch)) {
if ($this->debug)
dump(' = SEND CHAR :'.$char->ch.', attr:'.$char->attr.', last:'.$last);
$line .= $char->ch;
} else if ($shownullchars || ((is_null($char->ch) && is_null($char->attr)))) {
if ($this->debug)
dump(' = CHAR UNDEFINED');
$line .= ' ';
}
$last = $char->attr;
$x++;
}
if ($this->debug)
dump(['line'=>$line]);
$result->push($line);
if ($this->debug && ($y > $this->debug))
exit(1);
}
return $result;
}
/**
* Update a dynamic field with a value
*
* @param $name
* @param $value
* @return void
* @throws \Exception
*/
private function field_dynamic($name,$value): void
{
if (($x=$this->fields_dynamic->search(function($item) use ($name) { return $item->name === $name; })) !== FALSE) {
$field = $this->fields_dynamic->get($x);
// Store our value
$field->value = $value;
} else {
throw new \Exception(sprintf('Dynamic field: [%s], doesnt exist?',$name));
}
}
private function fields_insert($fields) {
foreach ($fields as $field) {
if (is_null($field->value))
continue;
$content = str_split($field->value);
$y = $field->y;
$x = $field->x;
for ($x;$x < $field->x+abs($field->size);$x++) {
$index = $x-$field->x;
if (isset($content[$index]))
$this->build[$y][$x]->ch = ($field->type !== 'p') ? $content[$index] : '*';
else
$this->build[$y][$x]->ch = $field->pad;
}
}
}
public function fieldReset(): void
{
$this->field_active = NULL;
foreach ($this->fields_input as $field)
$field->value = NULL;
}
public function fieldNext(): Field|NULL
{
if ($this->fields_input->count()) {
if (is_null($this->field_active))
$this->field_active = 0;
else
$this->field_active++;
return $this->fields_input->get($this->field_active);
} else
return NULL;
}
/**
* Load a frame by it's ID.
*
* @param int $id
* @return void
*/
public function get(int $id): void
{
$this->po->findOrFail($id);
$this->frame = $this->po->frame;
$this->index = $this->po->index;
}
/**
* Go to a specific frame
*
* @param int $frame
* @param string $index
* @return void
* @throws \Exception
*/
public function goto(int $frame,string $index='a'): void
{
if (strlen($index) !== 1)
throw new \Exception('Invalid index:'.$index);
$this->frame = $frame;
$this->index = $index;
$this->fo = NULL;
}
public function haveNext(): bool
{
return $this->fo
? Frame::where('frame',$this->frame)
->where('index',$this->next)
->where('mode_id',$this->fo->mode_id)
->exists()
: FALSE;
}
public function isCug(int $cug): bool
{
return $this->cug === $cug;
}
// @todo To implement
public function isOwner(User $o): bool
{
return FALSE;
}
public function isRoute(int $route): bool
{
return is_numeric($this->fo->{sprintf('r%d',$route)});
}
/**
* Load a frame, throw a model not found exception if it doesnt exist
*
* @return void
*/
public function load(): void
{
$this->fo = Frame::where('mode_id',$this->mo->id)
->where('frame',$this->frame)
->where('index',$this->index)
->orderBy('created_at','DESC')
->firstOrFail();
$this->history->push($this->fo);
$this->clear();
}
public function method(int $route): ?Action
{
if (($x=($this->fo->{sprintf('r%d',$route)})) && (! $this->isRoute($route)))
return Action::factory($x);
return NULL;
}
public function new(int $frame,string $index='a'): void
{
$this->frame = $frame;
$this->index = $index;
$this->fo = new Frame;
// Make sure parent frame exists
if (($this->index !== 'a') && (! Frame::where('frame',$this->frame)->where('index',$this->prev)->where('mode',$this->mo->id)->exists()))
throw new ParentNotFoundException(sprintf('Parent %d%s doesnt exist',$frame,$index));
}
public function next(): void
{
$this->index = $this->next;
$this->fo = NULL;
}
/**
* Clear a user's history
*
* @return void
*/
public function resetHistory(): void
{
$this->history = collect();
}
public function route(int $route): void
{
if ($this->isRoute($route)) {
$this->frame = (int)$this->fo->{sprintf('r%d',$route)};
$this->index = 'a';
$this->fo = NULL;
} else {
throw new NoRouteException('Invalid route '.$route);
}
}
public function prev(): void
{
$this->index = $this->prev;
$this->fo = NULL;
}
}

View File

@ -0,0 +1,433 @@
<?php
namespace App\Classes\BBS\Page;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use App\Classes\BBS\Frame\{Char,Field};
use App\Classes\BBS\Page;
use App\Models\BBS\Mode;
class Ansi extends Page
{
protected const FRAME_WIDTH = 80;
protected const FRAME_HEIGHT = 22;
protected const FRAME_PROVIDER_LENGTH = 55;
protected const FRAME_PAGE_LENGTH = 17; // Full space for page number + space at beginning (as would be displayed by viewdata)
protected const FRAME_COST_LENGTH = 8; // Full space for cost + space at beginning (as would be displayed by viewdata)
protected const FRAME_SPACE = 1; // Since colors dont take a space, this is to buffer a space
public const ESC = 27;
public const I_CLEAR_CODE = 0;
public const I_HIGH_CODE = 1;
public const I_BLINK_CODE = 5;
public const FG_WHITE_CODE = self::FG_LIGHTGRAY_CODE;
public const FG_YELLOW_CODE = self::FG_BROWN_CODE;
public const FG_BLACK_CODE = 30;
public const FG_RED_CODE = 31;
public const FG_GREEN_CODE = 32;
public const FG_BROWN_CODE = 33;
public const FG_BLUE_CODE = 34;
public const FG_MAGENTA_CODE = 35;
public const FG_CYAN_CODE = 36;
public const FG_LIGHTGRAY_CODE = 37;
public const BG_BLACK_CODE = 40;
public const BG_RED_CODE = 41;
public const BG_GREEN_CODE = 42;
public const BG_BROWN_CODE = 43;
public const BG_YELLOW_CODE = self::BG_BROWN_CODE;
public const BG_BLUE_CODE = 44;
public const BG_MAGENTA_CODE = 45;
public const BG_CYAN_CODE = 46;
public const BG_LIGHTGRAY_CODE = 47;
public static function strlenv($text): int
{
return strlen($text ? preg_replace('/'.ESC.'\[[0-9;?]+[a-zA-Z]/','',$text) : $text);
}
public function __construct(int $frame,string $index='a')
{
parent::__construct($frame,$index);
$this->mo = Mode::where('name','Ansi')->single();
}
public function __get(string $key): mixed
{
switch ($key) {
case 'color_page':
return sprintf(" %s[%d;%dm",chr(self::ESC),self::I_HIGH_CODE,self::FG_WHITE_CODE);
case 'color_unit':
return sprintf(" %s[%d;%dm",chr(self::ESC),self::I_HIGH_CODE,self::FG_GREEN_CODE);
default:
return parent::__get($key);
}
}
public function attr(array $field): string
{
return sprintf('%s[%d;%d;%dm',ESC,$field['i'],$field['f'],$field['b']);
}
/**
* This function converts ANSI text into an array of attributes
*
* We include the attribute for every character, so that if a window is placed on top of this window, the edges
* render correctly.
*
* @param string $contents Our ANSI content to convert
* @param int $width Canvas width before we wrap to the next line
* @param int $yoffset fields offset when rendered (based on main window)
* @param int $xoffset fields offset when rendered (based on main window)
* @param int|null $debug Enable debug mode
* @return array
* @throws \Exception
*/
public function parse(string $contents,int $width,int $yoffset=0,int $xoffset=0,?int $debug=NULL): array
{
$result = [];
$lines = collect(explode("\r\n",$contents));
if ($debug)
dump(['lines'=>$lines]);
$i = 0; // Intensity
$bg = self::BG_BLACK; // Background color
$fg = self::LIGHTGRAY; // Foreground color
$attr = $fg + $bg + $i; // Attribute int
$default = ['i'=>0,'f'=>self::FG_LIGHTGRAY_CODE,'b'=>self::BG_BLACK_CODE];
$y = 0; // Line
$saved_x = NULL; // Cursor saved
$saved_y = NULL; // Cursor saved
$ansi = $default; // Our current attribute used for input fields
while ($lines->count() > 0) {
$x = 0;
$line = $lines->shift();
$result[$y+1] = [];
if ($this->debug) dump(['next line'=>$line,'length'=>strlen($line)]);
if (is_numeric($debug) && ($y > $debug)) {
dump(['exiting'=>serialize($debug)]);
exit(1);
}
while (strlen($line) > 0) {
if ($debug)
dump(['y:'=>$y,'attr'=>$attr,'line'=>$line,'length'=>strlen($line)]);
if ($x >= $width) {
$x = 0;
$y++;
}
/* parse an attribute sequence*/
$m = [];
preg_match('/^\x1b\[((\d+)+(;(\d+)+)*)m/U',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
// Are values separated by ;
$m = array_map(function($item) { return (int)$item; },explode(';',$m[0]));
// Sort our numbers
sort($m);
// Reset
if ($m[0] === self::I_CLEAR_CODE) {
$bg = self::BG_BLACK;
$fg = self::LIGHTGRAY;
$i = 0;
$ansi = $default;
array_shift($m);
}
// High Intensity
if (count($m) && ($m[0] === self::I_HIGH_CODE)) {
$i += ((($i === 0) || ($i === self::BLINK)) ? self::HIGH : 0);
$ansi['i'] = self::I_HIGH_CODE;
array_shift($m);
}
// Blink
if (count($m) && ($m[0] === self::I_BLINK_CODE)) {
$i += ((($i === 0) || ($i === self::HIGH)) ? self::BLINK : 0);
array_shift($m);
}
// Foreground
if (count($m) && ($m[0] >= self::FG_BLACK_CODE) && ($m[0] <= self::FG_LIGHTGRAY_CODE)) {
$ansi['f'] = $m[0];
switch (array_shift($m)) {
case self::FG_BLACK_CODE:
$fg = self::BLACK;
break;
case self::FG_RED_CODE:
$fg = self::RED;
break;
case self::FG_GREEN_CODE:
$fg = self::GREEN;
break;
case self::FG_YELLOW_CODE:
$fg = self::BROWN;
break;
case self::FG_BLUE_CODE:
$fg = self::BLUE;
break;
case self::FG_MAGENTA_CODE:
$fg = self::MAGENTA;
break;
case self::FG_CYAN_CODE:
$fg = self::CYAN;
break;
case self::FG_LIGHTGRAY_CODE:
$fg = self::LIGHTGRAY;
break;
}
}
// Background
if (count($m) && ($m[0] >= self::BG_BLACK_CODE) && ($m[0] <= self::BG_LIGHTGRAY_CODE)) {
$ansi['b'] = $m[0];
switch (array_shift($m)) {
case self::BG_BLACK_CODE:
$bg = self::BG_BLACK;
break;
case self::BG_RED_CODE:
$bg = self::BG_RED;
break;
case self::BG_GREEN_CODE:
$bg = self::BG_GREEN;
break;
case self::BG_BROWN_CODE:
$bg = self::BG_BROWN;
break;
case self::BG_BLUE_CODE:
$bg = self::BG_BLUE;
break;
case self::BG_MAGENTA_CODE:
$bg = self::BG_MAGENTA;
break;
case self::BG_CYAN_CODE:
$bg = self::BG_CYAN;
break;
case self::BG_LIGHTGRAY_CODE:
$bg = self::BG_LIGHTGRAY;
break;
}
}
$attr = $bg + $fg + $i;
continue;
}
/* parse absolute character position */
$m = [];
preg_match('/^\x1b\[(\d*);?(\d*)[Hf]/',$line,$m);
if (count($m)) {
dump(['Hf'=>$m]); // @todo Remove once validated
$line = substr($line,strlen(array_shift($m)));
$y = (int)array_shift($m);
if (count($m))
$x = (int)array_shift($m)-1;
continue;
}
/* ignore an invalid sequence */
$m = [];
preg_match('/^\x1b\[\?7h/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
continue;
}
/* parse positional sequences */
$m = [];
preg_match('/^\x1b\[(\d+)([A-D])/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
switch ($m[1]) {
/* parse an up positional sequence */
case 'A':
$y -= ($m[0] < 1) ? 0 : $m[0];
break;
/* parse a down positional sequence */
case 'B':
$y += ($m[0] < 1) ? 0 : $m[0];
break;
/* parse a forward positional sequence */
case 'C':
$x += ($m[0] < 1) ? 0 : $m[0];
break;
/* parse a backward positional sequence */
case 'D':
$x -= ($m[0] < 1) ? 0 : $m[0];
break;
}
continue;
}
/* parse a clear screen sequence - we ignore them */
$m = [];
preg_match('/^\x1b\[2J/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
continue;
}
/* parse cursor sequences */
$m = [];
preg_match('/^\x1b\[([su])/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
switch ($m[0]) {
/* parse save cursor sequence */
case 's':
$saved_x = $x;
$saved_y = $y;
break;
/* parse restore cursor sequence */
case 'u':
$x = $saved_x;
$y = $saved_y;
break;
}
continue;
}
/* parse an input field */
// Input field 'FIELD;valueTYPE;input char'
// @todo remove the trailing ESC \ to end the field, just use a control code ^B \x02 (Start of Text) and ^C \x03
$m = [];
preg_match('/^\x1b_([A-Z]+;[0-9a-z]+)([;]?.+)?\x1b\\\/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
// We are interested in our field match
$f = explode(';',array_shift($m));
// First value is the field name
$field = array_shift($f);
// Second value is the length/type of the field, nnX nn=size in chars, X=type (lower case)
$c = [];
preg_match('/([0-9]+)([a-z])/',$xx=array_shift($f),$c);
if (! count($c)) {
Log::channel('bbs')->alert(sprintf('! IF FAILED PARSING FIELD LENGTH/TYPE [%02dx%02d] (%s)',$y,$x,$xx));
break;
}
// Third field is the char to use
$fieldpad = count($f) ? array_shift($f) : '.';
Log::channel('bbs')->info(sprintf('- IF [%02dx%02d], Field: [%s], Length: [%d], Char: [%s]',$y,$x,$c[2],$c[1],$fieldpad));
// Any remaining fields are junk
if (count($f))
Log::channel('bbs')->alert(sprintf('! IGNORING ADDITIONAL IF FIELDS [%02dx%02d] (%s)',$y,$x,join('',$f)));
// If we are padding our field with a char, we need to add that back to $line
// @todo validate if this goes beyond our width (and if scrolling not enabled)
if ($c[1])
$line = str_repeat($fieldpad,$c[1]).$line;
$this->fields_input->push(new Field([
'attribute' => $ansi,
'name' => $field,
'pad' => $fieldpad,
'size' => $c[1],
'type' => $c[2],
'value' => NULL,
'x' => $x+$xoffset,
'y' => $y+$yoffset,
]));
}
/* parse dynamic value field */
// @todo remove the trailing ESC \ to end the field, just use a control code ie: ^E \x05 (Enquiry) or ^Z \x26 (Substitute)
$m = [];
preg_match('/^\x1bX([a-zA-Z._:^;]+[0-9]?;-?[0-9^;]+)([;]?[^;]+)?\x1b\\\/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
// We are interested in our field match
$f = explode(';',array_shift($m));
$pad = Arr::get($f,2,' ');
Log::channel('bbs')->info(sprintf('- DF [%02dx%02d], Field: [%s], Length: [%d], Char: [%s]',$y,$x,$f[0],$f[1],$pad));
// If we are padding our field with a char, we need to add that back to line
// @todo validate if this goes beyond our width (and if scrolling not enabled)
$line = str_repeat($pad,abs($f[1])).$line;
$this->fields_dynamic->push(new Field([
'name' => $f[0],
'pad' => $pad,
'type' => NULL,
'size' => $f[1],
'value' => NULL,
'x' => $x+$xoffset,
'y' => $y+$yoffset,
]));
}
/* set character and attribute */
$ch = $line[0];
$line = substr($line,1);
/* validate position */
if ($y < 0)
$y = 0;
if ($x < 0)
$x = 0;
if ($attr === null)
throw new \Exception('Attribute is null?');
$result[$y+1][$x+1] = new Char($ch,$attr);
$x++;
}
// If we got a self::BG_BLACK|self::LIGHTGRAY ESC [0m, but not character, we include it as it resets any background that was going on
if (($attr === self::BG_BLACK|self::LIGHTGRAY) && isset($result[$y+1][$x]) && ($result[$y+1][$x]->attr !== $attr))
$result[$y+1][$x+1] = new Char(NULL,$attr);
$y++;
}
return $result;
}
}

View File

@ -0,0 +1,370 @@
<?php
namespace App\Classes\BBS\Page;
use Illuminate\Support\Arr;
use App\Classes\BBS\Frame\{Char,Field};
use App\Classes\BBS\Page;
use App\Models\BBS\Mode;
class Viewdata extends Page
{
protected const FRAME_WIDTH = 40;
protected const FRAME_HEIGHT = 22;
protected const FRAME_PROVIDER_LENGTH = 23;
protected const FRAME_PAGE_LENGTH = 11; // Spec is 9+1 - including our color code.
protected const FRAME_COST_LENGTH = 6; // including our color code
protected const FRAME_SPACE = 0; // Since colors take a space, this is not needed
public const MOSIAC = 0x10;
// Toggles
public const CONCEAL = 0x20;
public const REVEAL = 0x2000; // @temp Turns off Conceal
public const SEPARATED = 0x40;
public const BLOCKS = 0x4000; // @temp Turns off Separated
public const STEADY = 0x8000; // @temp (turn off flash)
public const DOUBLE = 0x100;
public const NORMAL = 0x1000; // @temp Turns off Double Height
public const HOLD = 0x200;
public const RELEASE = 0x20000; // @temp turns off Hold
public const NEWBACK = 0x400;
public const BLACKBACK = 0x800;
//public const ESC = 27;
//public const I_CLEAR_CODE = 0;
//public const I_HIGH_CODE = 1;
public const FG_BLACK_CODE = 0x40;
public const FG_RED_CODE = 0x41;
public const FG_GREEN_CODE = 0x42;
public const FG_YELLOW_CODE = 0x43;
public const FG_BLUE_CODE = 0x44;
public const FG_MAGENTA_CODE = 0x45;
public const FG_CYAN_CODE = 0x46;
public const FG_WHITE_CODE = 0x47;
public const I_BLINK_CODE = 0x48;
public const I_STEADY = 0x49;
public const I_NORMAL = 0x4c;
public const I_DOUBLE_CODE = 0x4d;
public const I_CONCEAL = 0x58;
public const I_BLOCKS = 0x59;
public const I_SEPARATED = 0x5a;
public const I_BLACKBACK = 0x5c;
public const I_NEWBACK = 0x5d;
public const I_HOLD = 0x5e;
public const I_REVEAL = 0x5f;
public const RED = 1;
//public const GREEN = 2;
public const YELLOW = 3;
public const BLUE = 4;
//public const MAGENTA = 5;
public const CYAN = 6;
public const WHITE = 7;
public const MOSIAC_RED_CODE = 0x51;
public const MOSIAC_GREEN_CODE = 0x52;
public const MOSIAC_YELLOW_CODE = 0x53;
public const MOSIAC_BLUE_CODE = 0x54;
public const MOSIAC_MAGENTA_CODE = 0x55;
public const MOSIAC_CYAN_CODE = 0x56;
public const MOSIAC_WHITE_CODE = 0x57; // W
public const input_map = [
'd' => 'DATE',
'e' => 'EMAIL',
'f' => 'FULLNAME',
'n' => 'USER',
'p' => 'PASS',
't' => 'TIME',
'y' => 'NODE',
'z' => 'TOKEN',
];
public static function strlenv($text):int
{
return strlen($text)-substr_count($text,ESC);
}
public function __construct(int $frame,string $index='a')
{
parent::__construct($frame,$index);
$this->mo = Mode::where('name','Viewdata')->single();
}
public function __get(string $key): mixed
{
switch ($key) {
case 'color_page':
return chr(self::WHITE);
case 'color_unit':
return chr(self::GREEN);
default:
return parent::__get($key);
}
}
public function attr(array $field): string
{
// Noop
return '';
}
/**
* This function converts Viewtex BIN data into an array of attributes
*
* With viewdata, a character is used/display regardless of whether it is a control character, or an actual display
* character.
*
* @param string $contents Our ANSI content to convert
* @param int $width Canvas width before we wrap to the next line
* @param int $yoffset fields offset when rendered (based on main window)
* @param int $xoffset fields offset when rendered (based on main window)
* @param int|null $debug Enable debug mode
* @return array
* @throws \Exception
*/
public function parse(string $contents,int $width,int $yoffset=0,int $xoffset=0,?int $debug=NULL): array
{
$result = [];
$lines = collect(explode("\r\n",$contents));
if ($debug)
dump(['lines'=>$lines]);
$i = 0; // Intensity
$bg = self::BG_BLACK; // Background color
$fg = self::WHITE; // Foreground color
$new_line = $fg + $bg + $i; // Attribute int
// Attribute state on a new line
$attr = $new_line;
$y = 0;
while ($lines->count() > 0) {
$x = 0;
$line = $lines->shift();
$result[$y+1] = [];
if ($this->debug)
dump(['next line'=>$line,'length'=>strlen($line)]);
while (strlen($line) > 0) {
if ($debug)
dump(['y:'=>$y,'attr'=>$attr,'line'=>$line,'length'=>strlen($line)]);
if ($x >= $width) {
$x = 0;
// Each new line, we reset the attrs
$attr = $new_line;
$y++;
}
/* parse control codes */
$m = [];
preg_match('/^([\x00-\x09\x0c-\x1a\x1c-\x1f])/',$line,$m);
if (count($m)) {
$line = substr($line,strlen(array_shift($m)));
$attr = 0;
switch ($xx=ord(array_shift($m))) {
case 0x00:
$attr += self::BLACK;
break;
case 0x01:
$attr += self::RED;
break;
case 0x02:
$attr += self::GREEN;
break;
case 0x03:
$attr += self::YELLOW;
break;
case 0x04:
$attr += self::BLUE;
break;
case 0x05:
$attr += self::MAGENTA;
break;
case 0x06:
$attr += self::CYAN;
break;
case 0x07:
$attr += self::WHITE;
break;
case 0x08:
$attr = self::BLINK;
break;
case 0x09:
$attr = self::STEADY;
break;
/*
case 0x0a:
//$attr = self::ENDBOX; // End Box (Unused?)
break;
case 0x0b:
//$attr = self::STARTBOX; // Start Box (Unused?)
break;
*/
case 0x0c:
$attr = self::NORMAL;
break;
case 0x0d:
$attr = self::DOUBLE;
break;
case 0x0e:
$attr = self::NORMAL; // @todo Double Width (Unused)?
break;
case 0x0f:
$attr = self::NORMAL; // @todo Double Width (Unused?)
break;
case 0x10:
$attr = self::MOSIAC|self::BLACK;
break;
case 0x11:
$attr = self::MOSIAC|self::RED;
break;
case 0x12:
$attr = self::MOSIAC|self::GREEN;
break;
case 0x13:
$attr = self::MOSIAC|self::YELLOW;
break;
case 0x14:
$attr = self::MOSIAC|self::BLUE;
break;
case 0x15:
$attr = self::MOSIAC|self::MAGENTA;
break;
case 0x16:
$attr = self::MOSIAC|self::CYAN;
break;
case 0x17:
$attr = self::MOSIAC|self::WHITE;
break;
case 0x18:
$attr = self::CONCEAL;
break;
case 0x19:
$attr = self::BLOCKS;
break;
case 0x1a:
$attr = self::SEPARATED;
break;
/*
// We are using this for field input
case 0x1b:
//$attr = self::NORMAL; // CSI
break;
*/
case 0x1c:
$attr = self::BLACKBACK; // Black Background
break;
case 0x1d:
$attr = self::NEWBACK; // New Background
break;
case 0x1e:
$attr = self::HOLD; // Mosiac Hold
break;
case 0x1f:
$attr = self::RELEASE; // Mosiac Release
break;
// Catch all for other codes
default:
dump(['char'=>$xx]);
$attr = 0xff00;
}
if ($debug)
dump(sprintf('- got control code [%02x] at [%02dx%02d]',$attr,$y,$x));
$result[$y+1][$x+1] = new Char(NULL,$attr);
$x++;
continue;
}
/**
* For response frames, a dialogue field is signalled by a CLS (0x0c) followed by a number of dialogue
* characters [a-z]. The field ends by the first different character from the initial dialogue character.
* The CLS is a "privileged space" and the dialogue characters defined the dialogue field.
*
* Standard dialogue characters:
* + n = name
* + t = telephone number
* + d = date and time
* + a = address
* + anything else free form, typically 'f' is used
*
* Source: Prestel Bulk Update Technical Specification
*/
/* parse an input field */
// Since 0x0c is double, we'll use good ol' ESC 0x1b
$m = [];
preg_match('/^([\x1b|\x9b])([a-z])\2+/',$line,$m);
if (count($m)) {
$line = substr($line,strlen($m[0]));
$len = strlen(substr($m[0],1));
$field = new Field([
'attribute' => [],
'name' => Arr::get(self::input_map,$m[2],$m[2]),
'pad' => '.',
'size' => $len,
'type' => $m[2],
'value' => NULL,
'x' => $x+$xoffset,
'y' => $y+$yoffset,
]);
(($m[1] === "\x1b") ? $this->fields_input : $this->fields_dynamic)->push($field);
$result[$y+1][++$x] = new Char(' ',$attr); // The \x1b|\x9b is the privileged space.
for ($xx=0;$xx<$len;$xx++)
$result[$y+1][$x+1+$xx] = new Char('.',$attr);
$x += $len;
continue;
}
/* set character and attribute */
$ch = $line[0];
$line = substr($line,1);
if ($debug)
dump(sprintf('Storing [%02xx%02x] [%s] with [%02x]',$y,$x,$ch,$attr));
/* validate position */
if ($y < 0)
$y = 0;
if ($x < 0)
$x = 0;
if ($attr === null)
throw new \Exception('Attribute is null?');
$result[$y+1][$x+1] = new Char($ch,$attr);
$x++;
}
// Each new line, we reset the attrs
$attr = $new_line;
$y++;
}
return $result;
}
}

1231
app/Classes/BBS/Server.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
<?php
namespace App\Classes\BBS\Server;
use App\Classes\BBS\Server as AbstractServer;
use App\Classes\Sock\SocketClient;
class Ansitex extends AbstractServer
{
protected const LOGKEY = 'BAS';
/* CONSTS */
public const PORT = 23;
protected function init(SocketClient $client)
{
define('ESC', chr(27));
define('CON', ESC.'[?25h'); // Cursor On
define('COFF', ESC.'[?25l'); // Cursor Off
define('CSAVE', ESC.'[s'); // Save Cursor position
define('CRESTORE',ESC.'[u'); // Restore to saved position
define('HOME', ESC.'[0;0f');
define('LEFT', ESC.'[D'); // Move Cursor
define('RIGHT', ESC.'[C'); // Move Cursor
define('DOWN', ESC.'[B'); // Move Cursor
define('UP', ESC.'[A'); // Move Cursor
define('CR', chr(13));
define('LF', chr(10));
define('BS', chr(8));
define('CLS', ESC.'[2J');
define('HASH', '#'); // Enter
define('STAR', '*'); // Star Entry
define('SPACE', ' '); // Space (for compatibility with Videotex)
// NOTE: This consts are effective output
define('RESET', ESC.'[0;39;49m');
define('RED', ESC.'[0;31m');
define('GREEN', ESC.'[0;32m');
define('YELLOW', ESC.'[1;33m');
define('BLUE', ESC.'[0;34m');
define('MAGENTA', ESC.'[0;35m');
define('CYAN', ESC.'[0;36m');
define('WHITE', ESC.'[1;37m');
define('NEWBG', '');
// Compatibility attributes (to Videotex).
define('R_RED', RED.SPACE);
define('R_GREEN', GREEN.SPACE);
define('R_YELLOW', YELLOW.SPACE);
define('R_BLUE', BLUE.SPACE);
define('R_MAGENTA', MAGENTA.SPACE);
define('R_CYAN', CYAN.SPACE);
define('R_WHITE', WHITE.SPACE);
//define('FLASH',chr(8));
// Keyboard presses
// @todo Check where these are used vs the keys defined above?
define('KEY_DELETE', chr(8));
define('KEY_LEFT', chr(136));
define('KEY_RIGHT', chr(137));
define('KEY_DOWN', chr(138));
define('KEY_UP', chr(139));
parent::init($client);
}
function moveCursor($x,$y): string
{
return ESC.'['.$y.';'.$x.'f';
}
// Abstract function
public function sendBaseline(string $text,bool $reposition=FALSE)
{
$this->client->send(CSAVE.ESC.'[24;0f'.RESET.SPACE.$text.
($this->blp > $this->po->strlenv(SPACE.$text)
? str_repeat(' ',$this->blp-$this->po->strlenv(SPACE.$text)).
($reposition ? ESC.'[24;0f'.str_repeat(RIGHT,$this->po->strlenv(SPACE.$text)) : CRESTORE)
: ($reposition ? '' : CRESTORE)),
static::TIMEOUT
);
$this->blp = $this->po->strlenv(SPACE.$text);
$this->baseline = $text;
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Classes\BBS\Server;
use App\Classes\BBS\Server as AbstractServer;
use App\Classes\Sock\SocketClient;
class Videotex extends AbstractServer
{
protected const LOGKEY = 'BVS';
/* CONSTS */
public const PORT = 516;
protected function init(SocketClient $client)
{
define('ESC', chr(27));
define('CON', chr(17)); // Cursor On
define('COFF', chr(20)); // Cursor Off
define('HOME', chr(30));
define('LEFT', chr(8)); // Move Cursor
define('RIGHT', chr(9)); // Move Cursor
define('DOWN', chr(10)); // Move Cursor
define('UP', chr(11)); // Move Cursor
define('CR', chr(13));
define('LF', chr(10));
define('CLS', chr(12));
define('HASH', '_'); // Enter
define('STAR', '*'); // Star Entry
define('SPACE', ''); // Space
// NOTE: This consts are effective output
define('RESET', '');
define('RED', ESC.'A');
define('GREEN', ESC.'B');
define('YELLOW', ESC.'C');
define('BLUE', ESC.'D');
define('MAGENTA', ESC.'E');
define('CYAN', ESC.'F');
define('WHITE', ESC.'G');
define('NEWBG', ESC.']');
// Raw attributes - used when storing frames.
define('R_RED', chr(1));
define('R_GREEN', chr(2));
define('R_YELLOW', chr(3));
define('R_BLUE', chr(4));
define('R_MAGENTA', chr(5));
define('R_CYAN', chr(6));
define('R_WHITE', chr(7));
define('FLASH', chr(8));
define('KEY_DELETE', chr(0x7f));
define('KEY_LEFT', chr(0x08));
define('KEY_RIGHT', chr(0x09));
define('KEY_DOWN', chr(0x0a));
define('KEY_UP', chr(0x0b));
parent::init($client);
}
public function moveCursor($x,$y): string
{
// Take the shortest path.
if ($y < 12) {
return HOME.
(($x < 21)
? str_repeat(DOWN,$y-1).str_repeat(RIGHT,$x)
: str_repeat(DOWN,$y).str_repeat(LEFT,40-$x));
} else {
return HOME.str_repeat(UP,24-$y+1).
(($x < 21)
? str_repeat(RIGHT,$x)
: str_repeat(LEFT,40-$x));
}
}
public function sendBaseline(string $text,bool $reposition=FALSE) {
$this->client->send(HOME.UP.$text.
($this->blp > $this->po->strlenv($text)
? str_repeat(' ',$this->blp-$this->po->strlenv($text)).
($reposition ? HOME.UP.str_repeat(RIGHT,$this->po->strlenv($text)) : '')
: ''),
static::TIMEOUT
);
$this->blp = $this->po->strlenv($text);
}
}

365
app/Classes/BBS/Window.php Normal file
View File

@ -0,0 +1,365 @@
<?php
namespace App\Classes\BBS;
use Illuminate\Support\Collection;
use App\Classes\BBS\Frame\Char;
/**
* Windows are elements of a Page object
*
* @param int $x - (int) starting x of it's parent [1..]
* @param int $y - (int) starting y of it's parent [1..]
* @param int $width - (int) full width of the window (text content will be smaller if there are scroll bars/boarder)
* @param int $height - (int) full height of the window (text content will be smaller if there are scroll bars/boarder)
* @param string $name - (string) internal name for the window (useful for debugging)
* @param Window $parent - (object) parent of this window
* @param bool $debug - (int) debug mode, which fills the window with debug content
*
* Pages have the following attributes:
* - bx/by - (int) right/bottom most boundary of the window representing the start + width/height of the window
* - child - (array) children in this window
* - height - (int) Window's height
* - name - (string) Windows name (useful for internal debugging)
* - parent - (object) Parent that this window belongs to
* - x/y - (int) start position of the window
* - visible - (bool) whether this window is visible
* - width - (int) Window's width
* - z - (int) Window's depth indicator
*
* Windows have the following public functions
* - build - Compile the frame for rendering
* - debug - Useful for debugging with properties of this Window
* - draw - Draw a part of this Window
*/
class Window
{
/** @var int X offset of parent that the canvas starts [1..width] */
private int $x;
/** @var int Y offset of parent that the canvas starts [1..height] */
private int $y;
/** @var int Window top-bottom position, higher z is shown [0..] */
private int $z = 0;
/** @var int When canvas width > width, this is the offset we display [0..] */
private int $ox = 0;
/** @var int When canvas height > height, this is the offset we display [0..] */
private int $oy = 0;
/** @var int Display Width + (1 char if scrollbars = true) */
private int $width;
/** @var int Display Height */
private int $height;
/** @var int Width of Canvas (default display width) */
private int $canvaswidth;
/** @var int Height of Canvas (default display height) */
private int $canvasheight;
/** @var array Window content - starting at 0,0 = 1,1 */
public array $content = [];
/** @var bool Window visible */
private bool $visible = TRUE;
/** @var string Window name */
private string $name;
/** @var bool Can this frame move outside the parent */
private bool $checkbounds = TRUE;
/** @var bool Can the content scroll vertically (takes up 1 line) [AUTO DETERMINE IF canvas > width] */
private bool $v_scroll = TRUE;
/** @var bool Can the content scroll horizontally (takes up 1 char) [AUTO DETERMINE IF canvas > height] */
private bool $h_scroll = FALSE;
/** @var int|bool Overflowed content is rendered with the next page */
private bool $pageable = FALSE;
private Page|Window|NULL $parent;
private Collection $child;
private bool $debug;
/*
Validation to implement:
+ X BOUNDARY
- x cannot be < parent.x if checkbounds is true [when moving window]
- x+width(-1 if h_scroll is true) cannot be greater than parent.width if checkbounds is true
- v_scroll must be true for canvaswidth > width
- when scrolling ox cannot be > width-x
- when layout.pageable is true, next page will only have windows included that have a y in the range
ie: if height is 44 (window is 22), next page is 23-44 and will only include children where y=23-44
+ Y BOUNDARY
- y cannot be < parent.y if checkbounds is true [when moving window]
- y+height(-1 if v_scroll is true) cannot be greater than parent.height if checkbounds is true
- h_scroll must be true for canvasheight > height
- when scrolling oy cannot be > height-y
- when layout.pageable is true, children height cannot be greater than parent.height - y.
*/
public function __construct(int $x,int $y,int $width,int $height,string $name,Window|Page $parent=NULL,bool $debug=FALSE) {
$this->x = $x;
$this->y = $y;
$this->name = $name;
$this->parent = $parent;
$this->debug = $debug;
$this->child = collect();
if ($parent instanceof self) {
$this->z = $parent->child->count()+1;
$this->parent = $parent;
$this->parent->child->push($this);
// Check that our height/widths is not outside our parent
if (($this->x < 1) || ($width > $this->parent->width))
throw new \Exception(sprintf('Window: %s width [%d] is beyond our parent\'s width [%d].',$name,$width,$this->parent->width));
if (($x > $this->parent->bx) || ($x+$width-1 > $this->parent->bx))
throw new \Exception(sprintf('Window: %s start x [%d] and width [%d] is beyond our parent\'s end x [%d].',$name,$x,$width,$this->parent->bx));
if (($this->y < 1) || ($height > $this->parent->height))
throw new \Exception(sprintf('Window: %s height [%d] is beyond our parent\'s height [%d].',$name,$height,$this->parent->height));
if (($y > $this->parent->by) || ($y+$height-1 > $this->parent->by))
throw new \Exception(sprintf('Window: %s start y [%d] and height [%d] is beyond our parent\'s end y [%s].',$name,$y,$height,$this->parent->by));
} elseif ($parent instanceof Page) {
$this->parent = $parent;
}
$this->width = $this->canvaswidth = $width;
$this->height = $this->canvasheight = $height;
if ($debug) {
$this->canvaswidth = $width*2;
$this->canvasheight = $height*2;
}
// Fill with data
for($y=1;$y<=$this->canvasheight;$y++) {
for($x=1;$x<=$this->canvaswidth;$x++) {
if (! isset($this->content[$y]))
$this->content[$y] = [];
$this->content[$y][$x] = $debug
? new Char((($x > $this->width) || ($y > $this->height)) ? strtoupper($this->name[0]) : strtolower($this->name[0]))
: new Char();
}
}
}
public function __get($key): mixed
{
switch ($key) {
case 'bx': return $this->x+$this->width-1;
case 'by': return $this->y+$this->height-1;
case 'checkbounds': return $this->checkbounds;
case 'child':
return $this->child->sort(function($a,$b) {return ($a->z < $b->z) ? -1 : (($b->z < $a->z) ? 1 : 0); });
case 'name':
return $this->name;
case 'height':
case 'parent':
case 'visible':
case 'width':
case 'x':
case 'y':
case 'z':
return $this->{$key};
default:
throw new \Exception('Unknown key: '.$key);
}
}
public function __set($key,$value): void
{
switch ($key) {
case 'child':
if ($value instanceof self)
$this->child->push($value);
else
throw new \Exception('child not an instance of Window()');
break;
case 'content':
$this->content = $value;
break;
case 'parent':
if ($this->parent)
throw new \Exception('parent already DEFINED');
else
$this->parent = $value;
break;
case 'visible':
$this->visible = $value;
break;
default:
throw new \Exception('Unknown key: '.$key);
}
}
/**
* Build this window, returning an array of Char that will be rendered by Page
*
* @param int $xoffset - (int) This windows x position for its parent
* @param int $yoffset - (int) This windows y position for its parent
* @param bool $debug - (int) debug mode, which fills the window with debug content
* @return array
*/
public function build(int $xoffset,int $yoffset,bool $debug=FALSE): array
{
$display = [];
if ($debug) {
dump('********* ['.$this->name.'] *********');
dump('name :'.$this->name);
dump('xoff :'.$xoffset);
dump('yoff :'.$yoffset);
dump('x :'.$this->x);
dump('bx :'.$this->bx);
dump('ox :'.$this->ox);
dump('y :'.$this->y);
dump('by :'.$this->by);
dump('oy :'.$this->oy);
dump('lines :'.count(array_keys($this->content)));
//dump('content:'.join('',$this->content[1]));
}
if ($debug)
dump('-------------');
for ($y=1;$y<=$this->height;$y++) {
if ($debug)
echo sprintf('%02d',$y).':';
$sy = $this->y-1+$y+$yoffset-1;
for ($x=1;$x<=$this->width;$x++) {
if ($debug)
dump('- Checking :'.$this->name.', y:'.($y+$this->oy).', x:'.($x+$this->ox));
$sx = $this->x-1+$x+$xoffset-1;
if (! isset($display[$sy]))
$display[$sy] = [];
if (isset($this->content[$y+$this->oy]) && isset($this->content[$y+$this->oy][$x+$this->ox])) {
$display[$sy][$sx] = $this->content[$y+$this->oy][$x+$this->ox];
if ($debug)
dump('- storing in y:'.($sy).', x:'.($sx).', ch:'.$display[$sy][$sx]->ch);
} else {
$display[$sy][$sx] = new Char();
if ($debug)
dump('- nothing for y:'.($sy).', x:'.($sx).', ch:'.$display[$sy][$sx]->ch);
}
}
if ($debug)
dump('---');
}
if ($debug)
dump('----LOOKING AT CHILDREN NOW---------');
if ($debug) {
dump('Window:'.$this->name.', has ['.$this->child->filter(function($child) { return $child->visible; })->count().'] children');
$this->child->each(function($child) {
dump(' - child:'.$child->name.', visible:'.$child->visible);
});
}
// Fill the array with our values
foreach ($this->child->filter(function($child) { return $child->visible; }) as $child) {
if ($debug) {
dump('=========== ['.$child->name.'] =============');
dump('xoff :'.$xoffset);
dump('yoff :'.$yoffset);
dump('x :'.$this->x);
dump('y :'.$this->y);
}
$draw = $child->build($this->x+$xoffset-1,$this->y+$yoffset-1,$debug);
if ($debug)
dump('draw y:'.join(',',array_keys($draw)));
foreach (array_keys($draw) as $y) {
foreach (array_keys($draw[$y]) as $x) {
if (! isset($display[$y]))
$display[$y] = [];
$display[$y][$x] = $draw[$y][$x];
}
}
if ($debug) {
//dump('draw 1:'.join(',',array_keys($draw[1])));
dump('=========== END ['.$child->name.'] =============');
}
}
if ($debug) {
dump('this->name:'.$this->name);
dump('this->y:'.$this->y);
dump('display now:'.join(',',array_values($display[$this->y])));
dump('********* END ['.$this->name.'] *********');
foreach ($display as $y => $data) {
dump(sprintf("%02d:%s (%d)\r\n",$y,join('',$data),count($data)));
}
}
return $display;
}
public function xdebug(string $text) {
return '- '.$text.': '.$this->name.'('.$this->x.'->'.($this->bx).') width:'.$this->width.' ['.$this->y.'=>'.$this->by.'] with z:'.$this->z;
}
/**
* Render this window
*
* @param $start - (int) Starting x position
* @param $end - (int) Ending x position
* @param $y - (int) Line to render
* @param $color - (bool) Whether to include color
* @returns {{x: number, content: string}}
*/
public function xdraw($start,$end,$y,$color): array
{
$content = '';
for ($x=$start;$x<=$end;$x++) {
$rx = $this->ox+$x;
$ry = $this->oy+$y;
// Check if we have an attribute to draw
if (! (isset($this->content[$ry])) || ! (isset($this->content[$ry][$rx]))) {
$content += ' ';
continue;
}
if ($color === NULL || $color === true) {
// Only write a new attribute if it has changed
if (($this->last === NULL) || ($this->last !== $this->content[$ry][$rx]->attr)) {
$this->last = $this->content[$ry][$rx]->attr;
$content += ($this->last === null ? BG_BLACK|LIGHTGRAY : $this->last);
}
}
try {
$content += ($this->content[$ry][$rx]->ch !== null ? $this->content[$ry][$rx]->ch : ' ');
} catch (\Exception $e) {
dump($e);
dump('---');
dump('x:'.($x-$this->x));
dump('y:'.($y-$this->y));
dump('ox:'.$this->ox);
dump('oy:'.$this->oy);
dump('$rx:'.$rx);
dump('$ry:'.$ry);
exit();
}
}
return ['content'=>$content, 'x'=>$end - $start + 1];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands\BBS;
use Illuminate\Console\Command;
use App\Classes\ANSI;
class ANSIDecode extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bbs:ansi:decode'
.' {file : ANS file to decode}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Decode ANS file from custom binary';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
echo ANSI::ansi($this->argument('file'));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands\BBS;
use Illuminate\Console\Command;
use App\Classes\ANSI;
class ANSIEncode extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bbs:ansi:encode'
.' {file : ANS file to encode}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Encode ANS file to custom binary';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
foreach (ANSI::binary($this->argument('file')) as $line) {
foreach (str_split(bin2hex($line),2) as $y)
echo hex2bin($y);
echo "\r";
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands\BBS;
use App\Models\Frame;
use Illuminate\Console\Command;
class FrameDelete extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bbs:frame:delete {frame} {index}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete frames from the database.';
/**
* Execute the console command.
*
* @return int
* @throws \Exception
*/
public function handle(): int
{
if (! is_numeric($this->argument('frame')))
throw new \Exception('Frame is not numeric: '.$this->argument('frame'));
if (strlen($this->argument('index')) != 1 OR ! preg_match('/^[a-z]$/',$this->argument('index')))
throw new \Exception('Subframe failed validation');
try {
$o = Frame::where('frame',$this->argument('frame'))
->where('index',$this->argument('index'))
->firstOrFail();
} catch (ModelNotFoundException $e) {
$this->error('Page not found to delete: '.$this->argument('frame').$this->argument('index'));
die(1);
}
$o->delete();
$this->info('Page deleted: '.$this->argument('frame').$this->argument('index'));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Console\Commands\BBS;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Models\BBS\{Frame,Mode};
use Illuminate\Support\Arr;
class FrameImport extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bbs:frame:import {frame} {file} '.
'{--index=a : The frame index }'.
'{--access=0 : Is frame accessible }'.
'{--public=0 : Is frame limited to CUG }'.
'{--cost=0 : Frame Cost }'.
'{--mode=Ansi : Frame Emulation Mode }'.
'{--replace : Replace existing frame}'.
'{--type=i : Frame Type}'.
'{--title= : Frame Title}'.
'{--keys= : Key Destinations [0,1,2,3,4,5,6,7,8,9]}'.
'{--trim= : Trim off header (first n chars)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Import frames into the database. The frames should be in binary format.';
/**
* Execute the console command.
*
* @return mixed
* @throws \Exception
*/
public function handle()
{
if (! is_numeric($this->argument('frame')))
throw new \Exception('Frame is not numeric: '.$this->argument('frame'));
if ((strlen($this->option('index')) !== 1) || (! preg_match('/^[a-z]$/',$this->option('index'))))
throw new \Exception('Subframe failed validation');
if (! file_exists($this->argument('file')))
throw new \Exception('File not found: '.$this->argument('file'));
$mo = Mode::where('name',$this->option('mode'))->firstOrFail();
$o = new Frame;
if ($this->option('replace')) {
try {
$o = $o->where('frame',$this->argument('frame'))
->where('index',$this->option('index'))
->where('mode_id',$mo->id)
->orderBy('created_at','DESC')
->firstOrNew();
} catch (ModelNotFoundException $e) {
$this->error('Frame not found to replace: '.$this->argument('frame').$this->option('index'));
exit(1);
}
}
$o->frame = $this->argument('frame');
$o->index = $this->option('index');
$o->mode_id = $mo->id;
$o->access = $this->option('access');
$o->public = $this->option('public');
$o->cost = $this->option('cost');
$o->type = $this->option('type');
$o->title = $this->option('title');
$keys = [];
if ($this->option('keys'))
$keys = explode(',',$this->option('keys'));
foreach (range(0,9) as $key) {
$index = sprintf('r%d',$key);
$o->{$index} = (($x=Arr::get($keys,$key,NULL)) === "null") ? NULL : $x;
}
// We need to escape any back slashes, so they dont get interpretted as hex
$o->content = $this->option('trim')
? substr(file_get_contents($this->argument('file')),$this->option('trim'))
: file_get_contents($this->argument('file'));
// If we have 0x1aSAUCE, we'll discard the sauce.
if ($x = strpos($o->content,chr(0x1a).'SAUCE')) {
$o->content = substr($o->content,0,$x-1).chr(0x0a);
}
$o->save();
$this->info(sprintf('Saved frame: [%s] as [%s] with [%d]',$o->page,$mo->name,$o->id));
}
}

View File

@ -0,0 +1,128 @@
<?php
/**
* Viewdata/Videotex Server
*
* Inspired by Rob O'Donnell at irrelevant.com
*/
namespace App\Console\Commands\BBS;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use App\Classes\BBS\Server\{Ansitex,Videotex};
use App\Classes\Sock\{SocketException,SocketServer};
use App\Models\Mode;
use App\Models\Setup;
class Start extends Command
{
private const LOGKEY = 'CBS';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bbs:start {--mode=VideoTex : Server Mode Profile}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Start BBS Server';
/**
* Execute the console command.
*
* @return void
* @throws SocketException
*/
public function handle()
{
Log::channel('bbs')->info(sprintf('%s:+ BBS Server Starting (%d)',self::LOGKEY,getmypid()));
$o = Setup::findOrFail(config('app.id'));
$start = collect();
if (TRUE || $o->ansitex_active)
$start->put('ansitex',[
'address'=>$o->ansitex_bind,
'port'=>$o->ansitex_port,
'proto'=>SOCK_STREAM,
'class'=>new Ansitex,
]);
if (TRUE || $o->viewdata_active)
$start->put('videotex',[
'address'=>$o->videotex_bind,
'port'=>$o->videotex_port,
'proto'=>SOCK_STREAM,
'class'=>new Videotex,
]);
$children = collect();
Log::channel('bbs')->debug(sprintf('%s:# Servers [%d]',self::LOGKEY,$start->count()));
if (! $start->count()) {
Log::channel('bbs')->alert(sprintf('%s:! No servers configured to start',self::LOGKEY));
return;
}
//pcntl_signal(SIGCHLD,SIG_IGN);
foreach ($start as $item => $config) {
Log::channel('bbs')->debug(sprintf('%s:- Starting [%s] (%d)',self::LOGKEY,$item,getmypid()));
$pid = pcntl_fork();
if ($pid === -1)
die('could not fork');
// We are the child
if (! $pid) {
Log::channel('bbs')->withContext(['pid'=>getmypid()]);
Log::channel('bbs')->info(sprintf('%s:= Started [%s]',self::LOGKEY,$item));
$server = new SocketServer($config['port'],$config['address'],$config['proto']);
$server->handler = [$config['class'],'onConnect'];
try {
$server->listen();
} catch (SocketException $e) {
if ($e->getMessage() === 'Can\'t accept connections: "Success"')
Log::channel('bbs')->debug(sprintf('%s:! Server Terminated [%s]',self::LOGKEY,$item));
else
Log::channel('bbs')->emergency(sprintf('%s:! Uncaught Message: %s',self::LOGKEY,$e->getMessage()));
}
Log::channel('bbs')->info(sprintf('%s:= Finished: [%s]',self::LOGKEY,$item));
// Child finished we need to get out of this loop.
exit;
}
Log::channel('bbs')->debug(sprintf('%s:- Forked for [%s] (%d)',self::LOGKEY,$item,$pid));
$children->put($pid,$item);
}
// Wait for children to exit
while ($x=$children->count()) {
// Wait for children to finish
$exited = pcntl_wait($status);
if ($exited < 0)
abort(500,sprintf('Something strange for status: [%s] (%d)',pcntl_wifsignaled($status) ? pcntl_wtermsig($status) : 'unknown',$exited));
Log::channel('bbs')->info(sprintf('%s:= Exited: #%d [%s]',self::LOGKEY,$x,$children->pull($exited)));
}
// Done
Log::channel('bbs')->debug(sprintf('%s:= Finished.',self::LOGKEY));
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Mail\BBS;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Models\Service;
class SendToken extends Mailable
{
use Queueable, SerializesModels;
public $token = '';
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(string $token)
{
$this->token = $token;
}
/**
* Build the message.
*
* @return self
*/
public function build()
{
return $this
->markdown('email.bbs.sendtoken')
->subject('Token to complete registration')
->with(['token'=>$this->token]);
}
}

21
app/Models/BBS/CUG.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models\BBS;
use Illuminate\Database\Eloquent\Model;
class CUG extends Model
{
protected $table = 'cugs';
public function isMember(CUG $o)
{
while ($o)
{
if (! $this->parent_id OR $o->id == $this->parent_id)
return TRUE;
$o = $this::findOrFail($o->parent_id);
}
}
}

92
app/Models/BBS/Frame.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace App\Models\BBS;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use App\Casts\CompressedString;
/*
* Frames belong to a CUG, and if no cug present, they are assigned to CUG 2, the "null CUG" to which all users
* have access. In our implementation, we are using CUG 0 for this purpose.
*
* Users can see a frame if:
* + The User Access is "Y"
* + If the frame is assigned to a CUG, then only if the user is a member of that CUG can they access
* + If the User Access is "N", then only the IP owner can access the frame.
*
* Frame types:
* + I, i or space = information frame
* + A, a, R or r = response frame
*/
class Frame extends Model
{
protected $casts = [
'content' => CompressedString::class,
];
public $cache_content = '';
public function cug()
{
return $this->belongsTo(CUG::class);
}
/*
public function route()
{
return $this->hasOne(FrameMeta::class);
}
protected static function boot() {
parent::boot();
static::addGlobalScope('order', function (Builder $builder) {
$builder->orderBy('created_at','DESC');
});
}
*/
/**
* For cockroachDB, content is a "resource stream"
*
* @return bool|string
*/
public function xgetContentAttribute()
{
// For stream resources, we need to cache this result.
if (! $this->cache_content) {
$this->cache_content = is_resource($this->attributes['content'])
? stream_get_contents($this->attributes['content'])
: $this->attributes['content'];
}
return $this->cache_content;
}
/**
* Return the Page Number
*
* @return string
*/
public function getPageAttribute()
{
return $this->frame.$this->index;
}
public function hasFlag(string $flag)
{
// @todo When flags is in the DB update this.
return isset($this->flags) ? in_array($flag,$this->flags,FALSE) : FALSE;
}
/**
* Frame Types
*/
public function type()
{
return $this->type ?: 'i';
}
}

13
app/Models/BBS/Mode.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace App\Models\BBS;
use Illuminate\Database\Eloquent\Model;
class Mode extends Model
{
public function getNameAttribute(string $val): string
{
return strtolower($val);
}
}

View File

@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use App\Classes\BBS\Server\{Ansitex,Videotex};
use App\Classes\Protocol\{Binkp,DNS,EMSI};
/**
@ -48,6 +49,8 @@ class Setup extends Model
case 'binkp_bind':
case 'dns_bind':
case 'emsi_bind':
case 'ansitex_bind':
case 'videotex_bind':
return Arr::get($this->servers,str_replace('_','.',$key),self::BIND);
case 'binkp_port':
@ -57,6 +60,11 @@ class Setup extends Model
case 'emsi_port':
return Arr::get($this->servers,str_replace('_','.',$key),EMSI::PORT);
case 'ansitex_port':
return Arr::get($this->servers,str_replace('_','.',$key),Ansitex::PORT);
case 'videotex_port':
return Arr::get($this->servers,str_replace('_','.',$key),Videotex::PORT);
case 'options_options':
return Arr::get($this->options,'options');

View File

@ -29,6 +29,9 @@ class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory,Notifiable,HasApiTokens,UserSwitch,ScopeActive;
/** @var int Enables users have unlimited online time in the BBS */
public const FLAG_H = 1<<12;
/**
* The attributes that are mass assignable.
*
@ -91,6 +94,23 @@ class User extends Authenticatable implements MustVerifyEmail
->get();
}
public function hasExemption(int $flag): bool
{
// @todo To implement
return FALSE;
}
/**
* See if the user is already a member of the chosen network
*
* @param Domain $o
* @return bool
*/
public function isMember(Domain $o): bool
{
return FALSE;
}
/**
* Is this user a ZC of a domain?
*

13
config/bbs.php Normal file
View File

@ -0,0 +1,13 @@
<?php
/**
* BBS Configuration items
*/
return [
'home' => env('BBS_HOME', 1),
'inactive_login' => env('BBS_INACTIVE_LOGIN',300),
'inactive_nologin' => env('BBS_INACTIVE_NOLOGIN',60),
'login' => env('BBS_LOGIN', 98),
'register' => env('BBS_REGISTER', 981),
'welcome' => env('BBS_WELCOME', 980),
];

View File

@ -54,6 +54,13 @@ return [
'days' => 93,
],
'bbs' => [
'driver' => 'daily',
'path' => storage_path('logs/ansitex.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@ -0,0 +1,118 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$this->down();
Schema::create('modes', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('name',16);
$table->string('note',255)->nullable();
$table->unique(['name']);
});
Schema::create('cugs', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->string('name', 16);
$table->string('description', 255)->nullable();
$table->integer('parent_id')->nullable();
$table->foreign('parent_id')->references('id')->on('cugs');
$table->unique(['name']);
$table->softDeletes();
});
Schema::create('cug_users', function (Blueprint $table) {
$table->integer('cug_id')->unsigned();
$table->foreign('cug_id')->references('id')->on('cugs');
$table->integer('user_id')->unsigned();
$table->foreign('user_id')->references('id')->on('users');
$table->boolean('owner');
$table->unique(['user_id','cug_id']);
});
Schema::create('frames', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->integer('frame')->unsigned();
$table->char('index', 1);
$table->string('r0')->nullable();
$table->string('r1')->nullable();
$table->string('r2')->nullable();
$table->string('r3')->nullable();
$table->string('r4')->nullable();
$table->string('r5')->nullable();
$table->string('r6')->nullable();
$table->string('r7')->nullable();
$table->string('r8')->nullable();
$table->string('r9')->nullable();
$table->string('type', 2);
$table->smallInteger('cost')->default(0);
$table->boolean('access')->default(FALSE);
$table->boolean('public')->default(FALSE);
$table->binary('content');
$table->string('title',16)->nullable();
$table->string('note', 255)->nullable();
$table->boolean('cls')->default(TRUE);
//$table->unique(['frame','index','mode_id']); // Not needed since we have timewarp
$table->integer('mode_id')->unsigned();
$table->foreign('mode_id')->references('id')->on('modes');
$table->integer('cug_id')->unsigned()->default(0);
$table->foreign('cug_id')->references('id')->on('cugs');
$table->softDeletes();
});
DB::statement("
CREATE VIEW frame_view AS
(
SELECT F.id, F.frame || F.index as page,F.type,F.access,F.public,F.cls,C.name as cug,M.name as mode,F.cost,F.title,
F.r0,F.r1,F.r2,F.r3,F.r4,F.r5,F.r6,F.r7,F.r8,F.r9
FROM frames F
LEFT JOIN cugs C ON C.id=F.cug_id
LEFT JOIN modes M ON M.id=F.mode_id
ORDER BY
F.mode_id,F.frame,F.index
)
");
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::statement('DROP VIEW IF EXISTS frame_view');
Schema::dropIfExists('frames');
Schema::dropIfExists('cug_users');
Schema::dropIfExists('cugs');
Schema::dropIfExists('modes');
}
};

View File

@ -0,0 +1,42 @@
<?php
namespace Database\Seeders;
use Carbon\Carbon;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class BBSModes extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
DB::table('modes')
->insert([
'created_at'=>Carbon::now(),
'updated_at'=>Carbon::now(),
'name'=>'Viewdata',
'note'=>'Original 40x25 Viewdata'
]);
DB::table('modes')
->insert([
'created_at'=>Carbon::now(),
'updated_at'=>Carbon::now(),
'name'=>'Ansi',
'note'=>'ANSItex 80x25 mode, ANSI + Videotex'
]);
DB::table('cugs')
->insert([
'id'=>0,
'created_at'=>Carbon::now(),
'updated_at'=>Carbon::now(),
'name'=>'All Users',
'description'=>'These frames are visible to all users. All frames belong to this CUG, unless specified.',
]);
}
}

View File

@ -0,0 +1,14 @@
@component('mail::message')
# New User Token
Use this token to sign into ANSItex
If this email is a surprise to you, you can ignore it.
@component('mail::panel')
TOKEN: <b>{{ $token }}</b>
@endcomponent
Thanks,
{{ config('app.name') }}
@endcomponent