From c9688ef3738097f2aaaaa31854a4923b148098ad Mon Sep 17 00:00:00 2001 From: Deon George Date: Thu, 10 Aug 2023 12:17:01 +1000 Subject: [PATCH] BBS ported from vbbs --- app/Classes/BBS/Control.php | 101 ++ app/Classes/BBS/Control/EditFrame.php | 198 +++ app/Classes/BBS/Control/Register.php | 158 +++ app/Classes/BBS/Control/Telnet.php | 199 +++ app/Classes/BBS/Control/Test.php | 56 + .../ActionMissingInputsException.php | 9 + .../Exceptions/InvalidPasswordException.php | 9 + .../BBS/Exceptions/NoRouteException.php | 9 + .../Exceptions/ParentNotFoundException.php | 9 + app/Classes/BBS/Frame/Action.php | 73 + app/Classes/BBS/Frame/Action/Login.php | 50 + app/Classes/BBS/Frame/Action/Register.php | 112 ++ app/Classes/BBS/Frame/Char.php | 290 ++++ app/Classes/BBS/Frame/Field.php | 110 ++ app/Classes/BBS/Page.php | 628 +++++++++ app/Classes/BBS/Page/Ansi.php | 433 ++++++ app/Classes/BBS/Page/Viewdata.php | 370 +++++ app/Classes/BBS/Server.php | 1231 +++++++++++++++++ app/Classes/BBS/Server/Ansitex.php | 87 ++ app/Classes/BBS/Server/Videotex.php | 91 ++ app/Classes/BBS/Window.php | 365 +++++ app/Console/Commands/BBS/FrameImport.php | 104 ++ app/Console/Commands/BBS/Start.php | 128 ++ app/Mail/BBS/SendToken.php | 40 + app/Models/BBS/CUG.php | 21 + app/Models/BBS/Frame.php | 92 ++ app/Models/BBS/Mode.php | 13 + app/Models/Setup.php | 8 + app/Models/User.php | 20 + config/bbs.php | 13 + config/logging.php | 7 + database/migrations/2023_07_31_104758_bbs.php | 118 ++ database/seeders/BBSModes.php | 42 + resources/views/email/bbs/sendtoken.blade.php | 14 + 34 files changed, 5208 insertions(+) create mode 100644 app/Classes/BBS/Control.php create mode 100644 app/Classes/BBS/Control/EditFrame.php create mode 100644 app/Classes/BBS/Control/Register.php create mode 100644 app/Classes/BBS/Control/Telnet.php create mode 100644 app/Classes/BBS/Control/Test.php create mode 100644 app/Classes/BBS/Exceptions/ActionMissingInputsException.php create mode 100644 app/Classes/BBS/Exceptions/InvalidPasswordException.php create mode 100644 app/Classes/BBS/Exceptions/NoRouteException.php create mode 100644 app/Classes/BBS/Exceptions/ParentNotFoundException.php create mode 100644 app/Classes/BBS/Frame/Action.php create mode 100644 app/Classes/BBS/Frame/Action/Login.php create mode 100644 app/Classes/BBS/Frame/Action/Register.php create mode 100644 app/Classes/BBS/Frame/Char.php create mode 100644 app/Classes/BBS/Frame/Field.php create mode 100644 app/Classes/BBS/Page.php create mode 100644 app/Classes/BBS/Page/Ansi.php create mode 100644 app/Classes/BBS/Page/Viewdata.php create mode 100644 app/Classes/BBS/Server.php create mode 100644 app/Classes/BBS/Server/Ansitex.php create mode 100644 app/Classes/BBS/Server/Videotex.php create mode 100644 app/Classes/BBS/Window.php create mode 100644 app/Console/Commands/BBS/FrameImport.php create mode 100644 app/Console/Commands/BBS/Start.php create mode 100644 app/Mail/BBS/SendToken.php create mode 100644 app/Models/BBS/CUG.php create mode 100644 app/Models/BBS/Frame.php create mode 100644 app/Models/BBS/Mode.php create mode 100644 config/bbs.php create mode 100644 database/migrations/2023_07_31_104758_bbs.php create mode 100644 database/seeders/BBSModes.php create mode 100644 resources/views/email/bbs/sendtoken.blade.php diff --git a/app/Classes/BBS/Control.php b/app/Classes/BBS/Control.php new file mode 100644 index 0000000..b219c7a --- /dev/null +++ b/app/Classes/BBS/Control.php @@ -0,0 +1,101 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Control/EditFrame.php b/app/Classes/BBS/Control/EditFrame.php new file mode 100644 index 0000000..6e2080f --- /dev/null +++ b/app/Classes/BBS/Control/EditFrame.php @@ -0,0 +1,198 @@ +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__); + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Control/Register.php b/app/Classes/BBS/Control/Register.php new file mode 100644 index 0000000..e3ad0c3 --- /dev/null +++ b/app/Classes/BBS/Control/Register.php @@ -0,0 +1,158 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Control/Telnet.php b/app/Classes/BBS/Control/Telnet.php new file mode 100644 index 0000000..7fa39bd --- /dev/null +++ b/app/Classes/BBS/Control/Telnet.php @@ -0,0 +1,199 @@ +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 ''; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Control/Test.php b/app/Classes/BBS/Control/Test.php new file mode 100644 index 0000000..d9a5c09 --- /dev/null +++ b/app/Classes/BBS/Control/Test.php @@ -0,0 +1,56 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Exceptions/ActionMissingInputsException.php b/app/Classes/BBS/Exceptions/ActionMissingInputsException.php new file mode 100644 index 0000000..482f2f8 --- /dev/null +++ b/app/Classes/BBS/Exceptions/ActionMissingInputsException.php @@ -0,0 +1,9 @@ + 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(','))); + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Frame/Action/Login.php b/app/Classes/BBS/Frame/Action/Login.php new file mode 100644 index 0000000..3612d7a --- /dev/null +++ b/app/Classes/BBS/Frame/Action/Login.php @@ -0,0 +1,50 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Frame/Action/Register.php b/app/Classes/BBS/Frame/Action/Register.php new file mode 100644 index 0000000..d9c9f0e --- /dev/null +++ b/app/Classes/BBS/Frame/Action/Register.php @@ -0,0 +1,112 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Frame/Char.php b/app/Classes/BBS/Frame/Char.php new file mode 100644 index 0000000..cf43402 --- /dev/null +++ b/app/Classes/BBS/Frame/Char.php @@ -0,0 +1,290 @@ +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'); + } + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Frame/Field.php b/app/Classes/BBS/Frame/Field.php new file mode 100644 index 0000000..e248ec0 --- /dev/null +++ b/app/Classes/BBS/Frame/Field.php @@ -0,0 +1,110 @@ +{$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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Page.php b/app/Classes/BBS/Page.php new file mode 100644 index 0000000..d712d38 --- /dev/null +++ b/app/Classes/BBS/Page.php @@ -0,0 +1,628 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Page/Ansi.php b/app/Classes/BBS/Page/Ansi.php new file mode 100644 index 0000000..bab2547 --- /dev/null +++ b/app/Classes/BBS/Page/Ansi.php @@ -0,0 +1,433 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Page/Viewdata.php b/app/Classes/BBS/Page/Viewdata.php new file mode 100644 index 0000000..50a7890 --- /dev/null +++ b/app/Classes/BBS/Page/Viewdata.php @@ -0,0 +1,370 @@ + '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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Server.php b/app/Classes/BBS/Server.php new file mode 100644 index 0000000..823b6be --- /dev/null +++ b/app/Classes/BBS/Server.php @@ -0,0 +1,1231 @@ +blp" remembers how many chars are there, so that they can be replaced with next call + * @param string $text + * @param bool $reposition + */ + abstract function sendBaseline(string $text,bool $reposition=FALSE); + + /* METHODS */ + + public function __construct() + { + switch (get_class($this)) { + case Ansitex::class; + $this->po = new Ansi(config('bbs.welcome')); + break; + + case Videotex::class; + $this->po = new Viewdata(config('bbs.welcome')); + break; + + default: + throw new \Exception('Unknown server type: '.get_class($this)); + + } + + $this->fields = collect(); + } + + /** + * Setup the session with the client + * + * @param SocketClient $client + * @return void + * @throws \Exception + */ + protected function init(SocketClient $client) + { + $this->client = $client; + + define('MODE_BL', 1); // Typing a * command on the baseline + define('MODE_FIELD', 2); // typing into an input field + define('MODE_WARPTO', 3); // awaiting selection of a timewarp + define('MODE_COMPLETE', 4); // Entry of data is complete .. + define('MODE_SUBMITRF', 5); // asking if should send or not. + define('MODE_RFSENT', 6); + define('MODE_RFERROR', 7); + define('MODE_RFNOTSENT', 8); + + define('ACTION_RELOAD', 1); + define('ACTION_GOTO', 2); + define('ACTION_BACK', 3); + define('ACTION_NEXT', 4); + define('ACTION_INFO', 5); + define('ACTION_TERMINATE', 6); + define('ACTION_SUBMITRF', 7); // Offer to submit a response frame + define('ACTION_STAR', 8); + define('ACTION_EDIT', 9); // Edit current frame + + /** @deprecated */ + define('CONTROL_METHOD', 2); // Send input to an external method + /** @deprecated */ + define('CONTROL_EDIT', 3); // Controller to edit frame + + // Status messages + define('MSG_SENDORNOT', GREEN.'KEY 1 TO SEND, 2 NOT TO SEND'); + define('MSG_SENT', GREEN.'MESSAGE SENT - KEY '.HASH.' TO CONTINUE'); + define('MSG_NOTSENT', GREEN.'MESSAGE NOT SENT - KEY '.HASH.' TO CONTINUE'); + + define('ERR_DATABASE', RED.'UNAVAILABLE AT PRESENT - PLS TRY LATER'); + define('ERR_NOTSENT', RED.'MESSAGE NOT SENT DUE TO AN ERROR'); + define('ERR_PRIVATE', WHITE.'PRIVATE PAGE'.GREEN.SPACE.'- FOR EXPLANATION *37'.HASH.'..'); + define('ERR_ROUTE', WHITE.'MISTAKE?'.GREEN.SPACE.'TRY AGAIN OR TELL US ON *08'); + define('ERR_PAGE',ERR_ROUTE); + define('ERR_USER_ALREADYMEMBER', RED.'ALREADY MEMBER OF CUG'); + define('ERR_SYSTEM', RED.'SYSTEM ERROR'); + + define('MSG_TIMEWARP_ON', WHITE.'TIMEWARP ON'.GREEN.SPACE.'VIEW INFO WITH *02'); + define('MSG_TIMEWARP_OFF', WHITE.'TIMEWARP OFF'.GREEN.SPACE.'VIEWING DATE IS FIXED'); + define('MSG_TIMEWARP_TO', GREEN.'TIMEWARP TO %s'); + define('MSG_TIMEWARP', WHITE.'OTHER VERSIONS EXIST'.GREEN.SPACE.'KEY *02 TO VIEW'); + + // Setup session + $this->client->send(Telnet::send_iac('do_suppress_goahead'),static::TIMEOUT); + $this->client->send(Telnet::send_iac('wont_linemode'),static::TIMEOUT); + $this->client->send(Telnet::send_iac('will_echo'),static::TIMEOUT); + $this->client->send(Telnet::send_iac('will_xmit_binary'),static::TIMEOUT); + //$this->client->send(Telnet::send_iac('are_you_there'),static::TIMEOUT); // AYT + $this->client->send(Telnet::send_iac('do_opt_termtype').Telnet::send_iac('sn_start').chr(Telnet::TCP_OPT_TERMTYPE).chr(Telnet::TCP_OPT_ECHO).Telnet::send_iac('sn_end'),static::TIMEOUT); // Request Term Type + + $this->client->send(CLS.COFF,static::TIMEOUT); + $this->client->send('Press a key...',static::TIMEOUT); + } + + /** + * Write something to the system log. + * + * @param string $mode + * @param string $message + * @param array $data + */ + public function log(string $mode,string $message,array $data=[]) + { + Log::channel('bbs')->$mode($message,$data); + } + + /** + * Connection handler + * + * @param SocketClient $client + * @return int|null + * @throws SocketException + */ + public function onConnect(SocketClient $client): ?int + { + $pid = pcntl_fork(); + + if ($pid === -1) + throw new SocketException(SocketException::CANT_ACCEPT,'Could not fork process'); + + // Parent return ready for next connection + elseif (! $pid) + return NULL; + + $this->log('info',sprintf('%s:+ New connection, thread [%d] created',static::LOGKEY,$pid)); + Log::channel('bbs')->withContext(['pid'=>getmypid()]); + + $this->init($client); + $this->session(); + $this->client->close(); + exit(0); + } + + /** + * The core of the BBS functionality + * + * @return void + * @throws \Exception + */ + protected function session(): void + { + /** + * - NULL means no action + * - ACTION_* is the action + */ + $action = ACTION_GOTO; // Initial action + + /** + * State of the current action + * - NULL means we are not doing anything + * - MODE_* is the mode + * + * @var ?int $mode + */ + $mode = NULL; + + /** Variable holding our current key timeout value */ + $inkey_timeout = 5; + + /** + * Current Session Object that describe the terminal that the user has connected on + * - SessionViewdata - for ViewData sessions + * - SessionAnsitex - for ANSItex sessions + * @type {SessionAnsitex|SessionViewdata|null} + * + var so = null; + + /** + * Current input field being edited when a frame has input fields + * - NULL means we are not inputting on a field + * @type {number|null} + * + var fn = null; + + /** + * Current Input Field. + * @type {object} + * + var cf = null; + + /** + * User has hit the inactivity timeout without any input + * @type {boolean} + */ + $timeout = FALSE; + + /** Time the user hit the inactivity timeout */ + $timer = time(); + + /** + * Current Control Method + * @type {null} + * + var cc = null; + + /** + * We are receiving an extended key sequence (like a function key) + */ + $extendedkey = ''; + + /** + * ESC key sequence received + */ + $esc = FALSE; + + $timewarp = FALSE; // Is timewarp active. + $timewarpalt = FALSE; // Alternative timewarp frame to get + + /** + * Our current control method stack to execute + */ + $control = collect(); + + /** Current command being entered */ + $cmd = ''; + + /** Current logged in user */ + $user = new User; + + $current = []; // Attributes about the current page @deprecate ? - store this information in Page::class + $current['prevmode'] = FALSE; // Previous mode - in case we need to go back to MODE_FIELD @deprecate ? + + // Our BBS session loop + while ($action !== ACTION_TERMINATE) { + /** @var string $read The current input character */ + $read = NULL; + $esc = FALSE; + + try { + // If we have no action, read from the terminal + if ($action === NULL) { + // If a special key sequence is coming... + while (($esc || is_null($read)) && ($action !== ACTION_TERMINATE)) { + // Read a character from the client session + $read = $this->client->read($inkey_timeout,1); + + // Handle ESC keys + // We are entering a special keyboard char. + if ($read === ESC) { + $this->log('debug',sprintf('%s:- READ SPECIAL KEY COMING',static::LOGKEY)); + $esc = TRUE; + // We reduce our timeout, and assume the key is a function key. If the user pressed ESC we'll process that later + $inkey_timeout = 1; + + // If we got the ESC, but no [ then re-put the ESC in the read, we loose the current key + // @todo We loose the current pressed key + } elseif ($esc && ! $extendedkey && $read !== '[') { + $this->log('alert',sprintf('%s:- READ SPECIAL KEY ABANDONED [%s] (%x)',static::LOGKEY,$read,ord($read))); + $esc = FALSE; + $inkey_timeout = self::TIMEOUT; + $read = ESC; + + // Recognise when the ESC sequence has ended (with a ~ or ;) + } elseif ($esc && $extendedkey && ($read === '~' || $read === ';' || is_null($read))) { + switch ($extendedkey) { + case '[15': $read = FALSE; break; // F5 + case '[17': $read = FALSE; break; // F6 + case '[18': $read = FALSE; break; // F7 + case '[19': $read = FALSE; break; // F8 + case '[20': $read = FALSE; break; // F9 + case '[21': $read = chr(26); break; // F10 + case '[23': $read = FALSE; break; // F11 + case '[24': $read = FALSE; break; // F12 + default: + $this->log('alert',sprintf('%s:- READ UNKNOWN KEY [%s] (%x)',static::LOGKEY,$extendedkey,ord($extendedkey))); + $read = ''; + } + + $esc = FALSE; + $extendedkey = ''; + $inkey_timeout = self::TIMEOUT; + + // Record the character as an extended key + } elseif ($esc) { + $this->log('alert',sprintf('%s:- READ SPECIAL KEY [%s] (%x)',static::LOGKEY,$read,ord($read))); + + $extendedkey .= $read; + $read = FALSE; + } + + // Calculate idle timeouts + // If the user has exemption H we dont worry about timeout + if (is_null($read) && ((! $user->exists) || (! $user->hasExemption(User::FLAG_H)))) { + $this->log('debug',sprintf('%s:+ Empty read, evaluating timeouts',static::LOGKEY)); + + $idletime = config(sprintf('bbs.%s',($user->exists ? 'inactive_login' : 'inactive_nologin'))); + + // Terminate the user if they have been inactive too long. + if (time() > $timer+$idletime*1.5) { + $this->sendBaseline(RED.'INACTIVE'); + $action = ACTION_TERMINATE; + $mode = NULL; + + $this->log('alert',sprintf('%s:+ User INACTIVE - terminating...',static::LOGKEY)); + + // Idle warning - due to inactivity. + } elseif (time() > $timer+$idletime) { + $timeout = TRUE; + $this->sendBaseline(RED.'INACTIVITY DISCONNECT PENDING'); + + /* + if (cf) { + so.gotoxy(cf.x+cf.value.length,cf.y); + so.attr(cf.attribute); + } + */ + } + + } else { + // If the user become active during inactivity, clear the baseline message + if ($timeout) { + echo 'so.baselineClear(false)'; + + /* + if (cf) { + so.gotoxy(cf.x+cf.value.length,cf.y); + so.attr(cf.attribute); + } + */ + } + + $timer = time(); + $timeout = FALSE; + } + + // If we are in a control, we need to break here so that the control takes the input + if ($control->count()) + break; + } + + $this->log('debug', + sprintf('%s:+ Got: %s [%02x]: Mode: [%02x], Action: [%02x], Control: [%d]', + static::LOGKEY, + ((ord($read) < 32) || (ord($read) > 250)) ? '.' : $read, + ord($read), + $mode, + $action, + $control->count())); + + // Handle telnet IAC commands + if ((ord($read) === Telnet::TCP_IAC) && ((! $control->last()) || ($control->last()->name !== Telnet::class))) { + $this->log('debug',sprintf('%s:- We got a TELNET command',static::LOGKEY)); + + // Process telnet IAC commands + $control->push(Control::factory(Telnet::class,$this)); + } + } + + // Run CONTROL, only if we are not on the bottom line + if (($mode !== MODE_BL) && $control->count()) { + $this->log('debug',sprintf('%s:= Start CONTROL: Going to method: %s',static::LOGKEY,get_class($control->last()))); + + /* + // Capture our state when we enter this method. + if (! array_key_exists('control',$control->last()->state)) { + $control->last()->state['control'] = $control; + $control->last()->state['action'] = $action; + } + + $control->last()->state['mode'] = $mode; + //$action = NULL; + */ + + // Pass Control to Method + // @todo do we need $current? + $read = $control->last()->handle($read,$current); + //$mode = $control->last()->state['mode']; + + if ($control->last()->complete) { + $this->log('info',sprintf('%s:- Complete CONTROL: %s',static::LOGKEY,get_class($control->last()))); + $save = $control->pop(); + + /* + if ($control->count()) { + $control = $control->last()->state['control']; + + } else { + $mode = $save->state['mode']; + $action = $save->state['action']; + $control = FALSE; + } + */ + } + + $this->log('debug',sprintf('%s:= End CONTROL: Read %s [%02x]: Mode: [%02x], Action: [%02x], Control: [%d]', + static::LOGKEY, + ((ord($read) < 32) || (ord($read) > 250)) ? '.' : $read, + ord($read), + $mode, + $action, + $control->count())); + } + + $this->log('debug',sprintf('%s:= Start MODE: [%02x]',static::LOGKEY,$mode)); + switch ($mode) { + // Key presses during field input. + case MODE_FIELD: + $cmd = ''; + $action = NULL; + + switch ($this->po->type) { + // Login frame. + case Page::FRAMETYPE_LOGIN: + switch ($read) { + case CR: + case HASH: + // If we are the main login screen, see if it is a new user + if ($this->po->isCUG(0)) { + if (strtolower($this->po->field_current->value) === 'new') { + $action = ACTION_GOTO; + $this->po->goto(config('bbs.register')); + $read = NULL; + $mode = NULL; + + break 2; + } + } + + //dump('not a public CUG'); + + break; + } + + // Response frame. + case Page::FRAMETYPE_RESPONSE: + switch ($read) { + // End of field entry. + case CR: + case HASH: + // For response frames, see if we have any field actions + // We'll use the submit key's method (1) and execute any pre-action on fields + try { + if ($msg=$frame_submit_method->preSubmitField($this,$this->po->field_current)) { + $this->sendBaseline(RED.strtoupper($msg)); + + // Next Field + } else + $this->po->fieldNext(); + + } catch (\Exception $e) { + $this->log('alert',(sprintf('Pre field exception [%s] for [%s] on page [%s]',$e->getMessage(),$this->po->field_current->name,$this->po->page))); + $this->sendBaseline(RED.'PRE FIELD ERROR'); + } + + if ($x=$this->po->field_current) { + $this->client->send($this->moveCursor($x->X,$x->y).$this->po->attr($x->attribute),static::TIMEOUT); + $mode = MODE_FIELD; + + // Finished all editable fields. + } else { + $action = ACTION_SUBMITRF; + } + + break; + + case STAR: + $current['prevmode'] = MODE_FIELD; + $action = ACTION_STAR; + + break; + + case KEY_DELETE: + if ($this->po->field_current->delete()) + $this->client->send(LEFT.$this->po->field_current->pad.LEFT,static::TIMEOUT); + + break; + + case ESC: + break; + + // Record Data Entry + default: + if (ord($read) > 31 && $this->po->field_current->append($read)) + $this->client->send($this->po->field_current->mask ?: $read,static::TIMEOUT); + } + + break; + + // Other Frame Types - Shouldnt get here. + default: + $this->client->close(); + + throw new \Exception('Shouldnt get here',500); + } + + break; + + // Form submission: 1 to send, 2 not to send. + case MODE_SUBMITRF: + switch ($read) { + case '1': + // If we are in a control method, complete it + if ($control->count()) { + $control->last()->process(); + + } elseif ($this->po->isRoute(1)) { + $this->sendBaseline(RED.'NO ACTION PERFORMED'); + $mode = MODE_RFSENT; + + } elseif ($frame_submit_method) { + $frame_submit_method->fields_input = $this->po->fields_input; + + try { + $result = $frame_submit_method->handle(); + + // Is this a user logging in? + if ( + (($frame_submit_method instanceof Action\Login) || ($frame_submit_method instanceof Action\Register)) + && $result) + { + $user = $frame_submit_method->user; + $this->po->resetHistory(); + $this->log('info',sprintf('User [%s] logged in',$user->name)); + $this->po->next(); + $this->po->showheader = TRUE; + $action = ACTION_GOTO; + $mode = NULL; + } + + } catch (ActionMissingInputsException $e) { + $this->log('alert',sprintf('Missing [%s] on page [%s]',$e->getMessage(),$this->po->page)); + + $this->sendBaseline(RED.'MISSING DETAILS, TRY AGAIN *00'); + $mode = NULL; + $action = NULL; + + } catch (InvalidPasswordException $e) { + $this->sendBaseline(RED.'INVALID PASSWORD, TRY AGAIN *00'); + $mode = NULL; + $action = NULL; + + } catch (ModelNotFoundException $e) { + $this->sendBaseline(RED.'USER NOT FOUND, TRY AGAIN *00'); + + $mode = NULL; + $action = NULL; + + } catch (\Exception $e) { + $this->log('error',sprintf('Exception [%s]during action: %s on line %d in %s',get_class($e),$e->getMessage(),$e->getLine(),$e->getFile())); + $this->sendBaseline(RED.'UNCAUGHT EXCEPTION, TRY AGAIN *00'); + $mode = NULL; + $action = NULL; + } + + $frame_submit_method = NULL; + + } else { + $this->sendBaseline(RED.'NO method exists...'); + + $mode = MODE_RFSENT; + } + + break; + + case '2': + // // For response frames, see if we have any field actions + // We'll use key2 method and execute any post-undo on fields + // @todo Check if HASH is a valid next destination + $frame_submit_method = NULL; + $this->sendBaseline(MSG_NOTSENT); + $mode = MODE_RFNOTSENT; + + // If a Control method was rejected, we can clear it + if ($control->count()) { + $save = $control->pop(); + + /* + if ($control->count()) { + //$control = $control->last()->state['control']; + + } else { + $mode = $save->state['mode']; + $action = $save->state['action']; + $control = FALSE; + } + */ + } + + break; + + case STAR: + $action = ACTION_STAR; + + break; + } + + break; + + // Response form ERROR + case MODE_RFERROR: + // Response form after NOT sending + case MODE_RFNOTSENT: + // Response form after Sent processing + case MODE_RFSENT: + $this->client->send(COFF,static::TIMEOUT); + + if ($read === HASH) { + if ($this->po->isRoute(2)) { + $this->po->route(2); + + } elseif ($this->po->haveNext()) { + $this->po->next(); + + } elseif ($this->po->isRoute(0)) { + $this->po->route(0); + + // No further routes defined, go home. + } else { + $this->po->goto(0); + } + + $action = ACTION_GOTO; + + } elseif ($read === STAR) { + $action = ACTION_STAR; + + break; + } + + break; + + // List of alternate frames has been presented + case MODE_WARPTO: + // @todo If we are in a control, we need to terminate it. + // @todo only enable warp for information frames + if (is_numeric($read) AND $read) { + $timewarpalt = $alts->get($read-1)->id; + $action = ACTION_GOTO; + + } elseif ($read === '0') { + $action = ACTION_RELOAD; + } + + break; + + // Not doing anything in particular. + case MODE_COMPLETE: + case FALSE: + $this->log('debug','Idle'); + $cmd = ''; + + switch ($read) { + case HASH: + $action = ACTION_NEXT; + break; + + case STAR: + $action = ACTION_STAR; + break; + + // Frame Routing + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + if ($this->po->isRoute($read)) { + $this->po->route($read); + $action = ACTION_GOTO; + + } else { + $this->sendBaseline(ERR_ROUTE); + $mode = $action = NULL; + } + + break; + } + + break; + + // Currently accepting baseline input after a * was received + case MODE_BL: + // if it's a number, continue entry + if (strpos('0123456789',$read) !== FALSE) { + $this->client->send($read,static::TIMEOUT); + $this->blp++; + $cmd .= $read; + } + + // If its a backspace, delete last input + if (($read === KEY_DELETE) && strlen($cmd)) { + $this->client->send(BS.' '.BS,static::TIMEOUT); + $this->blp--; + $cmd = substr($cmd,0,-1); + } + + // if we hit a special numeric command, deal with it. + // Refresh page + if ($cmd === '00') { + $this->client->send(COFF,static::TIMEOUT); + $action = ACTION_RELOAD; + $cmd = ''; + $mode = NULL; + + break; + } + + // Toggle Timewarp Mode + if ($cmd === '01') { + $this->client->send(COFF,static::TIMEOUT); + $timewarp = !$timewarp; + $this->sendBaseline(($timewarp ? MSG_TIMEWARP_ON : MSG_TIMEWARP_OFF)); + $cmd = ''; + $mode = NULL; + + /* + if ($current['prevmode'] === MODE_FIELD) { + $mode = $current['prevmode']; + $current['prevmode'] = FALSE; + + if ($x=$this->po->field_current()) { + // @todo This WHITE should be removed, and the color set to whatever is in the frame + $this->client->send($this->moveCursor($x->x+strlen($this->po->field_currentCurrentInput()),$x->y).CON.WHITE,static::TIMEOUT); + } + } + */ + + break; + } + + // Present Timewarp Frames + if ($cmd === '02') { + $this->client->send(COFF,static::TIMEOUT); + $mode = NULL; + $cmd = ''; + + if ($timewarp && $user->exists) { + $action = ACTION_INFO; + + break; + } + + $this->log('alert','Refusing timewarp for anonymous user: '.$this->po->page); + $this->sendBaseline(ERR_ROUTE); + } + + // Invalid system pages. + if (preg_match('/^0[1367]/',$cmd)) { + $this->log('alert','Invalid System Page: '.$cmd); + + $mode = NULL; + $cmd = ''; + $this->client->send(COFF,static::TIMEOUT); + $this->sendBaseline(ERR_ROUTE); + } + + // Edit frame + // Catch if we are going to edit a child frame + if (preg_match('/^04/',$cmd) && preg_match('/^[a-z]$/',$read)) { + $this->client->send(COFF,static::TIMEOUT); + $this->po->goto(substr($cmd,2),$read); + $mode = NULL; + $cmd = ''; + + $action = ACTION_EDIT; + + break; + } + + // Bookmark page + if ($cmd === '05') { + $this->sendBaseline(RED.'NOT IMPLEMENTED YET?'); + $mode = NULL; + + break; + } + + // Report a problem + if ($cmd === '08') { + $this->sendBaseline(RED.'NOT IMPLEMENTED YET?'); + $mode = NULL; + + break; + } + + // Reload page + if ($cmd === '09') { + $this->client->send(COFF,static::TIMEOUT); + $action = ACTION_GOTO; + $cmd = ''; + + break; + } + + // Another star aborts the command. + if ($read === STAR) { + $action = NULL; + $this->sendBaseline(Arr::get($current,'baseline','')); + $cmd = ''; + + if ($current['prevmode'] === MODE_FIELD) { + $mode = $current['prevmode']; + $current['prevmode'] = FALSE; + + if (! ($x=$this->po->field_current)) + $this->po->fieldPrev(); + + if ($x=$this->po->field_current) { + $this->client->send($this->moveCursor($x->x,$x->y).CON.$this->po->attr($x->attribute),static::TIMEOUT); + $this->client->send(str_repeat($x->pad,$x->size),static::TIMEOUT); + $this->client->send($this->moveCursor($x->x,$x->y),static::TIMEOUT); + $x->clear(); + } + + } else { + $mode = FALSE; + } + + break; + } + + // Complete request + if (($read === HASH) || ($read === CR)) { + $this->client->send(COFF,static::TIMEOUT); + $timewarpalt = FALSE; + + // If input is in a control, terminate it + if ($control) { + $control->pop(); + //$control = FALSE; + + // Our method count should be zero + if ($control->count()) { + dump($control); + throw new \Exception('Method count should be zero, but its not...',500); + } + } + + // Nothing typed between * and # + // *# means go back + if ($cmd === '') { + $action = ACTION_BACK; + + // Edit Frame + } elseif (preg_match('/^04/',$cmd)) { + $this->client->send(COFF,static::TIMEOUT); + $action = ACTION_EDIT; + $this->po->goto(substr($cmd,2) ?: $this->po->frame); + + } else { + $this->po->goto($cmd); + $action = ACTION_GOTO; + } + + // Clear the command, we are finished processing + $cmd = ''; + $mode = FALSE; + + break; + } + + break; + + default: + $this->log('debug','Not sure what we were doing?'); + } + + $this->log('debug',sprintf('%s:= End MODE: Read %s [%02x]: Mode: [%02x], Action: [%02x], Control: [%d]', + static::LOGKEY, + ((ord($read) < 32) || (ord($read) > 250)) ? '.' : $read, + ord($read), + $mode, + $action, + $control->count())); + + // This section performs some action if it is deemed necessary + $this->log('debug',sprintf('%s:= Start ACTION: [%02x]',static::LOGKEY,$action)); + switch ($action) { + case ACTION_STAR: + // If there is something on the baseline, lets preserve it + if ($this->blp) { + $current['baseline'] = $this->baseline; + } + + $this->sendBaseline(GREEN.STAR,TRUE); + $this->client->send(CON,static::TIMEOUT); + $action = NULL; + $mode = MODE_BL; + + break; + + case ACTION_SUBMITRF: + $action = NULL; + $this->client->send(COFF,static::TIMEOUT); + $this->sendBaseline(MSG_SENDORNOT); + $mode = MODE_SUBMITRF; + + break; + + // Edit Frame + case ACTION_EDIT: + $this->log('debug','Editing frame:',[$this->po->page]); + + $next_fo = NULL; + // If we are editing a different frame, load it + try { + $this->po->goto($this->po->frame,$this->po->index); + + } catch (ModelNotFoundException $e) { + try { + $this->po->new($this->po->frame,$this->po->index); + + } catch (ParentNotFoundException $e) { + $this->sendBaseline('PARENT_NOT_FOUND'); + $action = NULL; + break; + } + } + + /* + //$control = CONTROL_EDIT; + $control->push(Control::factory('editframe',$this,['fo'=>$next_fo])); + //$control->last()->state['control'] = $control; + //$control->last()->state['action'] = FALSE; + //$control->last()->state['mode'] = MODE_FIELD; + $action = NULL; + */ + + break; + + // Go Backwards + case ACTION_BACK: + // If there is no next page, we'll refresh the current page. + if ($this->po->back()) + $this->log('debug','Backing up to:'.$this->po->page); + + // Go to next index frame. + case ACTION_NEXT: + // We need this extra test in case we come from ACTION_BACK + if ($action === ACTION_NEXT) + $this->po->next(); + + // Look for requested page - charge for it to be loaded. + case ACTION_GOTO: + // If our target frame is 0, we'll go to our home page + if (($this->po->frame === 0) && ($this->po->index === 'a')) + $this->po->goto(config('bbs.'.($user->exists ? 'home' : 'welcome'))); + + // If we wanted a "Searching..." message, this is where to put it. + try { + // Store our next frame in a temporary var while we determine if it can be displayed + $timewarpalt + ? $this->po->get($timewarpalt) + : $this->po->load(); + + $this->log('debug',sprintf('Fetched frame: [%s] (%d)',$this->po->page,$this->po->id)); + + } catch (ModelNotFoundException $e) { + $this->log('alert',sprintf('Frame doesnt exist: [%s]',$this->po->page)); + + // @todo Make sure parent frame exists, or display error + $this->sendBaseline(ERR_PAGE); + $mode = $action = NULL; + + break; + } + + // Is there a user logged in + if ($user->exists) { + if ($this->po->public && $this->po->access) { + if (($this->po->type === Page::FRAMETYPE_LOGIN) && $user->isMemberCUG($this->po->cug)) { + $this->sendBaseline(ERR_USER_ALREADYMEMBER); + $this->po->back(); + $mode = $action = NULL; + $this->log('alert',sprintf('Frame Denied - Already Member: [%s] (%d)',$this->po->page,$this->po->id)); + + break; + } + + // If this is a login frame and the user is already a member. + } else { + if (! $this->po->isOwner($user)) { + if (! $this->po->access) { + $this->sendBaseline(ERR_PAGE); + $this->log('alert',sprintf('Frame Denied - In Accessible: [%s] (%d)',$this->po->page,$this->po->id)); + $this->po->back(); + $mode = $action = NULL; + + break; + } + + if (! $user->isMemberCUG($this->po->cug)) { + $this->sendBaseline(ERR_PRIVATE); + $this->po->back(); + $mode = $action = NULL; + $this->log('alert',sprintf('Frame Denied - Not in CUG [%d]: [%s] (%d)',$this->po->cug,$this->po->page,$this->po->id)); + + break; + } + } + } + + } else { + // Is this a public frame in CUG 0? + if ((! $this->po->isCUG(0)) || (! $this->po->public)) { + $this->sendBaseline(ERR_PAGE); + $this->po->back(); + $mode = $action = NULL; + + break; + } + } + + $timewarpalt = NULL; + + // Build our page for rendering + $this->po->build(); + + // drop into + case ACTION_RELOAD: + // Clear the baseline history + $this->sendBaseline(''); + $current['baseline'] = ''; + + $output = ($this->po->cls ? CLS : HOME).$this->po; + + if ($timewarpalt) + $this->sendBaseline(sprintf(MSG_TIMEWARP_TO,$this->po->created_at->format('Y-m-d H:i:s'))); + + switch ($this->po->type) { + default: + // Standard Frame + case Page::FRAMETYPE_INFO: + $this->client->send($output,static::TIMEOUT); + $mode = $action = NULL; + + break; + + // Login Frame. + case Page::FRAMETYPE_LOGIN: + $this->client->send($output,static::TIMEOUT); + $action = NULL; + $output = ''; + + // If this is the registration page + if ($this->po->page === config('bbs.register').'a') { + //$control = CONTROL_METHOD; + $control->push(Control::factory('register',$this)); + /* + $control->last()->state['control'] = $control; + $control->last()->state['action'] = FALSE; + $control->last()->state['mode'] = MODE_FIELD; + */ + } + + // Active Frame. Prestel uses this for a Response Frame. + case Page::FRAMETYPE_RESPONSE: + $this->client->send($output,static::TIMEOUT); + + // Our submit method + $frame_submit_method = $this->po->method(1); + + if ($this->po->fields_input->count()) { + $this->po->fieldReset(); + + if ($x=$this->po->fieldNext()) { + $mode = MODE_FIELD; + $this->client->send($this->moveCursor($x->x,$x->y).CON.$this->po->attr($x->attribute),static::TIMEOUT); + + // There were no editable fields. + } else { + $mode = MODE_COMPLETE; + $this->client->send(COFF,static::TIMEOUT); + } + + } else { + $mode = NULL; + } + + $action = NULL; + + break; + + // External Frame - run by a control. + case Page::FRAMETYPE_EXTERNAL: + $external = explode(' ',(string)$this->po); + $x = Control::factory(array_shift($external),$this,$external); + + if (! $x) + { + $this->sendBaseline(ERR_PAGE); + $mode = $action = NULL; + break; + } + + $control->push($x); + //$control = CONTROL_METHOD; + $action = NULL; + + break; + + // Terminate Frame + case Page::FRAMETYPE_TERMINATE: + $this->client->send($output,static::TIMEOUT); + $action = ACTION_TERMINATE; + + break; + } + + break; + + // Timewarp Mode + case ACTION_INFO: + $mode = $action = NULL; + $cmd = ''; + $y = 0; + + $output = $this->moveCursor(0,$y++).WHITE.NEWBG.RED.'TIMEWARP INFO FOR Pg.'.BLUE.$this->po->page.WHITE; + $output .= $this->moveCursor(0,$y++).WHITE.NEWBG.BLUE.'Dated : ' .substr($this->po->created_at->format('j F Y').str_repeat(' ',27),0,27); + + $alts = $this->po->alts(); + + if (count($alts)) { + $n = 1; + + $output .= $this->moveCursor(0,$y++).WHITE.NEWBG.RED.'ALTERNATIVE VERSIONS:'.str_repeat(' ',16); + + foreach ($alts as $o) { + $date = $o->created_at->format('d M Y'); + + $line = WHITE.NEWBG; + + if ($timewarp) { + $line .= RED.$n++; + } + + $line .= BLUE.$date.' '.$o->note; + + $output .= $this->moveCursor(0,$y++).$line.str_repeat(' ',$this->po->width-$this->po->strlenv($line)); + } + + if ($timewarp) { + $mode = MODE_WARPTO; + } + + } + + $this->client->send($output,static::TIMEOUT); + + break; + } + + $this->log('debug',sprintf('%s:= End ACTION: Read %s [%02x]: Mode: [%02x], Action: [%02x], Control: [%d]', + static::LOGKEY, + ((ord($read) < 32) || (ord($read) > 250)) ? '.' : $read, + ord($read), + $mode, + $action, + $control->count())); + + /* + // Did the client disconnect + if ($read === NULL || socket_last_error()) { + $this->log('debug',sprintf('Client Disconnected: %s',$this->client->address_remote),['read'=>$read,'socket_last_error'=>socket_strerror(socket_last_error())]); + $this->client->close(); + + return; + } + */ + + // @todo Turn cursor on + + // Something bad happened. We'll log it and then disconnect. + } catch (\Exception $e) { + $this->log('error',sprintf('! ERROR: %s (%s)',$e->getMessage(),get_class($e)),['line'=>$e->getLine(),'file'=>$e->getFile()]); + + $this->sendBaseline(ERR_SYSTEM); + $action = $mode = NULL; + + throw $e; + } + } + + $this->log('debug',sprintf('Disconnected: %s',$this->client->address_remote)); + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Server/Ansitex.php b/app/Classes/BBS/Server/Ansitex.php new file mode 100644 index 0000000..366f30a --- /dev/null +++ b/app/Classes/BBS/Server/Ansitex.php @@ -0,0 +1,87 @@ +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; + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Server/Videotex.php b/app/Classes/BBS/Server/Videotex.php new file mode 100644 index 0000000..c2983ed --- /dev/null +++ b/app/Classes/BBS/Server/Videotex.php @@ -0,0 +1,91 @@ +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); + } +} \ No newline at end of file diff --git a/app/Classes/BBS/Window.php b/app/Classes/BBS/Window.php new file mode 100644 index 0000000..2965cbe --- /dev/null +++ b/app/Classes/BBS/Window.php @@ -0,0 +1,365 @@ + 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]; + } +} \ No newline at end of file diff --git a/app/Console/Commands/BBS/FrameImport.php b/app/Console/Commands/BBS/FrameImport.php new file mode 100644 index 0000000..2e6176b --- /dev/null +++ b/app/Console/Commands/BBS/FrameImport.php @@ -0,0 +1,104 @@ +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)); + } +} \ No newline at end of file diff --git a/app/Console/Commands/BBS/Start.php b/app/Console/Commands/BBS/Start.php new file mode 100644 index 0000000..c89d83d --- /dev/null +++ b/app/Console/Commands/BBS/Start.php @@ -0,0 +1,128 @@ +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)); + } +} \ No newline at end of file diff --git a/app/Mail/BBS/SendToken.php b/app/Mail/BBS/SendToken.php new file mode 100644 index 0000000..01e8c6f --- /dev/null +++ b/app/Mail/BBS/SendToken.php @@ -0,0 +1,40 @@ +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]); + } +} \ No newline at end of file diff --git a/app/Models/BBS/CUG.php b/app/Models/BBS/CUG.php new file mode 100644 index 0000000..32edd9d --- /dev/null +++ b/app/Models/BBS/CUG.php @@ -0,0 +1,21 @@ +parent_id OR $o->id == $this->parent_id) + return TRUE; + + $o = $this::findOrFail($o->parent_id); + } + } +} \ No newline at end of file diff --git a/app/Models/BBS/Frame.php b/app/Models/BBS/Frame.php new file mode 100644 index 0000000..5aafa99 --- /dev/null +++ b/app/Models/BBS/Frame.php @@ -0,0 +1,92 @@ + 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'; + } +} \ No newline at end of file diff --git a/app/Models/BBS/Mode.php b/app/Models/BBS/Mode.php new file mode 100644 index 0000000..efcf92d --- /dev/null +++ b/app/Models/BBS/Mode.php @@ -0,0 +1,13 @@ +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'); diff --git a/app/Models/User.php b/app/Models/User.php index efdaab8..9d6e69d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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? * diff --git a/config/bbs.php b/config/bbs.php new file mode 100644 index 0000000..820bd9f --- /dev/null +++ b/config/bbs.php @@ -0,0 +1,13 @@ + 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), +]; \ No newline at end of file diff --git a/config/logging.php b/config/logging.php index 5a99e96..54521c1 100644 --- a/config/logging.php +++ b/config/logging.php @@ -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'), diff --git a/database/migrations/2023_07_31_104758_bbs.php b/database/migrations/2023_07_31_104758_bbs.php new file mode 100644 index 0000000..7631636 --- /dev/null +++ b/database/migrations/2023_07_31_104758_bbs.php @@ -0,0 +1,118 @@ +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'); + } +}; \ No newline at end of file diff --git a/database/seeders/BBSModes.php b/database/seeders/BBSModes.php new file mode 100644 index 0000000..4d164ee --- /dev/null +++ b/database/seeders/BBSModes.php @@ -0,0 +1,42 @@ +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.', + ]); + } +} diff --git a/resources/views/email/bbs/sendtoken.blade.php b/resources/views/email/bbs/sendtoken.blade.php new file mode 100644 index 0000000..f6a7844 --- /dev/null +++ b/resources/views/email/bbs/sendtoken.blade.php @@ -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: {{ $token }} +@endcomponent + +Thanks, +{{ config('app.name') }} +@endcomponent \ No newline at end of file