<?phpnamespace App\Command;use App\Chat\ChatServer;use App\Chat\RoomStore;use App\Net\TcpServer;use Symfony\Component\Console\Attribute\AsCommand;use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Input\InputOption;use Symfony\Component\Console\Output\OutputInterface;/** * The server: listens for the chat clients until SIGTERM / SIGINT (docker stop, Ctrl+C), then saves the permanent * rooms. Settings: options, else CARAMAIL_HOST, CARAMAIL_PORT, CARAMAIL_ADMIN_KEY, CARAMAIL_SUPER_KEY, CARAMAIL_OWNER_IPS. */#[AsCommand('serve', 'Runs the chat server (TCP, the protocol of the 2002 applet)')]final class ServeCommand extends Command{ public function __construct(private readonly string $data) { parent::__construct(); } protected function configure(): void { $this ->addOption('host', null, InputOption::VALUE_REQUIRED, 'address to listen on', getenv('CARAMAIL_HOST') ?: '0.0.0.0') ->addOption('port', 'p', InputOption::VALUE_REQUIRED, 'TCP port for the clients', getenv('CARAMAIL_PORT') ?: '4500') ->addOption('ws-port', null, InputOption::VALUE_REQUIRED, 'WebSocket port (behind the web server on 443; 0 = none)', getenv('CARAMAIL_WS_PORT') ?: '8080') ->addOption('admin-key', null, InputOption::VALUE_REQUIRED, 'key that makes a client a moderator (in its tempid: k=...)', getenv('CARAMAIL_ADMIN_KEY') ?: '') ->addOption('super-key', null, InputOption::VALUE_REQUIRED, 'key that makes a client an administrator (admin 2)', getenv('CARAMAIL_SUPER_KEY') ?: '') ->addOption('owner-ips', null, InputOption::VALUE_REQUIRED, 'the owner\'s public addresses, comma separated: the login form offers them normal / moderator / admin', getenv('CARAMAIL_OWNER_IPS') ?: ''); } protected function execute(InputInterface $input, OutputInterface $output): int { if (!function_exists('stream_socket_server') || getenv('JUMPHP_WEB_TERMINAL')) { $output->writeln('<error>The server needs a real machine (Docker, a service): a browser cannot listen on a port.</error>'); return Command::FAILURE; } $log = static function (string $line) use ($output): void { $output->writeln(date('[Y-m-d H:i:s] ') . $line); }; $store = new RoomStore($this->data . '/rooms.json'); $chat = new ChatServer($store, (string)$input->getOption('admin-key'), $output->isVerbose() ? $log : static function (string $l) use ($log): void { if (preg_match('/^(login|leave|drop|unknown|.* ejects )/', $l)) { $log($l); } }); $chat->setRoles((string)$input->getOption('super-key'), explode(',', (string)$input->getOption('owner-ips'))); $server = new TcpServer($chat, $log); try { $address = $server->listen((string)$input->getOption('host'), (int)$input->getOption('port')); if ((int)$input->getOption('ws-port') > 0) { $address .= ', WebSocket ' . $server->listenWebSocket((string)$input->getOption('host'), (int)$input->getOption('ws-port')); } } catch (\RuntimeException $e) { $output->writeln('<error>' . $e->getMessage() . '</error>'); return Command::FAILURE; } $log(ChatServer::BANNER . " listening on $address, data in {$this->data}"); if (function_exists('pcntl_async_signals')) { pcntl_async_signals(true); foreach ([SIGTERM, SIGINT, SIGHUP] as $signal) { pcntl_signal($signal, static fn () => $server->stop()); } } $status = new StatusFile($this->data . '/status.json'); $saveEvery = 60; $server->run(function () use ($chat, $status, $store, &$saveEvery): void { $status->write($chat->stats()); if (--$saveEvery <= 0) { $saveEvery = 60; $store->save($chat->rooms()); } }); $chat->shutdown(); $status->remove(); $log('stopped, rooms saved'); return Command::SUCCESS; }}