diff --git a/WHATSNEW.md b/WHATSNEW.md index c774f86b..8a74c94e 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -14,7 +14,8 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * Many additional backward-compatible bug fixes since the first release of 0.0.12-beta. See the [project repository](https://github.com/NuSkooler/enigma-bbs) for more information. * Deprecated Gopher's `messageConferences` configuration key in favor of a easier to deal with `exposedConfAreas` allowing wildcards and exclusions. See [Gopher](./docs/servers/contentservers/gopher.md). * NNTP write (aka POST) access support for authenticated users over TLS. -* [Advanced MCI formatting](./docs/art/mci.md#mci-formatting) +* [Advanced MCI formatting](./docs/art/mci.md#mci-formatting)! +* Additional options in the `abracadabra` module for launching doors. See [Local Doors](./docs/modding/local-doors.md) ## 0.0.12-beta * The `master` branch has become mainline. What this means to users is `git pull` will always give you the latest and greatest. Make sure to read [Updating](./docs/admin/updating.md) and keep an eye on `WHATSNEW.md` (this file) and [UPGRADE](UPGRADE.md)! See also [ticket #276](https://github.com/NuSkooler/enigma-bbs/issues/276). diff --git a/core/abracadabra.js b/core/abracadabra.js index 71338ac1..0d24d5f7 100644 --- a/core/abracadabra.js +++ b/core/abracadabra.js @@ -9,6 +9,7 @@ const ansi = require('./ansi_term.js'); const { Errors } = require('./enig_error.js'); const { trackDoorRunBegin, trackDoorRunEnd } = require('./door_util.js'); const Log = require('./logger').log; +const Config = require('./config.js').get; // deps const async = require('async'); @@ -181,6 +182,8 @@ exports.getModule = class AbracadabraModule extends MenuModule { const exeInfo = { name: this.config.name, cmd: this.config.cmd, + preCmd: this.config.preCmd, + preCmdArgs: this.config.preCmdArgs, cwd: this.config.cwd || paths.dirname(this.config.cmd), args: this.config.args, io: this.config.io || 'stdio', @@ -189,55 +192,86 @@ exports.getModule = class AbracadabraModule extends MenuModule { env: this.config.env, }; + exeInfo.dropFileDir = DropFile.dropFileDirectory( + Config().paths.dropFiles, + this.client + ); + exeInfo.userAreaDir = paths.join( + exeInfo.dropFileDir, + this.client.user.getSanitizedName(), + this.config.name.toLowerCase() + ); + if (this.dropFile) { exeInfo.dropFile = this.dropFile.fileName; exeInfo.dropFilePath = this.dropFile.fullPath; } - const doorTracking = trackDoorRunBegin(this.client, this.config.name); - - this.doorInstance.run(exeInfo, err => { + this._makeDropDirs([exeInfo.dropFileDir, exeInfo.userAreaDir], err => { if (err) { - Log.error(`Error running "${this.config.name}": ${err.message}`); + Log.warn( + `Failed creating directory ${exeInfo.dropFilePath}: ${err.message}` + ); } - trackDoorRunEnd(doorTracking); - this.decrementActiveDoorNodeInstances(); + const doorTracking = trackDoorRunBegin(this.client, this.config.name); - // Clean up dropfile, if any - if (exeInfo.dropFilePath) { - fs.unlink(exeInfo.dropFilePath, err => { - if (err) { - Log.warn( - { error: err, path: exeInfo.dropFilePath }, - 'Failed to remove drop file.' - ); - } - }); - } + this.doorInstance.run(exeInfo, err => { + if (err) { + Log.error(`Error running "${this.config.name}": ${err.message}`); + } - // client may have disconnected while process was active - - // we're done here if so. - if (!this.client.term.output) { - return; - } + trackDoorRunEnd(doorTracking); + this.decrementActiveDoorNodeInstances(); - // - // Try to clean up various settings such as scroll regions that may - // have been set within the door - // - this.client.term.rawWrite( - ansi.normal() + - ansi.goto(this.client.term.termHeight, this.client.term.termWidth) + - ansi.setScrollRegion() + - ansi.goto(this.client.term.termHeight, 0) + - '\r\n\r\n' - ); + // Clean up dropfile, if any + if (exeInfo.dropFilePath) { + fs.unlink(exeInfo.dropFilePath, err => { + if (err) { + Log.warn( + { error: err, path: exeInfo.dropFilePath }, + 'Failed to remove drop file.' + ); + } + }); + } - this.autoNextMenu(); + // client may have disconnected while process was active - + // we're done here if so. + if (!this.client.term.output) { + return; + } + + // + // Try to clean up various settings such as scroll regions that may + // have been set within the door + // + this.client.term.rawWrite( + ansi.normal() + + ansi.goto( + this.client.term.termHeight, + this.client.term.termWidth + ) + + ansi.setScrollRegion() + + ansi.goto(this.client.term.termHeight, 0) + + '\r\n\r\n' + ); + + this.autoNextMenu(); + }); }); } + _makeDropDirs(dirs, cb) { + async.forEach( + dirs, + (dir, nextDir) => { + fs.mkdir(dir, { recursive: true }, nextDir); + }, + cb + ); + } + leave() { super.leave(); this.decrementActiveDoorNodeInstances(); diff --git a/core/door.js b/core/door.js index 800292fc..313adf74 100644 --- a/core/door.js +++ b/core/door.js @@ -11,6 +11,7 @@ const decode = require('iconv-lite').decode; const createServer = require('net').createServer; const paths = require('path'); const _ = require('lodash'); +const async = require('async'); module.exports = class Door { constructor(client) { @@ -67,91 +68,139 @@ module.exports = class Door { const formatObj = { dropFile: exeInfo.dropFile, dropFilePath: exeInfo.dropFilePath, + dropFileDir: exeInfo.dropFileDir, + userAreaDir: exeInfo.userAreaDir, node: exeInfo.node.toString(), srvPort: this.sockServer ? this.sockServer.address().port.toString() : '-1', userId: this.client.user.userId.toString(), userName: this.client.user.getSanitizedName(), userNameRaw: this.client.user.username, + termWidth: this.client.term.termWidth, + termHeight: this.client.term.termHeight, cwd: cwd, }; const args = exeInfo.args.map(arg => stringFormat(arg, formatObj)); - this.client.log.info( - { cmd: exeInfo.cmd, args, io: this.io }, - `Executing external door (${exeInfo.name})` - ); + const spawnOptions = { + cols: this.client.term.termWidth, + rows: this.client.term.termHeight, + cwd: cwd, + env: exeInfo.env, + encoding: null, // we want to handle all encoding ourself + }; - try { - this.doorPty = pty.spawn(exeInfo.cmd, args, { - cols: this.client.term.termWidth, - rows: this.client.term.termHeight, - cwd: cwd, - env: exeInfo.env, - encoding: null, // we want to handle all encoding ourself - }); - } catch (e) { - return cb(e); - } + async.series( + [ + callback => { + if (!_.isString(exeInfo.preCmd)) { + return callback(null); + } - // - // PID is launched. Make sure it's killed off if the user disconnects. - // - Events.once(Events.getSystemEvents().ClientDisconnected, evt => { - if ( - this.doorPty && - this.client.session.uniqueId === _.get(evt, 'client.session.uniqueId') - ) { - this.client.log.info( - { pid: this.doorPty.pid }, - 'User has disconnected; Killing door process.' - ); - this.doorPty.kill(); - } - }); + const preCmdArgs = (exeInfo.preCmdArgs || []).map(arg => + stringFormat(arg, formatObj) + ); - this.client.log.debug( - { processId: this.doorPty.pid }, - 'External door process spawned' - ); + this.client.log.info( + { cmd: exeInfo.preCmd, args: preCmdArgs }, + `Executing external door pre-command (${exeInfo.name})` + ); - if ('stdio' === this.io) { - this.client.log.debug('Using stdio for door I/O'); + try { + const prePty = pty.spawn( + exeInfo.preCmd, + preCmdArgs, + spawnOptions + ); - this.client.term.output.pipe(this.doorPty); - - this.doorPty.onData(this.doorDataHandler.bind(this)); - - this.doorPty.once('close', () => { - return this.restoreIo(this.doorPty); - }); - } else if ('socket' === this.io) { - this.client.log.debug( - { - srvPort: this.sockServer.address().port, - srvSocket: this.sockServerSocket, + prePty.once('exit', exitCode => { + this.client.log.info( + { exitCode: exitCode }, + 'Door pre-command exited' + ); + return callback(null); + }); + } catch (e) { + return callback(e); + } }, - 'Using temporary socket server for door I/O' - ); - } + callback => { + this.client.log.info( + { cmd: exeInfo.cmd, args, io: this.io }, + `Executing external door (${exeInfo.name})` + ); - this.doorPty.once('exit', exitCode => { - this.client.log.info({ exitCode: exitCode }, 'Door exited'); + try { + this.doorPty = pty.spawn(exeInfo.cmd, args, spawnOptions); + } catch (e) { + return cb(e); + } - if (this.sockServer) { - this.sockServer.close(); + // + // PID is launched. Make sure it's killed off if the user disconnects. + // + Events.once(Events.getSystemEvents().ClientDisconnected, evt => { + if ( + this.doorPty && + this.client.session.uniqueId === + _.get(evt, 'client.session.uniqueId') + ) { + this.client.log.info( + { pid: this.doorPty.pid }, + 'User has disconnected; Killing door process.' + ); + this.doorPty.kill(); + } + }); + + this.client.log.debug( + { processId: this.doorPty.pid }, + 'External door process spawned' + ); + + if ('stdio' === this.io) { + this.client.log.debug('Using stdio for door I/O'); + + this.client.term.output.pipe(this.doorPty); + + this.doorPty.onData(this.doorDataHandler.bind(this)); + + this.doorPty.once('close', () => { + return this.restoreIo(this.doorPty); + }); + } else if ('socket' === this.io) { + this.client.log.debug( + { + srvPort: this.sockServer.address().port, + srvSocket: this.sockServerSocket, + }, + 'Using temporary socket server for door I/O' + ); + } + + this.doorPty.once('exit', exitCode => { + this.client.log.info({ exitCode: exitCode }, 'Door exited'); + + if (this.sockServer) { + this.sockServer.close(); + } + + // we may not get a close + if ('stdio' === this.io) { + this.restoreIo(this.doorPty); + } + + this.doorPty.removeAllListeners(); + delete this.doorPty; + + return callback(null); + }); + }, + ], + () => { + return cb(null); } - - // we may not get a close - if ('stdio' === this.io) { - this.restoreIo(this.doorPty); - } - - this.doorPty.removeAllListeners(); - delete this.doorPty; - - return cb(null); - }); + ); } doorDataHandler(data) { diff --git a/core/dropfile.js b/core/dropfile.js index a57c6ce3..a9e55e1d 100644 --- a/core/dropfile.js +++ b/core/dropfile.js @@ -34,8 +34,15 @@ module.exports = class DropFile { this.baseDir = baseDir; } + static dropFileDirectory(baseDir, client) { + return paths.join(baseDir, 'node' + client.node); + } + get fullPath() { - return paths.join(this.baseDir, 'node' + this.client.node, this.fileName); + return paths.join( + DropFile.dropFileDirectory(this.baseDir, this.client), + this.fileName + ); } get fileName() { diff --git a/docs/_docs/modding/local-doors.md b/docs/_docs/modding/local-doors.md index 3c2404bd..8c44c375 100644 --- a/docs/_docs/modding/local-doors.md +++ b/docs/_docs/modding/local-doors.md @@ -16,14 +16,16 @@ The `abracadabra` `config` block can contain the following members: | Item | Required | Description | |------|----------|-------------| | `name` | :+1: | Used as a key for tracking number of clients using a particular door. | -| `dropFileType` | :-1: | Specifies the type of dropfile to generate (See **Dropfile Types** below). Can be omitted or set to `none`. | +| `dropFileType` | :-1: | Specifies the type of dropfile to generate (See [Dropfile Types](#dropfile-types) below). Can be omitted or set to `none`. | | `cmd` | :+1: | Path to executable to launch. | -| `args` | :-1: | Array of argument(s) to pass to `cmd`. See **Argument Variables** below for information on variables that can be used here. +| `args` | :-1: | Array of argument(s) to pass to `cmd`. See [Argument Variables](#argument-variables) below for information on variables that can be utilized here. | +| `preCmd` | :-1: | Path to a pre-command executable or script to launch. Executes before `cmd`. | +| `preCmdArgs` | :-1: | Array of argument(s) to pass to `preCmd`. See [Argument Variables](#argument-variables) below for information on variables that can be utilized here. | | `cwd` | :-1: | Sets the Current Working Directory (CWD) for `cmd`. Defaults to the directory of `cmd`. | | `env` | :-1: | Sets the environment. Supplied in the form of an map: `{ SOME_VAR: "value" }` | `nodeMax` | :-1: | Max number of nodes that can access this door at once. Uses `name` as a tracking key. | | `tooManyArt` | :-1: | Art spec to display if too many instances are already in use. | -| `io` | :-1: | How to process input/output (I/O). Can be `stdio` or `socket`. When using `stdio`, I/O is handled via standard stdin/stdout. When using `socket` a temporary socket server is spawned that can be connected back to. The server listens on localhost on `{srvPort}` (See **Argument Variables** below for more information). Default value is `stdio`. | +| `io` | :-1: | How to process input/output (I/O). Can be `stdio` or `socket`. When using `stdio`, I/O is handled via standard stdin/stdout. When using `socket` a temporary socket server is spawned that can be connected back to. The server listens on localhost on `{srvPort}` (See [Argument Variables](#argument-variables) below for more information). Default value is `stdio`. | | `encoding` | :-1: | Sets the **door's** encoding. Defaults to `cp437`. Linux binaries often produce `utf8`. | #### Dropfile Types @@ -31,23 +33,28 @@ Dropfile types specified by `dropFileType`: | Value | Description | |-------|-------------| +| `none` | No door file is needed | | `DOOR` | [DOOR.SYS](https://web.archive.org/web/20160325192739/http://goldfndr.home.mindspring.com/dropfile/doorsys.htm) | `DOOR32` | [DOOR32.SYS](https://raw.githubusercontent.com/NuSkooler/ansi-bbs/master/docs/dropfile_formats/door32_sys.txt) | `DORINFO` | [DORINFOx.DEF](https://web.archive.org/web/20160321190038/http://goldfndr.home.mindspring.com/dropfile/dorinfo.htm) #### Argument Variables -The following variables may be used in `args` entries: +The following variables may be used in `args` and `preCmdArgs` entries: | Variable | Description | Example | |----------|-------------|---------| | `{node}` | Current node number. | `1` | | `{dropFile}` | Dropfile _filename_ only. | `DOOR.SYS` | | `{dropFilePath}` | Full path to generated dropfile. The system places dropfiles in the path set by `paths.dropFiles` in `config.hjson`. | `C:\enigma-bbs\drop\node1\DOOR.SYS` | +| `{dropFileDir}` | Full path to **directory** containing the generated dropfile. | `/home/enigma-bbs/drop/node1/` | +| `{userAreaDir}` | Full path to a **directory** safe for user-specific save files/etc. | `/home/enigma-bbs/drop/node1/NuSkooler/lord/` | | `{userId}` | Current user ID. | `420` | | `{userName}` | [Sanitized](https://www.npmjs.com/package/sanitize-filename) username. Safe for filenames, etc. If the full username is sanitized away, this will resolve to something like "user_1234". | `izard` | | `{userNameRaw}` | _Raw_ username. May not be safe for filenames! | `\/\/izard` | | `{srvPort}` | Temporary server port when `io` is set to `socket`. | `1234` | | `{cwd}` | Current Working Directory. | `/home/enigma-bbs/doors/foo/` | +| `{termHeight}` | Current client term height | `25` | +| `{termWidth}` | Current client term width | `80` | Example `args` member using some variables described above: ```hjson