├── .eslintrc.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── documentation └── forum_bridge.md ├── index.js ├── lib ├── commands │ ├── article.js │ ├── authinfo_pass.js │ ├── authinfo_user.js │ ├── body.js │ ├── capabilities.js │ ├── date.js │ ├── group.js │ ├── hdr.js │ ├── head.js │ ├── help.js │ ├── list.js │ ├── list_active.js │ ├── list_newsgroups.js │ ├── list_overview_fmt.js │ ├── listgroup.js │ ├── mode.js │ ├── mode_reader.js │ ├── newgroups.js │ ├── newnews.js │ ├── over.js │ ├── quit.js │ ├── stat.js │ ├── xhdr.js │ └── xover.js ├── flatten-stream.js ├── session.js ├── status.js └── wildmat.js ├── package-lock.json ├── package.json └── test ├── .eslintrc.yml ├── commands.js ├── fixtures ├── db.yml ├── server-cert.pem └── server-key.pem ├── flatten-stream.js ├── helpers ├── asline.js ├── index.js └── mock_db.js ├── pipeline.js ├── security.js └── wildmat.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | browser: false 4 | es6: true 5 | 6 | parserOptions: 7 | ecmaVersion: '2017' 8 | 9 | rules: 10 | accessor-pairs: 2 11 | array-bracket-spacing: [ 2, "always", { "singleValue": true, "objectsInArrays": true, "arraysInArrays": true } ] 12 | block-scoped-var: 2 13 | block-spacing: 2 14 | brace-style: [ 2, '1tbs', { "allowSingleLine": true } ] 15 | # Postponed 16 | #callback-return: 2 17 | comma-dangle: 2 18 | comma-spacing: 2 19 | comma-style: 2 20 | computed-property-spacing: [ 2, never ] 21 | # Postponed 22 | #consistent-return: 2 23 | consistent-this: [ 2, self ] 24 | # ? change to multi 25 | curly: [ 2, 'multi-line' ] 26 | # Postponed 27 | # dot-notation: [ 2, { allowKeywords: true } ] 28 | dot-location: [ 2, 'property' ] 29 | eol-last: 2 30 | eqeqeq: 2 31 | func-style: [ 2, declaration ] 32 | # Postponed 33 | #global-require: 2 34 | guard-for-in: 2 35 | handle-callback-err: 2 36 | 37 | # Postponed 38 | indent: [ 2, 2, { VariableDeclarator: { var: 2, let: 2, const: 3 }, SwitchCase: 1 } ] 39 | 40 | # key-spacing: [ 2, { "align": "value" } ] 41 | keyword-spacing: 2 42 | linebreak-style: 2 43 | max-depth: [ 1, 3 ] 44 | max-nested-callbacks: [ 1, 5 ] 45 | # string can exceed 80 chars, but should not overflow github website :) 46 | max-len: [ 2, 120, 1000 ] 47 | new-cap: 2 48 | new-parens: 2 49 | # Postponed 50 | #newline-after-var: 2 51 | no-alert: 2 52 | no-array-constructor: 2 53 | no-bitwise: 2 54 | no-caller: 2 55 | #no-case-declarations: 2 56 | no-catch-shadow: 2 57 | no-cond-assign: 2 58 | no-console: 1 59 | no-constant-condition: 2 60 | no-control-regex: 2 61 | no-debugger: 1 62 | no-delete-var: 2 63 | no-div-regex: 2 64 | no-dupe-args: 2 65 | no-dupe-keys: 2 66 | no-duplicate-case: 2 67 | no-else-return: 2 68 | # Tend to drop 69 | # no-empty: 1 70 | no-empty-character-class: 2 71 | no-empty-pattern: 2 72 | no-eq-null: 2 73 | no-eval: 2 74 | no-ex-assign: 2 75 | no-extend-native: 2 76 | no-extra-bind: 2 77 | no-extra-boolean-cast: 2 78 | no-extra-semi: 2 79 | no-fallthrough: 2 80 | no-floating-decimal: 2 81 | no-func-assign: 2 82 | # Postponed 83 | #no-implicit-coercion: [2, { "boolean": true, "number": true, "string": true } ] 84 | no-implied-eval: 2 85 | no-inner-declarations: 2 86 | no-invalid-regexp: 2 87 | no-irregular-whitespace: 2 88 | no-iterator: 2 89 | no-label-var: 2 90 | no-labels: 2 91 | no-lone-blocks: 1 92 | no-lonely-if: 2 93 | no-loop-func: 2 94 | no-mixed-requires: [ 1, { "grouping": true } ] 95 | no-mixed-spaces-and-tabs: 2 96 | # Postponed 97 | #no-native-reassign: 2 98 | no-negated-in-lhs: 2 99 | # Postponed 100 | #no-nested-ternary: 2 101 | no-new: 2 102 | no-new-func: 2 103 | no-new-object: 2 104 | no-new-require: 2 105 | no-new-wrappers: 2 106 | no-obj-calls: 2 107 | no-octal: 2 108 | no-octal-escape: 2 109 | no-path-concat: 2 110 | no-proto: 2 111 | no-redeclare: 2 112 | # Postponed 113 | #no-regex-spaces: 2 114 | no-return-assign: 2 115 | no-self-compare: 2 116 | no-sequences: 2 117 | # Postponed 118 | #no-shadow: 2 119 | no-shadow-restricted-names: 2 120 | no-sparse-arrays: 2 121 | # Postponed 122 | #no-sync: 2 123 | no-trailing-spaces: 2 124 | no-undef: 2 125 | no-undef-init: 2 126 | no-undefined: 2 127 | no-unexpected-multiline: 2 128 | no-unreachable: 2 129 | no-unused-expressions: 2 130 | no-unused-vars: 2 131 | no-use-before-define: 2 132 | no-void: 2 133 | no-with: 2 134 | object-curly-spacing: [ 2, always, { "objectsInObjects": true, "arraysInObjects": true } ] 135 | operator-assignment: 1 136 | # Postponed 137 | #operator-linebreak: [ 2, after ] 138 | semi: 2 139 | semi-spacing: 2 140 | space-before-function-paren: [ 2, { "anonymous": "always", "named": "never" } ] 141 | space-in-parens: [ 2, never ] 142 | space-infix-ops: 2 143 | space-unary-ops: 2 144 | # Postponed 145 | #spaced-comment: [ 1, always, { exceptions: [ '/', '=' ] } ] 146 | strict: [ 2, global ] 147 | quotes: [ 2, single, avoid-escape ] 148 | quote-props: [ 1, 'as-needed', { "keywords": true } ] 149 | radix: 2 150 | use-isnan: 2 151 | valid-typeof: 2 152 | yoda: [ 2, never, { "exceptRange": true } ] 153 | 154 | # 155 | # es6 156 | # 157 | arrow-body-style: [ 1, "as-needed" ] 158 | arrow-parens: [ 1, "as-needed" ] 159 | arrow-spacing: 2 160 | constructor-super: 2 161 | generator-star-spacing: [ 2, {"before": false, "after": true } ] 162 | no-class-assign: 2 163 | no-confusing-arrow: [ 1, { allowParens: true } ] 164 | no-const-assign: 2 165 | #no-constant-condition: 2 166 | no-dupe-class-members: 2 167 | no-this-before-super: 2 168 | # Postponed 169 | #no-var: 2 170 | object-shorthand: 1 171 | # Postponed 172 | #prefer-arrow-callback: 1 173 | # Postponed 174 | #prefer-const: 1 175 | #prefer-reflect 176 | #prefer-spread 177 | # Postponed 178 | #prefer-template: 1 179 | require-yield: 1 180 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 3' 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | 17 | - run: npm install 18 | 19 | - name: Test 20 | run: | 21 | npm test 22 | npm run covreport 23 | 24 | - name: Upload coverage report to coveralls.io 25 | uses: coverallsapp/github-action@v1.1.2 26 | with: 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 3.1.0 / 2021-11-29 2 | ------------------ 3 | 4 | - Switch to ES6 classes. 5 | - Use strict methods in asserts. 6 | - Deps bump. 7 | 8 | 9 | 3.0.0 / 2021-05-25 10 | ------------------ 11 | 12 | - node.js v14+ required. 13 | - deps bump. 14 | - remove lodash dependency. 15 | 16 | 17 | 2.0.0 / 2020-10-05 18 | ------------------ 19 | 20 | - node.js v10+ required. 21 | - Switch to native stream methods. 22 | - Code cleanup. 23 | 24 | 25 | 1.0.3 / 2018-09-11 26 | ------------------ 27 | 28 | - Maintenance: deps bump & tests fix. 29 | 30 | 31 | 1.0.2 / 2017-08-09 32 | ------------------ 33 | 34 | - Maintenence: bump `debug` version. 35 | 36 | 37 | 1.0.1 / 2017-06-08 38 | ------------------ 39 | 40 | - Add missed dependency. 41 | 42 | 43 | 1.0.0 / 2017-05-15 44 | ------------------ 45 | 46 | - First release. 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Vitaly Puzrin. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nntp-server 2 | =========== 3 | 4 | [![CI](https://github.com/nodeca/nntp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/nodeca/nntp-server/actions/workflows/ci.yml) 5 | [![NPM version](https://img.shields.io/npm/v/nntp-server.svg?style=flat)](https://www.npmjs.org/package/nntp-server) 6 | [![Coverage Status](https://coveralls.io/repos/github/nodeca/nntp-server/badge.svg?branch=master)](https://coveralls.io/github/nodeca/nntp-server?branch=master) 7 | 8 | > NNTP server for readers. 9 | 10 | Demo: [news://dev.nodeca.com](news://dev.nodeca.com) 11 | 12 | This project is intended to build NNTP interface for internet forums and 13 | similars. 14 | 15 | - It implements all commands, required by popular 16 | [usenet newsreaders](https://en.wikipedia.org/wiki/List_of_Usenet_newsreaders). 17 | - It implements commands pipelining to reduce responses latency. 18 | - You should only add database access methods and output templates. 19 | 20 | 21 | Install 22 | ------- 23 | 24 | ```sh 25 | npm install nntp-server --save 26 | ``` 27 | 28 | 29 | API 30 | --- 31 | 32 | Until better docs/examples provided, we sugget 33 | 34 | 1. Dig [nntp-server source](https://github.com/nodeca/nntp-server/blob/master/index.js). 35 | You should override all `._*` methods (via monkeypatching or subclassing). All data in/out described in each method header. 36 | 2. See [tests](https://github.com/nodeca/nntp-server/tree/master/test) 37 | for more examples. 38 | 39 | ### new nntp-server(address, options) 40 | 41 | ```js 42 | const Server = require('nntp-server'); 43 | const nntp = new Server('nntp://localhost', { requireAuth: true }); 44 | ``` 45 | 46 | Address has "standard" format to define everything in one string: 47 | `nttp(s)://hostname:port/?option1=value1&option2=value2`. For example: 48 | 49 | - `nntp://example.com` - listen on 119 port 50 | - `nntps://example.com` - listen on 563 port, encrypted 51 | 52 | options: 53 | 54 | - `key` - tls secret key, optional. 55 | - `cert` - tls cert, optional. 56 | - `pfx` - tls key+cert together, optional. 57 | - `requireAuth` (false) - set `true` if user should be authenticated. 58 | - `secure` - "false" for `nntp://`, "true" for `nntps://`. Set `true` 59 | if you use `nntp://` with external SSL proxy and connection is secure. 60 | If connection is not secure client will be requested to upgrade via 61 | STARTTLS after AUTHINFO command. 62 | - `session` - override default `Session` class if needed, optional. 63 | - `commands` - your own configuration of supported commands, optional. 64 | For example you may wish to drop AUTHINFO commands, been enabled by default. 65 | 66 | 67 | ### .listen(address) -> Promise 68 | 69 | Bind server to given addres, format is `protocol://host[:port]`: 70 | 71 | - 'nntps://localhost' - bind to 127.0.0.1:563 via SSL/TLS 72 | - 'nntp://localhost' - bind to 127.0.0.1:119 without encryption. 73 | 74 | Returns Promise, resolved on success or fail. 75 | 76 | 77 | ### .close() -> Promise 78 | 79 | Stop accepting new connections and wait until existing ones will be finished. 80 | 81 | 82 | Bind server to given addres, format is `protocol://host[:port]`: 83 | 84 | - 'nntps://localhost' - bind to 127.0.0.1:563 via SSL/TLS 85 | - 'nntp://localhost' - bind to 127.0.0.1:119 without encryption. 86 | 87 | Returns Promise, resolved on success or fail. 88 | 89 | 90 | ### exports.commands 91 | 92 | ```js 93 | { 94 | 'LIST OVERVIEW.FMT': ..., 95 | 'XOVER': ..., 96 | ... 97 | } 98 | ``` 99 | 100 | Object with command configurations. Use it to create your own and apply 101 | modifications. Don't forget to clone objects prior to modify. Keep default 102 | ones immutable. 103 | 104 | 105 | ### exports.Session 106 | 107 | This class contains client connection logic. Probably, you will not 108 | need to extend it. 109 | -------------------------------------------------------------------------------- /documentation/forum_bridge.md: -------------------------------------------------------------------------------- 1 | Forum NNTP bridge design 2 | ======================== 3 | 4 | Here are some notes how to create NNTP gate to read forum 5 | via newsreaders. 6 | 7 | 8 | General 9 | ------- 10 | 11 | NNTP messages have autoincremental enumeration inside each group. 12 | With high probability you will need to create separate message index, 13 | and keep it in sync with source (forum) data. 14 | 15 | Additional edges you should know: 16 | 17 | - If you use authentication, it can be requested by client very often 18 | (on every new connection). Caching is a good idea. 19 | - It's a good idea to cache access check to forum sections. 20 | - Try to acheive minimal latency for article fetch. NNTP clients 21 | fetch headers with blocks, but articles only one-by-one. Though 22 | pipelining should compensate this if supported by client. 23 | - No need to implement LAST and NEXT commands. Those are not 24 | used by clients. 25 | - All clients still use XOVER/XHDR instead of OVER/HDR. 26 | - None of known clients support STARTTLS (rfc4642). If you need 27 | authentication, you should use SSL at 563 port for security. 28 | - None of known clients support compression (rfc8054). 29 | 30 | Other things to know: 31 | 32 | - Usually, your NNTP index can have only minimal mapping info, and 33 | you may merge the rest of data from source. 34 | - It's a good idea to restrict index depth by time. 35 | - Messages are threaded. You can simplify things and make all messages 36 | refer to first one in thread. 37 | 38 | 39 | Data & DB 40 | --------- 41 | 42 | First, read 43 | [source comments](https://github.com/nodeca/nntp-server/blob/master/index.js). 44 | Each method to override has decription of data format. 45 | 46 | Examples below are from Nodeca. Note: 47 | 48 | - We store only minimal possible set of data, and read the rest from 49 | original source. 50 | - We do NOT support full index consistency. Only add/delete, no restore. That's 51 | enougth for real world and simplifies permissions check significantly. 52 | 53 | You may have another approach. For example - no permissions, full consistency 54 | via SQL triggers et al. 55 | 56 | 57 | ### Groups collection 58 | 59 | ```js 60 | const Schema = require('mongoose').Schema; 61 | 62 | const Group = new Schema({ 63 | name: String, 64 | source: Schema.ObjectId, 65 | // content type (usually, 'forum') 66 | type: String, 67 | // min visible post index (default max_index+1 means that group is empty) 68 | min_index: { type: Number, 'default': 1 }, 69 | // max visible post index 70 | max_index: { type: Number, 'default': 0 }, 71 | // Message counter. We can't use min/max directly, 72 | // because last message can be deleted. 73 | last_index: { type: Number, 'default': 0 } 74 | }, { 75 | versionKey: false 76 | }); 77 | 78 | // to find a group by name (GROUP command) 79 | Group.index({ name: 1 }); 80 | 81 | // to find a group for a forum section 82 | Group.index({ source: 1 }); 83 | ``` 84 | 85 | 86 | ### Articles collection 87 | 88 | Source postings <=> NNTP messages mapping. 89 | 90 | ```js 91 | const Schema = require('mongoose').Schema; 92 | 93 | const Article = new Schema({ 94 | source: Schema.ObjectId, 95 | group: Schema.ObjectId, 96 | index: Number 97 | }, { 98 | versionKey: false 99 | }); 100 | 101 | // to find an article by message_id (ARTICLE command) 102 | Article.index({ source: 1 }); 103 | 104 | // to get range of articles inside a group 105 | Article.index({ group: 1, index: 1 }); 106 | ``` 107 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // NNTP server template. Configure commands & methods for your needs 2 | // 3 | 'use strict'; 4 | 5 | 6 | const net = require('net'); 7 | const tls = require('tls'); 8 | const url = require('url'); 9 | const Session = require('./lib/session'); 10 | 11 | 12 | const commands = [ 13 | require('./lib/commands/article'), 14 | require('./lib/commands/authinfo_pass'), 15 | require('./lib/commands/authinfo_user'), 16 | require('./lib/commands/body'), 17 | require('./lib/commands/capabilities'), 18 | require('./lib/commands/date'), 19 | require('./lib/commands/group'), 20 | require('./lib/commands/hdr'), 21 | require('./lib/commands/head'), 22 | require('./lib/commands/help'), 23 | require('./lib/commands/list'), 24 | require('./lib/commands/list_active'), 25 | require('./lib/commands/list_newsgroups'), 26 | require('./lib/commands/list_overview_fmt'), 27 | require('./lib/commands/listgroup'), 28 | require('./lib/commands/mode'), 29 | require('./lib/commands/mode_reader'), 30 | require('./lib/commands/newgroups'), 31 | require('./lib/commands/newnews'), 32 | require('./lib/commands/over'), 33 | require('./lib/commands/quit'), 34 | require('./lib/commands/stat'), 35 | require('./lib/commands/xhdr'), 36 | require('./lib/commands/xover') 37 | ].reduce((obj, cmd) => { 38 | obj[cmd.head] = cmd; return obj; 39 | }, {}); 40 | 41 | 42 | const DEFAULT_OPTIONS = { 43 | // Use `false` if you don't need authentication 44 | requireAuth: false, 45 | // Is connection secure by default? Set `true` if you use external SSL 46 | // proxy or use built-in NTTPS. Default server on 119 port is not secure, 47 | // and AUTHINFO will require to upgrade connection via STARTSSL. 48 | secure: false, 49 | // TLS/SSL options (see node.js TLS documentation). 50 | tls: null, 51 | session: Session, 52 | commands 53 | }; 54 | 55 | 56 | function Nntp(options) { 57 | this.options = Object.assign({}, DEFAULT_OPTIONS, options || {}); 58 | 59 | this.commands = this.options.commands; 60 | } 61 | 62 | 63 | Nntp.prototype.listen = function (address) { 64 | let host, port; 65 | let parsed = url.parse(address); 66 | 67 | let listener = connection => { 68 | this.options.session.create(this, connection); 69 | }; 70 | 71 | host = parsed.hostname || 'localhost'; 72 | 73 | if (parsed.protocol === 'nntps:') { 74 | this.server = tls.createServer(this.options.tls, listener); 75 | 76 | port = parsed.port !== null ? parsed.port : 563; 77 | } else { 78 | this.server = net.createServer(listener); 79 | 80 | port = parsed.port !== null ? parsed.port : 119; 81 | } 82 | 83 | return new Promise((resolve, reject) => { 84 | let on_listening, on_error; 85 | 86 | on_listening = () => { 87 | this.server.removeListener('listening', on_listening); 88 | this.server.removeListener('error', on_error); 89 | resolve(); 90 | }; 91 | 92 | on_error = err => { 93 | this.server.removeListener('listening', on_listening); 94 | this.server.removeListener('error', on_error); 95 | reject(err); 96 | }; 97 | 98 | this.server.on('listening', on_listening); 99 | this.server.on('error', on_error); 100 | this.server.listen(port, host); 101 | }); 102 | }; 103 | 104 | 105 | Nntp.prototype.close = function () { 106 | return new Promise(resolve => this.server.close(resolve)); 107 | }; 108 | 109 | //////////////////////////////////////////////////////////////////////////////// 110 | 111 | // 112 | // Override methods below for your needs 113 | // 114 | 115 | 116 | /** 117 | * Return "true" if client should be authenticated prior to running a specific 118 | * command. By default a minimal set of commands is whitelisted. 119 | */ 120 | Nntp.prototype._needAuth = function (session, command) { 121 | if (!this.options.requireAuth || 122 | session.authenticated || 123 | /^(MODE|AUTHINFO|STARTTLS|CAPABILITIES|DATE)\b/i.test(command)) { 124 | return false; 125 | } 126 | 127 | return true; 128 | }; 129 | 130 | 131 | /* 132 | * Authenticate user based on credentials stored in `session.authinfo_user` 133 | * and `session.authinfo_pass`. 134 | * 135 | * Should return `true` on success, and `false` on failure. 136 | */ 137 | Nntp.prototype._authenticate = function (/*session*/) { 138 | return Promise.resolve(false); 139 | }; 140 | 141 | 142 | /* 143 | * Return message object or `null`. 144 | * 145 | * - message_id: number-like string or '' 146 | */ 147 | Nntp.prototype._getArticle = function (/*session, message_id*/) { 148 | throw new Error('method `nntp._getArticle` is not implemented'); 149 | }; 150 | 151 | 152 | /* 153 | * Get list of message numbers current group in given interval. 154 | * 155 | * Returns an array/stream of articles that could be used later 156 | * with `build*` methods. 157 | */ 158 | Nntp.prototype._getRange = function (/*session, first, last, options*/) { 159 | throw new Error('method `nntp._getRange` is not implemented'); 160 | }; 161 | 162 | 163 | /* 164 | * Try to select group by name. Returns `true` on success 165 | * and fill `session.group` data with: 166 | * 167 | * - min_index (Number) - low water mark 168 | * - max_index (Number) - high water mark 169 | * - total (Number) - an amount of messages in the group 170 | * - name (Number) - group name, e.g. 'misc.test' 171 | * - description (String) - group description (optional) 172 | * - current_article (Number) - usually equals to min_index, can be modified 173 | * by the server later, 0 means invalid 174 | */ 175 | Nntp.prototype._selectGroup = function (/*session, name*/) { 176 | throw new Error('method `nntp._selectGroup` is not implemented'); 177 | }; 178 | 179 | 180 | /* 181 | * Get visible groups list 182 | * 183 | * - time (optional) - minimal last update time 184 | * - wildmat (optional) - name filter RegExp 185 | * 186 | * Returns an array/stream of articles that could be used later 187 | * with `build*` methods. 188 | */ 189 | Nntp.prototype._getGroups = function (/*session, time, wildmat*/) { 190 | throw new Error('method `nntp._getGroups` is not implemented'); 191 | }; 192 | 193 | 194 | /* 195 | * Generate message headers 196 | */ 197 | Nntp.prototype._buildHead = function (/*session, message*/) { 198 | throw new Error('method `nntp._buildHead` is not implemented'); 199 | }; 200 | 201 | 202 | /* 203 | * Generate message body 204 | */ 205 | Nntp.prototype._buildBody = function (/*session, message*/) { 206 | throw new Error('method `nntp._buildBody` is not implemented'); 207 | }; 208 | 209 | 210 | /* 211 | * Generate header content 212 | * 213 | * NNTP server user may request any field using HDR command, 214 | * and in addition to that the following fields are used internally by 215 | * nntp-server: 216 | * 217 | * - subject 218 | * - from 219 | * - date 220 | * - message-id 221 | * - references 222 | * - :bytes 223 | * - :lines 224 | * - xref 225 | */ 226 | Nntp.prototype._buildHeaderField = function (/*session, message, field*/) { 227 | throw new Error('method `nntp._buildHeaderField` is not implemented'); 228 | }; 229 | 230 | 231 | /* 232 | * Get fields for OVER and LIST OVERVIEW.FMT commands. 233 | * 234 | * First 7 fields (up to :lines) are mandatory and should not be changed, 235 | * you can remove Xref or add any field supported by buildHeaderField after 236 | * that. 237 | * 238 | * Format matches LIST OVERVIEW.FMT, ':full' means header includes header 239 | * name itself (which is mandatory for custom fields). 240 | */ 241 | Nntp.prototype._getOverviewFmt = function (/*session*/) { 242 | return [ 243 | 'Subject:', 244 | 'From:', 245 | 'Date:', 246 | 'Message-ID:', 247 | 'References:', 248 | ':bytes', 249 | ':lines', 250 | 'Xref:full' 251 | ]; 252 | }; 253 | 254 | 255 | /* 256 | * Get list of messages newer than specified timestamp 257 | * in NNTP groups selected by a wildcard. 258 | * 259 | * - time - minimal last update time 260 | * - wildmat - name filter RegExp 261 | * 262 | * Returns an array/stream of articles that could be used later 263 | * with `build*` methods. 264 | */ 265 | Nntp.prototype._getNewNews = function (/*session, time, wildmat*/) { 266 | throw new Error('method `nntp._getNewNews` is not implemented'); 267 | }; 268 | 269 | 270 | /* 271 | * Called when an internal error is occured in any of the commands 272 | */ 273 | Nntp.prototype._onError = function (/*error*/) { 274 | }; 275 | 276 | 277 | module.exports = Nntp; 278 | module.exports.commands = commands; 279 | module.exports.Session = Session; 280 | -------------------------------------------------------------------------------- /lib/commands/article.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-6.2.1 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^ARTICLE( (\d{1,15}|<[^\s<>]+>))?$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'ARTICLE', 14 | validate: CMD_RE, 15 | pipeline: true, 16 | 17 | run(session, cmd) { 18 | let match = cmd.match(CMD_RE); 19 | let id; 20 | 21 | if (!match[1]) { 22 | let cursor = session.group.current_article; 23 | 24 | if (cursor <= 0) return status._420_ARTICLE_NOT_SLCTD; 25 | 26 | id = cursor.toString(); 27 | } else { 28 | id = match[2]; 29 | } 30 | 31 | let by_identifier = id[0] === '<'; 32 | 33 | if (!by_identifier && !session.group.name) { 34 | return status._412_GRP_NOT_SLCTD; 35 | } 36 | 37 | return session.server._getArticle(session, id) 38 | .then(msg => { 39 | if (!msg) { 40 | if (by_identifier) return status._430_NO_ARTICLE_BY_ID; 41 | return status._423_NO_ARTICLE_BY_NUM; 42 | } 43 | 44 | if (!by_identifier) session.group.current_article = msg.index; 45 | 46 | let msg_id = session.server._buildHeaderField(session, msg, 'message-id'); 47 | let msg_index = by_identifier ? 0 : id; 48 | 49 | return [ 50 | `${status._220_ARTICLE_FOLLOWS} ${msg_index} ${msg_id}`, 51 | session.server._buildHead(session, msg), 52 | '', 53 | session.server._buildBody(session, msg), 54 | '.' 55 | ]; 56 | }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /lib/commands/authinfo_pass.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc4643 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^AUTHINFO PASS (.+)$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'AUTHINFO PASS', 14 | validate: CMD_RE, 15 | 16 | run(session, cmd) { 17 | if (session.authenticated) return status._502_CMD_UNAVAILABLE; 18 | 19 | if (!session.authinfo_user) return status._482_AUTH_OUT_OF_SEQ; 20 | 21 | session.authinfo_pass = cmd.match(CMD_RE)[1]; 22 | 23 | return session.server._authenticate(session) 24 | .then(success => { 25 | if (!success) { 26 | session.authinfo_user = null; 27 | session.authinfo_pass = null; 28 | return status._481_AUTH_REJECTED; 29 | } 30 | 31 | session.authenticated = true; 32 | return status._281_AUTH_ACCEPTED; 33 | }); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /lib/commands/authinfo_user.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc4643 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^AUTHINFO USER (.+)$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'AUTHINFO USER', 14 | validate: CMD_RE, 15 | 16 | run(session, cmd) { 17 | if (session.authenticated) return status._502_CMD_UNAVAILABLE; 18 | 19 | if (!session.server.options.secure && 20 | !session.secure) { 21 | return status._483_NOT_SECURE; 22 | } 23 | 24 | session.authinfo_user = cmd.match(CMD_RE)[1]; 25 | 26 | return status._381_AUTH_NEED_PASS; 27 | }, 28 | 29 | capability(session, report) { 30 | if (!session.authenticated && 31 | (session.server.options.secure || session.secure)) { 32 | report.push([ 'AUTHINFO', 'USER' ]); 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /lib/commands/body.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-6.2.3 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^BODY( (\d{1,15}|<[^\s<>]+>))?$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'BODY', 14 | validate: CMD_RE, 15 | pipeline: true, 16 | 17 | run(session, cmd) { 18 | let match = cmd.match(CMD_RE); 19 | let id; 20 | 21 | if (!match[1]) { 22 | let cursor = session.group.current_article; 23 | 24 | if (cursor <= 0) return status._420_ARTICLE_NOT_SLCTD; 25 | 26 | id = cursor.toString(); 27 | } else { 28 | id = match[2]; 29 | } 30 | 31 | let by_identifier = id[0] === '<'; 32 | 33 | if (!by_identifier && !session.group.name) { 34 | return status._412_GRP_NOT_SLCTD; 35 | } 36 | 37 | return session.server._getArticle(session, id) 38 | .then(msg => { 39 | if (!msg) { 40 | if (by_identifier) return status._430_NO_ARTICLE_BY_ID; 41 | return status._423_NO_ARTICLE_BY_NUM; 42 | } 43 | 44 | if (!by_identifier) session.group.current_article = msg.index; 45 | 46 | let msg_id = session.server._buildHeaderField(session, msg, 'message-id'); 47 | let msg_index = by_identifier ? 0 : id; 48 | 49 | return [ 50 | `${status._222_BODY_FOLLOWS} ${msg_index} ${msg_id}`, 51 | session.server._buildBody(session, msg), 52 | '.' 53 | ]; 54 | }); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /lib/commands/capabilities.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-5.2 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | module.exports = { 10 | head: 'CAPABILITIES', 11 | // Param is not used, but spec requires to support it. 12 | validate: /^CAPABILITIES( ([a-zA-Z\-_0-9]+))?$/i, 13 | 14 | run(session) { 15 | let report = [ 16 | [ 'VERSION', 2 ] 17 | ]; 18 | 19 | // Collect 20 | Object.keys(session.server.options.commands).forEach(name => { 21 | let conf = session.server.options.commands[name]; 22 | 23 | if (!conf.capability) return; 24 | 25 | conf.capability(session, report); 26 | }); 27 | 28 | let uniq = {}; 29 | 30 | // Flatten options for duplicated names 31 | report.forEach(feature => { 32 | if (typeof feature === 'string') { 33 | uniq[feature] = []; 34 | } else { 35 | let name = feature[0]; 36 | if (!uniq[name]) uniq[name] = []; 37 | uniq[name] = uniq[name].concat(feature.slice(1)); 38 | } 39 | }); 40 | 41 | return [ status._101_CAPABILITY_LIST ] 42 | .concat(Object.keys(uniq).map(k => [ k ].concat(uniq[k]).join(' '))) 43 | .concat([ '.' ]); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /lib/commands/date.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.1 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | // 1 -> 01 10 | function pad2(value) { 11 | return value < 10 ? '0' + value : '' + value; 12 | } 13 | 14 | 15 | module.exports = { 16 | head: 'DATE', 17 | validate: /^DATE$/i, 18 | pipeline: true, 19 | 20 | run() { 21 | let now = new Date(); 22 | 23 | return [ 24 | status._111_DATE, 25 | ' ', 26 | now.getUTCFullYear(), 27 | pad2(now.getUTCMonth() + 1), 28 | pad2(now.getUTCDate()), 29 | pad2(now.getUTCHours()), 30 | pad2(now.getUTCMinutes()), 31 | pad2(now.getUTCSeconds()) 32 | ].join(''); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /lib/commands/group.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-6.1.1 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^GROUP ([^\s]+)$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'GROUP', 14 | validate: CMD_RE, 15 | 16 | async run(session, cmd) { 17 | let name = cmd.match(CMD_RE)[1]; 18 | 19 | let ok = await session.server._selectGroup(session, name); 20 | 21 | if (!ok) return status._411_GRP_NOT_FOUND; 22 | 23 | let g = session.group; 24 | 25 | return `${status._211_GRP_SELECTED} ${g.total} ${g.min_index} ${g.max_index} ${name}`; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /lib/commands/hdr.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-8.5 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const { Readable, Transform, pipeline } = require('stream'); 8 | 9 | 10 | const CMD_RE = /^X?HDR ([^\s]+)(?: (?:(\d{1,15})(-(\d{1,15})?)?|(<[^\s<>]+>))?)?$/i; 11 | 12 | module.exports = { 13 | head: 'HDR', 14 | validate: CMD_RE, 15 | 16 | async run(session, cmd) { 17 | let [ , field, first, dash, last, message_id ] = cmd.match(CMD_RE); 18 | 19 | field = field.toLowerCase(); 20 | 21 | let article; 22 | let article_stream; 23 | 24 | if (typeof message_id !== 'undefined') { 25 | article = await session.server._getArticle(session, message_id); 26 | 27 | if (!article) return status._430_NO_ARTICLE_BY_ID; 28 | 29 | } else if (typeof first !== 'undefined') { 30 | first = +first; 31 | 32 | if (!dash) { 33 | last = first; 34 | } else { 35 | last = typeof last === 'undefined' ? session.group.max_index : +last; 36 | } 37 | 38 | if (!session.group.name) return status._412_GRP_NOT_SLCTD; 39 | 40 | article_stream = await session.server._getRange(session, first, last); 41 | 42 | if (Array.isArray(article_stream)) article_stream = Readable.from(article_stream); 43 | 44 | } else { 45 | if (session.group.current_article <= 0) return status._420_ARTICLE_NOT_SLCTD; 46 | 47 | article = await session.server._getArticle(session, String(session.group.current_article)); 48 | 49 | if (!article) return status._420_ARTICLE_NOT_SLCTD; 50 | } 51 | 52 | function transform(msg) { 53 | let index; 54 | 55 | if (typeof message_id !== 'undefined') { 56 | index = '0'; 57 | } else { 58 | index = msg.index.toString(); 59 | } 60 | 61 | let content = (session.server._buildHeaderField(session, msg, field) || ''); 62 | 63 | // unfolding + replacing invalid characters, see RFC 3977 section 8.3.2 64 | content = content.replace(/\r?\n/g, '').replace(/[\0\t\r\n]/g, ' '); 65 | 66 | return index + ' ' + content; 67 | } 68 | 69 | if (article_stream) { 70 | let count = 0; 71 | 72 | let stream = new Transform({ 73 | objectMode: true, 74 | transform(article, encoding, callback) { 75 | if (count === 0) this.push(status._225_HEADERS_FOLLOW); 76 | count++; 77 | this.push(transform(article)); 78 | callback(); 79 | }, 80 | flush(callback) { 81 | if (count === 0) this.push(status._423_NO_ARTICLE_BY_NUM); 82 | else this.push('.'); 83 | callback(); 84 | } 85 | }); 86 | 87 | pipeline(article_stream, stream, () => {}); 88 | 89 | return stream; 90 | } 91 | 92 | return [ 93 | status._225_HEADERS_FOLLOW, 94 | transform(article), 95 | '.' 96 | ]; 97 | }, 98 | 99 | capability(session, report) { 100 | report.push('HDR'); 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /lib/commands/head.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-6.2.2 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^HEAD( (\d{1,15}|<[^\s<>]+>))?$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'HEAD', 14 | validate: CMD_RE, 15 | pipeline: true, 16 | 17 | run(session, cmd) { 18 | let match = cmd.match(CMD_RE); 19 | let id; 20 | 21 | if (!match[1]) { 22 | let cursor = session.group.current_article; 23 | 24 | if (cursor <= 0) return status._420_ARTICLE_NOT_SLCTD; 25 | 26 | id = cursor.toString(); 27 | } else { 28 | id = match[2]; 29 | } 30 | 31 | let by_identifier = id[0] === '<'; 32 | 33 | if (!by_identifier && !session.group.name) { 34 | return status._412_GRP_NOT_SLCTD; 35 | } 36 | 37 | return session.server._getArticle(session, id) 38 | .then(msg => { 39 | if (!msg) { 40 | if (by_identifier) return status._430_NO_ARTICLE_BY_ID; 41 | return status._423_NO_ARTICLE_BY_NUM; 42 | } 43 | 44 | if (!by_identifier) session.group.current_article = msg.index; 45 | 46 | let msg_id = session.server._buildHeaderField(session, msg, 'message-id'); 47 | let msg_index = by_identifier ? 0 : id; 48 | 49 | return [ 50 | `${status._221_HEAD_FOLLOWS} ${msg_index} ${msg_id}`, 51 | session.server._buildHead(session, msg), 52 | '.' 53 | ]; 54 | }); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /lib/commands/help.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.2 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | module.exports = { 10 | head: 'HELP', 11 | validate: /^HELP$/i, 12 | pipeline: true, 13 | 14 | run() { 15 | return [ 16 | status._100_HELP_FOLLOWS, 17 | '.' 18 | ]; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /lib/commands/list.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.1 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const { Readable, Transform, pipeline } = require('stream'); 8 | 9 | const CMD_RE = /^LIST( (.+))?$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'LIST', 14 | validate: CMD_RE, 15 | pipeline: true, 16 | 17 | async run(session, cmd) { 18 | // Reject all params. All extentions are in separate files 19 | // and detected before this one. 20 | if (cmd.match(CMD_RE)[2]) return status._501_SYNTAX_ERROR; 21 | 22 | let groups = await session.server._getGroups(session); 23 | 24 | if (Array.isArray(groups)) groups = Readable.from(groups); 25 | 26 | let stream = new Transform({ 27 | objectMode: true, 28 | transform(group, encoding, callback) { 29 | this.push(`${group.name} ${group.max_index} ${group.min_index} n`); 30 | callback(); 31 | } 32 | }); 33 | 34 | pipeline(groups, stream, () => {}); 35 | 36 | return [ 37 | status._215_INFO_FOLLOWS, 38 | stream, 39 | '.' 40 | ]; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /lib/commands/list_active.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.1 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const wildmat_re = require('../wildmat'); 8 | const { Readable, Transform, pipeline } = require('stream'); 9 | 10 | 11 | const CMD_RE = /^LIST ACTIVE( ([^\s]+))?$/i; 12 | 13 | 14 | module.exports = { 15 | head: 'LIST ACTIVE', 16 | validate: CMD_RE, 17 | pipeline: true, 18 | 19 | async run(session, cmd) { 20 | let wildmat = null; 21 | 22 | if (cmd.match(CMD_RE)[2]) { 23 | try { 24 | wildmat = wildmat_re(cmd.match(CMD_RE)[2]); 25 | } catch (err) { 26 | return `501 ${err.message}`; 27 | } 28 | } 29 | 30 | let groups = await session.server._getGroups(session, 0, wildmat); 31 | 32 | if (Array.isArray(groups)) groups = Readable.from(groups); 33 | 34 | let stream = new Transform({ 35 | objectMode: true, 36 | transform(group, encoding, callback) { 37 | this.push(`${group.name} ${group.max_index} ${group.min_index} n`); 38 | callback(); 39 | } 40 | }); 41 | 42 | pipeline(groups, stream, () => {}); 43 | 44 | return [ 45 | status._215_INFO_FOLLOWS, 46 | stream, 47 | '.' 48 | ]; 49 | }, 50 | 51 | capability(session, report) { 52 | report.push([ 'LIST', 'ACTIVE' ]); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /lib/commands/list_newsgroups.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.6.6 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const wildmat_re = require('../wildmat'); 8 | const { Readable, Transform, pipeline } = require('stream'); 9 | 10 | 11 | const CMD_RE = /^LIST NEWSGROUPS( ([^\s]+))?$/i; 12 | 13 | 14 | module.exports = { 15 | head: 'LIST NEWSGROUPS', 16 | validate: CMD_RE, 17 | pipeline: true, 18 | 19 | async run(session, cmd) { 20 | let wildmat = null; 21 | 22 | if (cmd.match(CMD_RE)[2]) { 23 | try { 24 | wildmat = wildmat_re(cmd.match(CMD_RE)[2]); 25 | } catch (err) { 26 | return `501 ${err.message}`; 27 | } 28 | } 29 | 30 | let groups = await session.server._getGroups(session, 0, wildmat); 31 | 32 | if (Array.isArray(groups)) groups = Readable.from(groups); 33 | 34 | let stream = new Transform({ 35 | objectMode: true, 36 | transform(group, encoding, callback) { 37 | this.push(`${group.name}\t${group.description || ''}`); 38 | callback(); 39 | } 40 | }); 41 | 42 | pipeline(groups, stream, () => {}); 43 | 44 | return [ 45 | status._215_INFO_FOLLOWS, 46 | stream, 47 | '.' 48 | ]; 49 | }, 50 | 51 | capability(session, report) { 52 | report.push([ 'LIST', 'NEWSGROUPS' ]); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /lib/commands/list_overview_fmt.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-8.4 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^LIST OVERVIEW\.FMT$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'LIST OVERVIEW.FMT', 14 | validate: CMD_RE, 15 | pipeline: true, 16 | 17 | run(session/*, cmd*/) { 18 | return [ status._215_INFO_FOLLOWS ] 19 | .concat(session.server._getOverviewFmt(session)) 20 | .concat('.'); 21 | }, 22 | 23 | capability(session, report) { 24 | report.push([ 'LIST', 'OVERVIEW.FMT' ]); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /lib/commands/listgroup.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-6.1.2 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const { Readable, Transform, pipeline } = require('stream'); 8 | 9 | 10 | const CMD_RE = /^LISTGROUP( ([^\s]+)( (\d{1,15})((-)(\d{1,15})?)?)?)?$/i; 11 | 12 | 13 | module.exports = { 14 | head: 'LISTGROUP', 15 | validate: CMD_RE, 16 | 17 | async run(session, cmd) { 18 | // [2] -> name, [4] -> first, [5] -> last 19 | let match = cmd.match(CMD_RE); 20 | 21 | let name = match[2], 22 | first = match[4], 23 | dash = match[6], 24 | last = match[7]; 25 | 26 | if (name) { 27 | // try to select groups 28 | let ok = await session.server._selectGroup(session, match[2]); 29 | 30 | if (!ok) return status._411_GRP_NOT_FOUND; 31 | } else { 32 | // check current group 33 | if (!session.group.name) return status._412_GRP_NOT_SLCTD; 34 | 35 | name = session.group.name; 36 | } 37 | 38 | let g = session.group; 39 | 40 | // 41 | // Now group selected, need to fetch range 42 | // 43 | if (typeof first === 'undefined') { 44 | first = g.min_index; 45 | last = g.max_index; 46 | } else { 47 | first = +first; 48 | if (!dash) { 49 | last = first; 50 | } else { 51 | last = typeof last === 'undefined' ? g.max_index : +last; 52 | } 53 | } 54 | 55 | let articles = await session.server._getRange(session, first, last); 56 | 57 | if (Array.isArray(articles)) articles = Readable.from(articles); 58 | 59 | let stream = new Transform({ 60 | objectMode: true, 61 | transform(article, encoding, callback) { 62 | this.push(article.index.toString()); 63 | callback(); 64 | } 65 | }); 66 | 67 | pipeline(articles, stream, () => {}); 68 | 69 | return [ 70 | `${status._211_GRP_SELECTED} ${g.total} ${g.min_index} ${g.max_index} ${name} list follows`, 71 | stream, 72 | '.' 73 | ]; 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /lib/commands/mode.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-3.4.2 2 | // 3 | // Just a stub to not return errors on "MODE READER" 4 | // 5 | 'use strict'; 6 | 7 | 8 | module.exports = { 9 | head: 'MODE', 10 | validate: /^MODE( [^\s]+)$/i, 11 | 12 | // All supported params are defined in separate files 13 | run() { 14 | return '501 Unknown MODE option'; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /lib/commands/mode_reader.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-3.4.2 2 | // 3 | // Just a stub to not return errors on "MODE READER" 4 | // 5 | 'use strict'; 6 | 7 | 8 | const status = require('../status'); 9 | 10 | 11 | module.exports = { 12 | head: 'MODE READER', 13 | validate: /^MODE READER$/i, 14 | 15 | run() { 16 | return status._201_SRV_READY_RO; 17 | }, 18 | 19 | capability(session, report) { 20 | report.push('READER'); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /lib/commands/newgroups.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.3 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const { Readable, Transform, pipeline } = require('stream'); 8 | 9 | 10 | const CMD_RE = /^NEWGROUPS (\d{6,8})\s(\d{6})(?:\sGMT)?$/i; 11 | 12 | 13 | module.exports = { 14 | head: 'NEWGROUPS', 15 | validate: CMD_RE, 16 | pipeline: true, 17 | 18 | async run(session, cmd) { 19 | let [ , d, t ] = cmd.match(CMD_RE); 20 | 21 | // Backward compatibility, as per RFC 3977 section 7.3.2: 22 | // 76 => 1976, 12 => 2012 23 | if (d.length === 6) { 24 | let current_year = new Date().getUTCFullYear(); 25 | let century = Math.floor(current_year / 100); 26 | 27 | if (Number(d.slice(0, 2)) > current_year % 100) { 28 | d = String(century - 1) + d; 29 | } else { 30 | d = String(century) + d; 31 | } 32 | } 33 | 34 | let [ , year, month, day ] = d.match(/^(....)(..)(..)$/); 35 | let [ , hour, min, sec ] = t.match(/^(..)(..)(..)$/); 36 | 37 | let ts = new Date(Date.UTC(year, month, day, hour, min, sec)); 38 | 39 | let groups = await session.server._getGroups(session, ts); 40 | 41 | if (Array.isArray(groups)) groups = Readable.from(groups); 42 | 43 | let stream = new Transform({ 44 | objectMode: true, 45 | transform(group, encoding, callback) { 46 | this.push(`${group.name} ${group.max_index} ${group.min_index} n`); 47 | callback(); 48 | } 49 | }); 50 | 51 | pipeline(groups, stream, () => {}); 52 | 53 | return [ 54 | status._215_INFO_FOLLOWS, 55 | stream, 56 | '.' 57 | ]; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /lib/commands/newnews.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-7.4 2 | // 3 | // This command was supported by Opera M20 client, which is dead now. 4 | // 5 | 'use strict'; 6 | 7 | 8 | const status = require('../status'); 9 | const wildmat_re = require('../wildmat'); 10 | const { Readable, Transform, pipeline } = require('stream'); 11 | 12 | 13 | const CMD_RE = /^NEWNEWS ([^\s]+)\s(\d{6,8})\s(\d{6})(?:\sGMT)?$/i; 14 | 15 | 16 | module.exports = { 17 | head: 'NEWNEWS', 18 | validate: CMD_RE, 19 | pipeline: true, 20 | 21 | async run(session, cmd) { 22 | let [ , wildmat_str, d, t ] = cmd.match(CMD_RE); 23 | 24 | let wildmat = null; 25 | 26 | try { 27 | wildmat = wildmat_re(wildmat_str); 28 | } catch (err) { 29 | return `501 ${err.message}`; 30 | } 31 | 32 | // Backward compatibility, as per RFC 3977 section 7.3.2: 33 | // 76 => 1976, 12 => 2012 34 | if (d.length === 6) { 35 | let current_year = new Date().getUTCFullYear(); 36 | let century = Math.floor(current_year / 100); 37 | 38 | if (Number(d.slice(0, 2)) > current_year % 100) { 39 | d = String(century - 1) + d; 40 | } else { 41 | d = String(century) + d; 42 | } 43 | } 44 | 45 | let [ , year, month, day ] = d.match(/^(....)(..)(..)$/); 46 | let [ , hour, min, sec ] = t.match(/^(..)(..)(..)$/); 47 | 48 | let ts = new Date(Date.UTC(year, month, day, hour, min, sec)); 49 | 50 | let newnews = await session.server._getNewNews(session, ts, wildmat); 51 | 52 | if (Array.isArray(newnews)) newnews = Readable.from(newnews); 53 | 54 | let stream = new Transform({ 55 | objectMode: true, 56 | transform(article, encoding, callback) { 57 | this.push(session.server._buildHeaderField(session, article, 'message-id')); 58 | callback(); 59 | } 60 | }); 61 | 62 | pipeline(newnews, stream, () => {}); 63 | 64 | return [ 65 | status._230_NEWNEWS_FOLLOW, 66 | stream, 67 | '.' 68 | ]; 69 | }, 70 | 71 | capability(session, report) { 72 | report.push('NEWNEWS'); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /lib/commands/over.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-8.3 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | const { Readable, Transform, pipeline } = require('stream'); 8 | 9 | 10 | const CMD_RE = /^X?OVER(?: (?:(\d{1,15})(-(\d{1,15})?)?|(<[^\s<>]+>))?)?$/i; 11 | 12 | module.exports = { 13 | head: 'OVER', 14 | validate: CMD_RE, 15 | 16 | async run(session, cmd) { 17 | let [ , first, dash, last, message_id ] = cmd.match(CMD_RE); 18 | 19 | let article; 20 | let article_stream; 21 | 22 | if (typeof message_id !== 'undefined') { 23 | article = await session.server._getArticle(session, message_id); 24 | 25 | if (!article) return status._430_NO_ARTICLE_BY_ID; 26 | 27 | } else if (typeof first !== 'undefined') { 28 | first = +first; 29 | 30 | if (!dash) { 31 | last = first; 32 | } else { 33 | last = typeof last === 'undefined' ? session.group.max_index : +last; 34 | } 35 | 36 | if (!session.group.name) return status._412_GRP_NOT_SLCTD; 37 | 38 | article_stream = await session.server._getRange(session, first, last); 39 | 40 | if (Array.isArray(article_stream)) article_stream = Readable.from(article_stream); 41 | 42 | } else { 43 | if (session.group.current_article <= 0) return status._420_ARTICLE_NOT_SLCTD; 44 | 45 | article = await session.server._getArticle(session, String(session.group.current_article)); 46 | 47 | if (!article) return status._420_ARTICLE_NOT_SLCTD; 48 | } 49 | 50 | function transform(msg) { 51 | let result; 52 | 53 | if (typeof message_id !== 'undefined') { 54 | result = '0'; 55 | } else { 56 | result = msg.index.toString(); 57 | } 58 | 59 | let fields = session.server._getOverviewFmt(session); 60 | 61 | for (let spec of fields) { 62 | let [ , field, full ] = spec.match(/^(.+?)(?::(|full))?$/i); 63 | 64 | let content = session.server._buildHeaderField(session, msg, field.toLowerCase()) || ''; 65 | 66 | // unfolding + replacing invalid characters, see RFC 3977 section 8.3.2 67 | content = content.replace(/\r?\n/g, '').replace(/[\0\t\r\n]/g, ' '); 68 | 69 | if (full && content) content = field + ': ' + content; 70 | 71 | result += '\t' + content; 72 | } 73 | 74 | return result; 75 | } 76 | 77 | if (article_stream) { 78 | let count = 0; 79 | 80 | let stream = new Transform({ 81 | objectMode: true, 82 | transform(article, encoding, callback) { 83 | if (count === 0) this.push(status._224_OVERVIEW_INFO); 84 | count++; 85 | this.push(transform(article)); 86 | callback(); 87 | }, 88 | flush(callback) { 89 | if (count === 0) this.push(status._423_NO_ARTICLE_BY_NUM); 90 | else this.push('.'); 91 | callback(); 92 | } 93 | }); 94 | 95 | pipeline(article_stream, stream, () => {}); 96 | 97 | return stream; 98 | } 99 | 100 | return [ 101 | status._224_OVERVIEW_INFO, 102 | transform(article), 103 | '.' 104 | ]; 105 | }, 106 | 107 | capability(session, report) { 108 | report.push('OVER'); 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /lib/commands/quit.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-5.4 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | module.exports = { 10 | head: 'QUIT', 11 | validate: /^QUIT$/i, 12 | pipeline: true, 13 | 14 | run() { 15 | return [ 16 | status._205_QUIT, 17 | null // signal to close connection 18 | ]; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /lib/commands/stat.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-6.2.4 2 | // 3 | 'use strict'; 4 | 5 | 6 | const status = require('../status'); 7 | 8 | 9 | const CMD_RE = /^STAT( (\d{1,15}|<[^\s<>]+>))?$/i; 10 | 11 | 12 | module.exports = { 13 | head: 'STAT', 14 | validate: CMD_RE, 15 | pipeline: true, 16 | 17 | run(session, cmd) { 18 | let match = cmd.match(CMD_RE); 19 | let id; 20 | 21 | if (!match[1]) { 22 | let cursor = session.group.current_article; 23 | 24 | if (cursor <= 0) return status._420_ARTICLE_NOT_SLCTD; 25 | 26 | id = cursor.toString(); 27 | } else { 28 | id = match[2]; 29 | } 30 | 31 | let by_identifier = id[0] === '<'; 32 | 33 | if (!by_identifier && !session.group.name) { 34 | return status._412_GRP_NOT_SLCTD; 35 | } 36 | 37 | return session.server._getArticle(session, id) 38 | .then(msg => { 39 | if (!msg) { 40 | if (by_identifier) return status._430_NO_ARTICLE_BY_ID; 41 | return status._423_NO_ARTICLE_BY_NUM; 42 | } 43 | 44 | if (!by_identifier) session.group.current_article = msg.index; 45 | 46 | let msg_id = session.server._buildHeaderField(session, msg, 'message-id'); 47 | let msg_index = by_identifier ? 0 : id; 48 | 49 | return `${status._223_ARTICLE_EXISTS} ${msg_index} ${msg_id}`; 50 | }); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /lib/commands/xhdr.js: -------------------------------------------------------------------------------- 1 | // Alias for HDR 2 | // 3 | // Actually, ALL clients still use XHDR instead of HDR. 4 | // 5 | 'use strict'; 6 | 7 | const hdr = require('./hdr'); 8 | 9 | module.exports = { 10 | head: 'XHDR', 11 | validate: hdr.validate, 12 | run: hdr.run 13 | }; 14 | -------------------------------------------------------------------------------- /lib/commands/xover.js: -------------------------------------------------------------------------------- 1 | // Alias for OVER 2 | // 3 | // Actually, ALL clients still use XOVER instead of OVER. 4 | // 5 | 'use strict'; 6 | 7 | const over = require('./over'); 8 | 9 | module.exports = { 10 | head: 'XOVER', 11 | validate: over.validate, 12 | run: over.run 13 | }; 14 | -------------------------------------------------------------------------------- /lib/flatten-stream.js: -------------------------------------------------------------------------------- 1 | // Transform stream that concatenates and unfolds all strings in input 2 | // 3 | // Each input element could be either: 4 | // 5 | // - String 6 | // - Stream of strings in object mode (strings only; null and arrays are not allowed) 7 | // - null (ends the stream) 8 | // - Array with any combinations of the above 9 | // 10 | // This stream inserts CRLF after each string/buffer, each array element and 11 | // each chunk in the nested object stream. 12 | // 13 | 'use strict'; 14 | 15 | 16 | const Denque = require('denque'); 17 | const stream = require('stream'); 18 | 19 | const STATE_IDLE = 0; // no data in queue 20 | const STATE_WRITE = 1; // writing a string to the output 21 | const STATE_FLOWING = 2; // piping one of input streams to the output 22 | const STATE_PAUSED = 3; // output stream does not accept more data 23 | 24 | 25 | class FlattenStream extends stream.Duplex { 26 | constructor(options) { 27 | super(Object.assign({}, options, { 28 | writableObjectMode: true, 29 | readableObjectMode: false, 30 | allowHalfOpen: false 31 | })); 32 | 33 | this.queue = new Denque(); 34 | this.state = STATE_IDLE; 35 | this.top_chunk_stream = null; 36 | this.top_chunk_callback = null; 37 | this.top_chunk_read_fn = null; 38 | this.stream_ended = false; 39 | } 40 | 41 | 42 | // Recursive function to add data to internal queue 43 | // 44 | _add_data(data, fn) { 45 | if (Array.isArray(data)) { 46 | // Flatten any arrays, callback is called when last element is processed 47 | data.forEach((el, idx) => this._add_data(el, (idx === data.length - 1 ? fn : null))); 48 | return; 49 | } 50 | 51 | this.queue.push([ data, fn ]); 52 | } 53 | 54 | 55 | _write(data, encoding, callback) { 56 | this._add_data(data, callback); 57 | 58 | if (this.state === STATE_IDLE) this._read(); 59 | } 60 | 61 | 62 | destroy() { 63 | if (this.stream_ended) return; 64 | 65 | this.stream_ended = true; 66 | this.push(null); 67 | 68 | if (this.top_chunk_stream && typeof this.top_chunk_stream.destroy === 'function') { 69 | this.top_chunk_stream.destroy(); 70 | } 71 | 72 | while (!this.queue.isEmpty()) { 73 | let data = this.queue.shift()[0]; 74 | 75 | if (data && typeof data.destroy === 'function') data.destroy(); 76 | } 77 | } 78 | 79 | 80 | _read() { 81 | for (;;) { 82 | if (this.state === STATE_WRITE) { 83 | if (this.top_chunk_callback) { 84 | this.top_chunk_callback(); 85 | } 86 | 87 | this.state = STATE_IDLE; 88 | this.top_chunk_callback = null; 89 | } else if (this.state === STATE_FLOWING) { 90 | this.top_chunk_read_fn(); 91 | return; 92 | } else if (this.state === STATE_PAUSED) { 93 | this.state = STATE_FLOWING; 94 | this.top_chunk_read_fn(); 95 | return; 96 | } 97 | 98 | if (this.queue.isEmpty()) break; 99 | 100 | let [ data, callback ] = this.queue.shift(); 101 | 102 | if (data && typeof data.on === 'function') { 103 | // looks like data is a stream 104 | this.state = STATE_FLOWING; 105 | this.top_chunk_stream = data; 106 | this.top_chunk_callback = callback; 107 | 108 | this.top_chunk_read_fn = () => { 109 | if (this.state !== STATE_FLOWING) return; 110 | 111 | for (;;) { 112 | let chunk = data.read(); 113 | 114 | if (chunk === null) { 115 | // no more data is available yet 116 | break; 117 | } 118 | 119 | if (this.stream_ended) break; 120 | if (!this.push(String(chunk) + '\r\n')) { 121 | this.state = STATE_PAUSED; 122 | break; 123 | } 124 | } 125 | }; 126 | 127 | data.on('readable', this.top_chunk_read_fn); 128 | this.top_chunk_read_fn(); 129 | 130 | stream.finished(data, err => { 131 | data.removeListener('readable', this.top_chunk_read_fn); 132 | 133 | if (err) { 134 | this.destroy(); 135 | return; 136 | } 137 | 138 | if (this.top_chunk_callback) { 139 | this.top_chunk_callback(); 140 | } 141 | 142 | this.state = STATE_IDLE; 143 | this.top_chunk_stream = null; 144 | this.top_chunk_callback = null; 145 | this.top_chunk_read_fn = null; 146 | this._read(); 147 | }); 148 | break; 149 | 150 | } else { 151 | // regular data chunk (null, string, buffer) 152 | this.state = STATE_WRITE; 153 | this.top_chunk_callback = callback; 154 | 155 | if (data === null) { 156 | // signal to end this stream 157 | this.destroy(); 158 | break; 159 | 160 | } else { 161 | /* eslint-disable no-lonely-if */ 162 | // string (or mistakenly pushed numbers and such) 163 | if (!this.push(String(data) + '\r\n')) break; 164 | } 165 | } 166 | } 167 | } 168 | } 169 | 170 | 171 | module.exports = (...args) => new FlattenStream(...args); 172 | -------------------------------------------------------------------------------- /lib/session.js: -------------------------------------------------------------------------------- 1 | // Client session. Contains all info about current connection state. 2 | // 3 | 'use strict'; 4 | 5 | 6 | const crypto = require('crypto'); 7 | const Denque = require('denque'); 8 | const debug_err = require('debug')('nntp-server.error'); 9 | const debug_net = require('debug')('nntp-server.network'); 10 | const serializeError = require('serialize-error').serializeError; 11 | const split2 = require('split2'); 12 | const pipeline = require('stream').pipeline; 13 | const flattenStream = require('./flatten-stream'); 14 | const status = require('./status'); 15 | 16 | const CMD_WAIT = 0; 17 | const CMD_PENDING = 1; 18 | const CMD_RESOLVED = 2; 19 | const CMD_REJECTED = 3; 20 | 21 | // same as lodash.escapeRegExp 22 | function escape_regexp(str) { 23 | return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); 24 | } 25 | 26 | 27 | function Command(fn, cmd_line) { 28 | this.state = CMD_WAIT; 29 | this.fn = fn; 30 | this.cmd_line = cmd_line; 31 | this.resolved_value = null; 32 | this.rejected_value = null; 33 | } 34 | 35 | Command.prototype.run = function () { 36 | this.state = CMD_PENDING; 37 | 38 | return this.fn().then( 39 | value => { 40 | this.state = CMD_RESOLVED; 41 | this.resolved_value = value; 42 | }, 43 | value => { 44 | this.state = CMD_REJECTED; 45 | this.rejected_value = value; 46 | } 47 | ); 48 | }; 49 | 50 | 51 | function Session(server, stream) { 52 | this.in_stream = stream; 53 | this.out_stream = flattenStream(); 54 | this.server = server; 55 | 56 | // Could be just {}, but this is more clean 57 | // if this.groups.name is not set, group is not selected 58 | this.group = { 59 | min_index: 0, 60 | max_index: 0, 61 | total: 0, 62 | name: null, 63 | description: '', 64 | current_article: 0 65 | }; 66 | 67 | this.pipeline = new Denque(); 68 | 69 | this.debug_mark = crypto.pseudoRandomBytes(3).toString('hex'); 70 | 71 | // Random string used to track connection in logs 72 | debug_net(' [%s] %s', this.debug_mark, 'new connection'); 73 | 74 | // Create RE to search command name. Longest first (for subcommands) 75 | let commands = Object.keys(this.server.commands).sort().reverse(); 76 | 77 | this.__search_cmd_re = RegExp(`^(${commands.map(escape_regexp).join('|')})`, 'i'); 78 | 79 | this.lines = split2(); 80 | 81 | this.write(status._201_SRV_READY_RO); 82 | 83 | pipeline(stream, this.lines, () => {}); 84 | 85 | pipeline(this.out_stream, stream, () => {}); 86 | 87 | if (debug_net.enabled) { 88 | let debug_logger = split2(); 89 | 90 | pipeline(this.out_stream, debug_logger, () => {}); 91 | 92 | debug_logger.on('data', line => { 93 | debug_net('<-- [%s] %s', this.debug_mark, line); 94 | }); 95 | } 96 | 97 | this.lines.on('data', line => { 98 | debug_net('--> [%s] %s', this.debug_mark, line); 99 | this.parse(line); 100 | }); 101 | 102 | this.lines.on('error', err => { 103 | debug_err('ERROR: %O', serializeError(err)); 104 | this.server._onError(err); 105 | this.out_stream.destroy(); 106 | }); 107 | 108 | this.lines.on('end', () => { 109 | debug_net(' [%s] %s', this.debug_mark, 'connection closed'); 110 | this.out_stream.destroy(); 111 | }); 112 | } 113 | 114 | // By default connection is not secure 115 | Session.prototype.secure = false; 116 | // Default mode is "reader" 117 | Session.prototype.reader = true; 118 | 119 | Session.prototype.authenticated = false; 120 | Session.prototype.authinfo_user = null; 121 | Session.prototype.authinfo_pass = null; 122 | 123 | Session.prototype.current_group = null; 124 | 125 | /** 126 | * Send strings to connected client, adding CRLF after each 127 | * 128 | * data: 129 | * 130 | * - String 131 | * - Stream of strings (object mode) 132 | * - null (close session) 133 | * - Array with any combinations above 134 | */ 135 | Session.prototype.write = function (data) { 136 | if (!this.out_stream.writable) { 137 | if (typeof data.destroy === 'function') data.destroy(); 138 | return; 139 | } 140 | 141 | this.out_stream.write(data); 142 | }; 143 | 144 | 145 | function enqueue(session, command) { 146 | session.pipeline.push(command); 147 | session.tick(); 148 | } 149 | 150 | // Parse client commands and push into pipeline 151 | // 152 | Session.prototype.parse = function (data) { 153 | let input = data.toString().replace(/\r?\n$/, ''); 154 | 155 | // Command not recognized 156 | if (!this.__search_cmd_re.test(input)) { 157 | enqueue(this, new Command(() => Promise.resolve(status._500_CMD_UNKNOWN), input)); 158 | return; 159 | } 160 | 161 | let cmd = input.match(this.__search_cmd_re)[1].toUpperCase(); 162 | 163 | // Command looks known, but whole validation failed -> bad params 164 | if (!this.server.commands[cmd].validate.test(input)) { 165 | enqueue(this, new Command(() => Promise.resolve(status._501_SYNTAX_ERROR), input)); 166 | return; 167 | } 168 | 169 | // Command require auth, but it was not done yet 170 | // Force secure connection if needed 171 | if (this.server._needAuth(this, cmd)) { 172 | enqueue(this, new Command(() => Promise.resolve( 173 | this.secure ? status._480_AUTH_REQUIRED : status._483_NOT_SECURE 174 | ), input)); 175 | return; 176 | } 177 | 178 | enqueue(this, new Command(() => Promise.resolve(this.server.commands[cmd].run(this, input)), input)); 179 | }; 180 | 181 | 182 | Session.prototype.tick = function () { 183 | if (this.pipeline.isEmpty()) return; 184 | 185 | let cmd = this.pipeline.peekFront(); 186 | 187 | if (cmd.state === CMD_RESOLVED) { 188 | this.write(cmd.resolved_value); 189 | this.pipeline.shift(); 190 | this.tick(); 191 | 192 | } else if (cmd.state === CMD_REJECTED) { 193 | if (cmd.rejected_value) cmd.rejected_value.nntp_command = cmd.cmd_line; 194 | this.write(status._403_FUCKUP); 195 | debug_err('ERROR: %O', serializeError(cmd.rejected_value)); 196 | this.server._onError(cmd.rejected_value); 197 | this.pipeline.shift(); 198 | this.tick(); 199 | 200 | } else if (cmd.state === CMD_WAIT) { 201 | // stop executing commands on closed connection 202 | if (!this.out_stream.writable) return; 203 | 204 | cmd.run().then(() => this.tick()); 205 | } 206 | }; 207 | 208 | 209 | module.exports = Session; 210 | 211 | module.exports.create = function (server, stream) { 212 | return new Session(server, stream); 213 | }; 214 | -------------------------------------------------------------------------------- /lib/status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | _100_HELP_FOLLOWS : '100 Help text follows', 5 | _101_CAPABILITY_LIST : '101 Capability list:', 6 | _111_DATE : '111', 7 | _201_SRV_READY_RO : '201 Server ready - No posting allowed', 8 | _205_QUIT : '205 Closing connection - Goodbye', 9 | _211_GRP_SELECTED : '211', 10 | _215_INFO_FOLLOWS : '215 Information follows', 11 | _220_ARTICLE_FOLLOWS : '220', 12 | _221_HEAD_FOLLOWS : '221', 13 | _222_BODY_FOLLOWS : '222', 14 | _223_ARTICLE_EXISTS : '223', 15 | _224_OVERVIEW_INFO : '224 Overview information follows', 16 | _225_HEADERS_FOLLOW : '225 Headers follow', 17 | _230_NEWNEWS_FOLLOW : '230 List of new articles follows', 18 | _231_GRP_FOLLOWS : '231 List of new newsgroups follows', 19 | _281_AUTH_ACCEPTED : '281 Authentication accepted', 20 | _381_AUTH_NEED_PASS : '381 More authentication information required', 21 | _403_FUCKUP : '403 Internal fault', 22 | _411_GRP_NOT_FOUND : '411 No such newsgroup', 23 | _412_GRP_NOT_SLCTD : '412 No newsgroup has been selected', 24 | _420_ARTICLE_NOT_SLCTD : '420 Current article number is invalid', 25 | _421_NO_NEXT_ARTICLE : '421 No next article to retrieve', 26 | _422_NO_LAST_ARTICLE : '422 No previous article to retrieve', 27 | _423_NO_ARTICLE_BY_NUM : '423 No such article number in this group', 28 | _430_NO_ARTICLE_BY_ID : '430 No such article found', 29 | _480_AUTH_REQUIRED : '480 Authentication required', 30 | _481_AUTH_REJECTED : '481 Authentication rejected', 31 | _483_NOT_SECURE : '483 Secure connection required', 32 | _481_AUTH_BLACKLIST : '481 Authentication rejected (too many attempts)', 33 | _482_AUTH_OUT_OF_SEQ : '482 Authentication commands issued out of sequence', 34 | _500_CMD_UNKNOWN : '500 Command not recognized', 35 | _501_SYNTAX_ERROR : '501 Command syntax error', 36 | _502_CMD_UNAVAILABLE : '502 Command unavailable', 37 | _503_NOT_SUPPORTED : '503 Feature not supported' 38 | }; 39 | -------------------------------------------------------------------------------- /lib/wildmat.js: -------------------------------------------------------------------------------- 1 | // https://tools.ietf.org/html/rfc3977#section-4.1 2 | // 3 | // Create RegExp from wildmat patterns 4 | // 5 | 'use strict'; 6 | 7 | 8 | // Escape RE without * and ? 9 | // (full escape uses /([.?*+^$[\]\\(){}|-])/) 10 | // 11 | function escape(src) { 12 | return String(src).replace(/([.+^$[\]\\(){}|-])/g, '\\$1'); 13 | } 14 | 15 | 16 | module.exports = function wildmat(wm) { 17 | // Since rules are simple, use simple replace 18 | // steps instead of scanner. 19 | 20 | if (!wm) throw new Error('empty wildmat not allowed'); 21 | 22 | if (/^,|,$/.test(wm)) throw new Error('"," not allowed at start/end of wildmat'); 23 | if (/,,/.test(wm)) throw new Error('",," not allowed in wildmat'); 24 | 25 | // Split by "," and mark negative 26 | let patterns = wm.split(',').map(p => ({ 27 | match: p[0] === '!' ? p.slice(1) : p, 28 | neg: p[0] === '!' 29 | })); 30 | 31 | patterns.forEach(p => { 32 | if (/[\\[\]]/.test(p.match)) { 33 | throw new Error('"\\", "[" and "]" not allowed in wildmat'); 34 | } 35 | 36 | if (p.neg && !p.match) { 37 | throw new Error('empty negative condition not allowed'); 38 | } 39 | 40 | if (/\*.*\*.*\*/.test(p.match)) { 41 | throw new Error('too many asteriscs'); 42 | } 43 | 44 | p.match = p.match.replace(/\*+/g, '*'); 45 | 46 | if (p.match === '*') { 47 | // Special case, should not match empty string 48 | p.match = '.+'; 49 | } else { 50 | p.match = escape(p.match) 51 | .replace(/\*/g, '.*') 52 | .replace(/\?/g, '.'); 53 | } 54 | }); 55 | 56 | // Remove heading negative conditions (should be ignored) 57 | while (patterns.length && patterns[0].neg) patterns.shift(); 58 | 59 | if (!patterns.length) { 60 | throw new Error('wildmat should have positive condition'); 61 | } 62 | 63 | // compose full RE from parts 64 | // 65 | // pos,neg => ^(?!neg$)(pos)$ 66 | 67 | let res_head = '^('; 68 | let res_tail = ')$'; 69 | 70 | for (let i = patterns.length - 1; i >= 0; i--) { 71 | let p = patterns[i]; 72 | 73 | if (p.neg) { 74 | // negative pattern 75 | res_head += `((?!${p.match}$)(`; 76 | res_tail = '))' + res_tail; 77 | } else { 78 | // positive pattern 79 | res_head += p.match; 80 | if (i > 0) res_head += '|'; 81 | } 82 | } 83 | 84 | return new RegExp(res_head + res_tail, 'u'); 85 | }; 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nntp-server", 3 | "version": "3.1.0", 4 | "description": "NNTP server implementation.", 5 | "keywords": [ 6 | "nntp", 7 | "nntp server" 8 | ], 9 | "license": "MIT", 10 | "repository": "nodeca/nntp-server", 11 | "files": [ 12 | "index.js", 13 | "lib/" 14 | ], 15 | "scripts": { 16 | "lint": "eslint .", 17 | "test": "npm run lint && nyc mocha", 18 | "covreport": "nyc report --reporter html && nyc report --reporter lcov" 19 | }, 20 | "dependencies": { 21 | "debug": "^4.3.3", 22 | "denque": "^2.0.1", 23 | "serialize-error": "^8.1.0", 24 | "split2": "^4.1.0" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^8.3.0", 28 | "js-yaml": "^4.1.0", 29 | "mocha": "^9.1.3", 30 | "nyc": "^15.1.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | -------------------------------------------------------------------------------- /test/commands.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const net = require('net'); 5 | const join = require('path').join; 6 | const asline = require('./helpers').asline; 7 | const mock_db = require('./helpers').mock_db; 8 | const Server = require('..'); 9 | 10 | 11 | describe('commands', function () { 12 | let nntp, port, socket, client; 13 | 14 | before(function () { 15 | // Disable default channel ecryption check 16 | nntp = new Server({ secure: true }); 17 | mock_db(nntp, join(__dirname, 'fixtures/db.yml')); 18 | 19 | // listen on random port 20 | return nntp.listen('nntp://localhost:0').then(() => { 21 | port = nntp.server.address().port; 22 | }); 23 | }); 24 | 25 | 26 | after(function () { 27 | return nntp.close(); 28 | }); 29 | 30 | 31 | beforeEach(function (callback) { 32 | socket = net.connect(port, err => { 33 | client = asline(socket, { timeout: 2000 }).expect(/^201/); 34 | callback(err); 35 | }); 36 | }); 37 | 38 | 39 | afterEach(function () { 40 | return client.end(); 41 | }); 42 | 43 | 44 | describe('ARTICLE/BODY/HEAD/STAT', function () { 45 | 46 | it('ARTICLE should return current article', function () { 47 | return client 48 | .send('GROUP test.groups.foo') 49 | .expect(/^211 /) 50 | .send('ARTICLE') 51 | .expect('.', /first message in first group/); 52 | }); 53 | 54 | it('ARTICLE should fail in empty group', function () { 55 | return client 56 | .send('GROUP test.groups.empty') 57 | .expect(/^211 /) 58 | .send('ARTICLE') 59 | .expect(/^420 /); 60 | }); 61 | 62 | it('ARTICLE should fail without a group selected', function () { 63 | return client 64 | .send('ARTICLE 1') 65 | .expect(/^412 /); 66 | }); 67 | 68 | it('ARTICLE should fail if no article found by id', function () { 69 | return client 70 | .send('ARTICLE ') 71 | .expect(/^430 /); 72 | }); 73 | 74 | it('ARTICLE should retrieve an article by id', function () { 75 | return client 76 | .send('ARTICLE ') 77 | .expect('.', /second message in first group/); 78 | }); 79 | 80 | it('ARTICLE should retrieve an article by number', function () { 81 | return client 82 | .send('GROUP test.groups.foo') 83 | .expect(/^211/) 84 | .send('ARTICLE 2') 85 | .expect('.', /second message in first group/); 86 | }); 87 | 88 | it('ARTICLE should fail if no article found by number', function () { 89 | return client 90 | .send('GROUP test.groups.foo') 91 | .expect(/^211/) 92 | .send('ARTICLE 123456') 93 | .expect(/^423 /); 94 | }); 95 | 96 | 97 | it('BODY should return current article', function () { 98 | return client 99 | .send('GROUP test.groups.foo') 100 | .expect(/^211 /) 101 | .send('BODY') 102 | .expect('.', /first message in first group/); 103 | }); 104 | 105 | it('BODY should fail in empty group', function () { 106 | return client 107 | .send('GROUP test.groups.empty') 108 | .expect(/^211 /) 109 | .send('BODY') 110 | .expect(/^420 /); 111 | }); 112 | 113 | it('BODY should fail without a group selected', function () { 114 | return client 115 | .send('BODY 1') 116 | .expect(/^412 /); 117 | }); 118 | 119 | it('BODY should fail if no article found by id', function () { 120 | return client 121 | .send('BODY ') 122 | .expect(/^430 /); 123 | }); 124 | 125 | it('BODY should retrieve an article body by id', function () { 126 | return client 127 | .send('BODY ') 128 | .expect('.', /second message in first group/); 129 | }); 130 | 131 | it('BODY should retrieve an article body by number', function () { 132 | return client 133 | .send('GROUP test.groups.foo') 134 | .expect(/^211/) 135 | .send('BODY 2') 136 | .expect('.', /second message in first group/); 137 | }); 138 | 139 | it('BODY should fail if no article found by number', function () { 140 | return client 141 | .send('GROUP test.groups.foo') 142 | .expect(/^211/) 143 | .send('BODY 123456') 144 | .expect(/^423 /); 145 | }); 146 | 147 | 148 | it('HEAD should return current article', function () { 149 | return client 150 | .send('GROUP test.groups.foo') 151 | .expect(/^211 /) 152 | .send('HEAD') 153 | .expect('221 1 <4c51f95eda05@lists.example.org>') 154 | .expect(/^From: /) 155 | .expect(/^Xref: /) 156 | .expect('.'); 157 | }); 158 | 159 | it('HEAD should fail in empty group', function () { 160 | return client 161 | .send('GROUP test.groups.empty') 162 | .expect(/^211 /) 163 | .send('HEAD') 164 | .expect(/^420 /); 165 | }); 166 | 167 | it('HEAD should fail without a group selected', function () { 168 | return client 169 | .send('HEAD 1') 170 | .expect(/^412 /); 171 | }); 172 | 173 | it('HEAD should fail if no article found by id', function () { 174 | return client 175 | .send('HEAD ') 176 | .expect(/^430 /); 177 | }); 178 | 179 | it('HEAD should retrieve an article header by id', function () { 180 | return client 181 | .send('HEAD ') 182 | .expect('221 0 ') 183 | .expect(/^From: /) 184 | .expect('.'); 185 | }); 186 | 187 | it('HEAD should retrieve an article header by number', function () { 188 | return client 189 | .send('GROUP test.groups.foo') 190 | .expect(/^211/) 191 | .send('HEAD 2') 192 | .expect('221 2 ') 193 | .expect(/^From: /) 194 | .expect('.'); 195 | }); 196 | 197 | it('HEAD should fail if no article found by number', function () { 198 | return client 199 | .send('GROUP test.groups.foo') 200 | .expect(/^211/) 201 | .send('HEAD 123456') 202 | .expect(/^423 /); 203 | }); 204 | 205 | 206 | it('STAT should return current article', function () { 207 | return client 208 | .send('GROUP test.groups.foo') 209 | .expect(/^211 /) 210 | .send('STAT') 211 | .expect('223 1 <4c51f95eda05@lists.example.org>'); 212 | }); 213 | 214 | it('STAT should fail in empty group', function () { 215 | return client 216 | .send('GROUP test.groups.empty') 217 | .expect(/^211 /) 218 | .send('STAT') 219 | .expect(/^420 /); 220 | }); 221 | 222 | it('STAT should fail without a group selected', function () { 223 | return client 224 | .send('STAT 1') 225 | .expect(/^412 /); 226 | }); 227 | 228 | it('STAT should fail if no article found by id', function () { 229 | return client 230 | .send('STAT ') 231 | .expect(/^430 /); 232 | }); 233 | 234 | it('STAT should retrieve an article info by id', function () { 235 | return client 236 | .send('STAT ') 237 | .expect('223 0 '); 238 | }); 239 | 240 | it('STAT should retrieve an article info by number', function () { 241 | return client 242 | .send('GROUP test.groups.foo') 243 | .expect(/^211/) 244 | .send('STAT 2') 245 | .expect('223 2 '); 246 | }); 247 | 248 | it('STAT should fail if no article found by number', function () { 249 | return client 250 | .send('GROUP test.groups.foo') 251 | .expect(/^211/) 252 | .send('STAT 123456') 253 | .expect(/^423 /); 254 | }); 255 | }); 256 | 257 | describe('CAPABILITIES', function () { 258 | it('should return info', function () { 259 | return client 260 | .send('CAPABILITIES') 261 | .expect('.', /^VERSION 2$/m); 262 | }); 263 | }); 264 | 265 | describe('AUTHINFO', function () { 266 | 267 | it('should success', function () { 268 | return client 269 | .send('AUTHINFO USER foo') 270 | .expect(/^381/) 271 | .send('AUTHINFO PASS bar') 272 | .expect(/^281/); 273 | }); 274 | 275 | it('should fail', function () { 276 | return client 277 | .send('AUTHINFO USER foo') 278 | .expect(/^381/) 279 | .send('AUTHINFO PASS baddddd') 280 | .expect(/^481/); 281 | }); 282 | 283 | it('AUTHINFO PASS out of order', function () { 284 | return client 285 | .send('AUTHINFO PASS kkk') 286 | .expect(/^482/); 287 | }); 288 | 289 | it('not allowed twice', function () { 290 | return client 291 | .send('AUTHINFO USER foo') 292 | .expect(/^381/) 293 | .send('AUTHINFO PASS bar') 294 | .expect(/^281/) 295 | .send('AUTHINFO USER foo') 296 | .expect(/^502/) 297 | .send('AUTHINFO PASS bar') 298 | .expect(/^502/); 299 | }); 300 | 301 | it('should announce capability', function () { 302 | return client 303 | .send('CAPABILITIES') 304 | .expect('.', /^AUTHINFO USER/m) 305 | .send('AUTHINFO USER foo') 306 | .expect(/^381/) 307 | .send('AUTHINFO PASS bar') 308 | .expect(/^281/) 309 | .send('CAPABILITIES') 310 | .expect('.', caps => caps.indexOf('AUTHINFO') === -1); 311 | }); 312 | 313 | }); 314 | 315 | 316 | it('DATE', function () { 317 | return client 318 | .send('DATE') 319 | .expect(/^111 \d{14}$/); 320 | }); 321 | 322 | 323 | describe('GROUP', function () { 324 | 325 | it('should be ok on existing', function () { 326 | return client 327 | .send('GROUP test.groups.foo') 328 | .expect(/^211/); 329 | }); 330 | 331 | it('should fail on not existing', function () { 332 | return client 333 | .send('GROUP test.groups.not.exists') 334 | .expect(/^411/); 335 | }); 336 | 337 | it('should select first message on enter', function () { 338 | return client 339 | .send('GROUP test.groups.foo') 340 | .expect(/^211 /) 341 | .send('STAT') // check that current article is 1 342 | .expect(/^223 1 /) 343 | .send('STAT 2') // set current article to 2 344 | .expect(/^223 2 /) 345 | .send('GROUP test.groups.foo') 346 | .expect(/^211 /) 347 | .send('STAT') // check that current article is 1 348 | .expect(/^223 1 /); 349 | }); 350 | 351 | }); 352 | 353 | 354 | it('HELP', function () { 355 | return client 356 | .send('HELP') 357 | .expect(/^100 /) 358 | .expect('.'); 359 | }); 360 | 361 | 362 | describe('LIST', function () { 363 | 364 | it('should announce capabilities', function () { 365 | return client 366 | .send('CAPABILITIES') 367 | .expect('.', /^LIST ACTIVE NEWSGROUPS/m); 368 | }); 369 | 370 | it('should return active groups', function () { 371 | return client 372 | .send('LIST') 373 | .expect(/^215 /) 374 | .expect('test.groups.foo 2 1 n') 375 | .expect('test.groups.bar 3 1 n') 376 | .expect('test.groups.empty 0 0 n') 377 | .expect('.'); 378 | }); 379 | 380 | it('should fail on wrong subcommand', function () { 381 | return client 382 | .send('LIST BADSUBCOMMAND') 383 | .expect(/^501 /); 384 | }); 385 | 386 | it('LIST ACTIVE should return groups as LIST', function () { 387 | return client 388 | .send('LIST ACTIVE') 389 | .expect(/^215 /) 390 | .expect('test.groups.foo 2 1 n') 391 | .expect('test.groups.bar 3 1 n') 392 | .expect('test.groups.empty 0 0 n') 393 | .expect('.'); 394 | }); 395 | 396 | it('LIST ACTIVE with wildmat "*.foo,*.empty"', function () { 397 | return client 398 | .send('LIST ACTIVE *.foo,*.empty') 399 | .expect(/^215 /) 400 | .expect('test.groups.foo 2 1 n') 401 | .expect('test.groups.empty 0 0 n') 402 | .expect('.'); 403 | }); 404 | 405 | it('LIST ACTIVE with bad wildmat', function () { 406 | return client 407 | .send('LIST ACTIVE !bad') 408 | .expect(/^501 /); 409 | }); 410 | 411 | it('LIST NEWSGROUPS should return groups as LIST', function () { 412 | return client 413 | .send('LIST NEWSGROUPS') 414 | .expect(/^215 /) 415 | .expect('test.groups.foo\tTest newsgroup') 416 | .expect('test.groups.bar\t') 417 | .expect('test.groups.empty\tEmpty newsgroup') 418 | .expect('.'); 419 | }); 420 | 421 | it('LIST NEWSGROUPS with wildmat "*.foo,*.empty"', function () { 422 | return client 423 | .send('LIST NEWSGROUPS *.foo,*.empty') 424 | .expect(/^215 /) 425 | .expect('test.groups.foo\tTest newsgroup') 426 | .expect('test.groups.empty\tEmpty newsgroup') 427 | .expect('.'); 428 | }); 429 | 430 | it('LIST NEWSGROUPS with bad wildmat', function () { 431 | return client 432 | .send('LIST NEWSGROUPS !bad') 433 | .expect(/^501 /); 434 | }); 435 | 436 | it('LIST OVERVIEW.FMT', function () { 437 | return client 438 | .send('LIST OVERVIEW.FMT') 439 | .expect(/^215 /) 440 | .expect('.', /^Subject:\r\nFrom:\r\nDate:\r\nMessage-ID:\r\nReferences:\r\n:bytes\r\n:lines\r\n/); 441 | }); 442 | 443 | }); 444 | 445 | 446 | describe('LISTGROUP', function () { 447 | 448 | it('should be ok on existing', function () { 449 | return client 450 | .send('LISTGROUP test.groups.foo') 451 | .expect(/^211 2 1 2 test.groups.foo/) 452 | .expect('1') 453 | .expect('2') 454 | .expect('.'); 455 | }); 456 | 457 | it('should fail on not existing', function () { 458 | return client 459 | .send('LISTGROUP test.groups.not.exists') 460 | .expect(/^411 /); 461 | }); 462 | 463 | it('should fail if group not selected', function () { 464 | return client 465 | .send('LISTGROUP') 466 | .expect(/^412 /); 467 | }); 468 | 469 | it('should be ok for current groups', function () { 470 | return client 471 | .send('GROUP test.groups.foo') 472 | .expect(/^211 /) 473 | .send('LISTGROUP') 474 | .expect(/^211 2 1 2 test.groups.foo/) 475 | .expect('1') 476 | .expect('2') 477 | .expect('.'); 478 | }); 479 | 480 | it('should filter head by xxx range', function () { 481 | return client 482 | .send('LISTGROUP test.groups.foo 1') 483 | .expect(/^211 2 1 2 test.groups.foo/) 484 | .expect('1') 485 | .expect('.'); 486 | }); 487 | 488 | it('should filter head by xxx-yyy range', function () { 489 | return client 490 | .send('LISTGROUP test.groups.foo 2-5') 491 | .expect(/^211 2 1 2 test.groups.foo/) 492 | .expect('2') 493 | .expect('.'); 494 | }); 495 | 496 | it('should filter tail by xxx-yyy range', function () { 497 | return client 498 | .send('LISTGROUP test.groups.foo 0-1') 499 | .expect(/^211 2 1 2 test.groups.foo/) 500 | .expect('1') 501 | .expect('.'); 502 | }); 503 | 504 | it('should filter by xxx- range', function () { 505 | return client 506 | .send('LISTGROUP test.groups.foo 2-') 507 | .expect(/^211 2 1 2 test.groups.foo/) 508 | .expect('2') 509 | .expect('.'); 510 | }); 511 | 512 | it('should select first message on enter', function () { 513 | return client 514 | .send('LISTGROUP test.groups.foo') 515 | .expect('.', /^211 /) 516 | .send('STAT') // check that current article is 1 517 | .expect(/^223 1 /) 518 | .send('STAT 2') // set current article to 2 519 | .expect(/^223 2 /) 520 | .send('LISTGROUP test.groups.foo') 521 | .expect('.', /^211 /) 522 | .send('STAT') // check that current article is 1 523 | .expect(/^223 1 /); 524 | }); 525 | 526 | }); 527 | 528 | 529 | describe('HDR', function () { 530 | 531 | it('should get headers by message id', function () { 532 | return client 533 | .send('HDR From <4c51f95eda05@lists.example.org>') 534 | .expect(/^225 /) 535 | .expect('0 John Doe ') 536 | .expect('.'); 537 | }); 538 | 539 | it('should get headers by number', function () { 540 | return client 541 | .send('GROUP test.groups.foo') 542 | .expect(/^211 /) 543 | .send('HDR From 2') 544 | .expect(/^225 /) 545 | .expect('2 Richard Roe ') 546 | .expect('.'); 547 | }); 548 | 549 | it('should get headers by open range', function () { 550 | return client 551 | .send('GROUP test.groups.foo') 552 | .expect(/^211 /) 553 | .send('HDR From 1-') 554 | .expect(/^225 /) 555 | .expect('1 John Doe ') 556 | .expect('2 Richard Roe ') 557 | .expect('.'); 558 | }); 559 | 560 | it('should get headers by closed range', function () { 561 | return client 562 | .send('GROUP test.groups.foo') 563 | .expect(/^211 /) 564 | .send('HDR From 1-5') 565 | .expect(/^225 /) 566 | .expect('1 John Doe ') 567 | .expect('2 Richard Roe ') 568 | .expect('.'); 569 | }); 570 | 571 | it('should get headers for current message', function () { 572 | return client 573 | .send('GROUP test.groups.foo') 574 | .expect(/^211 /) 575 | .send('HDR From') 576 | .expect(/^225 /) 577 | .expect('1 John Doe ') 578 | .expect('.'); 579 | }); 580 | 581 | it('should fail to get headers for non-existent message id', function () { 582 | return client 583 | .send('HDR From ') 584 | .expect(/^430 /); 585 | }); 586 | 587 | it('should fail to get headers for non-existent range', function () { 588 | return client 589 | .send('GROUP test.groups.foo') 590 | .expect(/^211 /) 591 | .send('HDR From 100-102') 592 | .expect(/^423 /); 593 | }); 594 | 595 | it('should require newsgroup to be selected for range', function () { 596 | return client 597 | .send('HDR From 1-2') 598 | .expect(/^412 /); 599 | }); 600 | 601 | it('should return non-existent headers as blanks', function () { 602 | return client 603 | .send('GROUP test.groups.foo') 604 | .expect(/^211 /) 605 | .send('HDR References 1-5') 606 | .expect(/^225 /) 607 | .expect('1 ') 608 | .expect('2 ') 609 | .expect('.'); 610 | }); 611 | 612 | it('should be aliased to XHDR', function () { 613 | return client 614 | .send('XHDR From <4c51f95eda05@lists.example.org>') 615 | .expect(/^225 /) 616 | .expect('0 John Doe ') 617 | .expect('.'); 618 | }); 619 | 620 | }); 621 | 622 | 623 | describe('OVER', function () { 624 | /* eslint-disable max-len */ 625 | let over_1_re_by_num = /^0\t\tJohn Doe \t\t<4c51f95eda05@lists\.example\.org>\t\t\d+\t\d+\tXref: localhost test.groups.foo:1$/; 626 | let over_1_re = /^1\t\tJohn Doe \t\t<4c51f95eda05@lists\.example\.org>\t\t\d+\t\d+\tXref: localhost test.groups.foo:1$/; 627 | let over_2_re = /^2\t\tRichard Roe \t\t\t\t\d+\t\d+\t$/; 628 | /* eslint-enable max-len */ 629 | 630 | it('should get overview by message id', function () { 631 | return client 632 | .send('OVER <4c51f95eda05@lists.example.org>') 633 | .expect(/^224 /) 634 | .expect(over_1_re_by_num) 635 | .expect('.'); 636 | }); 637 | 638 | it('should get overview by number', function () { 639 | return client 640 | .send('GROUP test.groups.foo') 641 | .expect(/^211 /) 642 | .send('OVER 2') 643 | .expect(/^224 /) 644 | .expect(over_2_re) 645 | .expect('.'); 646 | }); 647 | 648 | it('should get overview by open range', function () { 649 | return client 650 | .send('GROUP test.groups.foo') 651 | .expect(/^211 /) 652 | .send('OVER 1-') 653 | .expect(/^224 /) 654 | .expect(over_1_re) 655 | .expect(over_2_re) 656 | .expect('.'); 657 | }); 658 | 659 | it('should get overview by closed range', function () { 660 | return client 661 | .send('GROUP test.groups.foo') 662 | .expect(/^211 /) 663 | .send('OVER 1-5') 664 | .expect(/^224 /) 665 | .expect(over_1_re) 666 | .expect(over_2_re) 667 | .expect('.'); 668 | }); 669 | 670 | it('should get overview for current message', function () { 671 | return client 672 | .send('GROUP test.groups.foo') 673 | .expect(/^211 /) 674 | .send('OVER') 675 | .expect(/^224 /) 676 | .expect(over_1_re) 677 | .expect('.'); 678 | }); 679 | 680 | it('should fail to get overview for non-existent message id', function () { 681 | return client 682 | .send('OVER ') 683 | .expect(/^430 /); 684 | }); 685 | 686 | it('should fail to get overview for non-existent range', function () { 687 | return client 688 | .send('GROUP test.groups.foo') 689 | .expect(/^211 /) 690 | .send('OVER 100-102') 691 | .expect(/^423 /); 692 | }); 693 | 694 | it('should require newsgroup to be selected for range', function () { 695 | return client 696 | .send('OVER 1-2') 697 | .expect(/^412 /); 698 | }); 699 | 700 | it('should be aliased to XOVER', function () { 701 | return client 702 | .send('XOVER <4c51f95eda05@lists.example.org>') 703 | .expect(/^224 /) 704 | .expect(over_1_re_by_num) 705 | .expect('.'); 706 | }); 707 | 708 | }); 709 | 710 | 711 | describe('MODE', function () { 712 | 713 | it('should announce capability', function () { 714 | return client 715 | .send('CAPABILITIES') 716 | .expect('.', /^READER$/m); 717 | }); 718 | 719 | it('MODE READER', function () { 720 | return client 721 | .send('MODE READER') 722 | .expect(/^201 /); 723 | }); 724 | 725 | it('MODE POSTER', function () { 726 | return client 727 | .send('MODE POSTER') 728 | .expect('501 Unknown MODE option'); 729 | }); 730 | }); 731 | 732 | 733 | describe('NEWGROUPS', function () { 734 | 735 | it('2-digits year (previous century)', function () { 736 | return client 737 | .send('NEWGROUPS 990101 000000') 738 | .expect(/^215 /) 739 | .expect('test.groups.foo 2 1 n') 740 | .expect('test.groups.bar 3 1 n') 741 | .expect('.'); 742 | }); 743 | 744 | it('2-digits year (current century)', function () { 745 | return client 746 | .send('NEWGROUPS 110101 000000') 747 | .expect(/^215 /) 748 | .expect('test.groups.bar 3 1 n') 749 | .expect('.'); 750 | }); 751 | 752 | it('4-digits year', function () { 753 | return client 754 | .send('NEWGROUPS 19990101 000000') 755 | .expect(/^215 /) 756 | .expect('test.groups.foo 2 1 n') 757 | .expect('test.groups.bar 3 1 n') 758 | .expect('.'); 759 | }); 760 | 761 | }); 762 | 763 | 764 | describe('NEWNEWS', function () { 765 | 766 | it('bad wildmat', function () { 767 | return client 768 | .send('NEWNEWS !bad 19990101 000000') 769 | .expect(/^501 /); 770 | }); 771 | 772 | it('2-digits year (previous century)', function () { 773 | return client 774 | .send('NEWNEWS * 990101 000000') 775 | .expect(/^230 /) 776 | .expect('') 777 | .expect('<1ce0bf1e35b4@lists.example.org>') 778 | .expect('<535b279b4bb9@lists.example.org>') 779 | .expect('.'); 780 | }); 781 | 782 | it('2-digits year (current century)', function () { 783 | return client 784 | .send('NEWNEWS * 160101 000000') 785 | .expect(/^230 /) 786 | .expect('') 787 | .expect('<1ce0bf1e35b4@lists.example.org>') 788 | .expect('<535b279b4bb9@lists.example.org>') 789 | .expect('.'); 790 | }); 791 | 792 | it('4-digits year', function () { 793 | return client 794 | .send('NEWNEWS * 19990101 000000') 795 | .expect(/^230 /) 796 | .expect('') 797 | .expect('<1ce0bf1e35b4@lists.example.org>') 798 | .expect('<535b279b4bb9@lists.example.org>') 799 | .expect('.'); 800 | }); 801 | 802 | it('limit by a wildmat', function () { 803 | return client 804 | .send('NEWNEWS *foo 19990101 000000') 805 | .expect(/^230 /) 806 | .expect('') 807 | .expect('.'); 808 | }); 809 | 810 | }); 811 | 812 | 813 | it('QUIT', function () { 814 | let wait_for_close = new Promise(resolve => { 815 | socket.on('close', resolve); 816 | }); 817 | 818 | return client 819 | .send('QUIT') 820 | .expect(/^205 /) 821 | .then(() => wait_for_close); 822 | }); 823 | 824 | 825 | it('should fail on unknown command', function () { 826 | return client 827 | .send('qwerty') 828 | .expect(/^500 /); 829 | }); 830 | }); 831 | -------------------------------------------------------------------------------- /test/fixtures/db.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - 3 | name: test.groups.foo 4 | min_index: 1 5 | max_index: 2 6 | total: 2 7 | create_ts: 2010-01-16 3:14:24.05 8 | description: Test newsgroup 9 | - 10 | name: test.groups.bar 11 | min_index: 1 12 | max_index: 3 13 | total: 3 14 | create_ts: 2011-04-29 16:42:35.11 15 | description: ~ 16 | - 17 | name: test.groups.empty 18 | min_index: 0 19 | max_index: 0 20 | total: 0 21 | create_ts: 1980-01-01 0:01:00.00 22 | description: Empty newsgroup 23 | 24 | messages: 25 | # First group 26 | - 27 | group: test.groups.foo 28 | id: 4c51f95eda05@lists.example.org 29 | index: 1 30 | ts: 1997-01-15 2:59:43.10 31 | head: | 32 | From: John Doe 33 | Xref: localhost test.groups.foo:1 34 | body: | 35 | first message in first group 36 | - 37 | group: test.groups.foo 38 | id: d417dea0c7a3@lists.example.org 39 | index: 2 40 | ts: 2017-01-16 3:14:24.05 41 | head: | 42 | From: Richard Roe 43 | body: | 44 | second message in first group 45 | # Second group 46 | - 47 | group: test.groups.bar 48 | id: 1ce0bf1e35b4@lists.example.org 49 | index: 1 50 | ts: 2016-02-11 12:34:24.48 51 | head: | 52 | From: John Doe 53 | body: | 54 | first message in second group 55 | - 56 | group: test.groups.bar 57 | id: 535b279b4bb9@lists.example.org 58 | index: 2 59 | ts: 2016-04-29 16:42:35.11 60 | head: | 61 | From: Richard Roe 62 | body: | 63 | second message in second group 64 | -------------------------------------------------------------------------------- /test/fixtures/server-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDDjCCAfYCCQDWm33tO7TIBTANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJq 3 | czEPMA0GA1UECAwGbm9kZWNhMRQwEgYDVQQKDAtubnRwLXNlcnZlcjESMBAGA1UE 4 | AwwJbG9jYWxob3N0MCAXDTE3MDMyMzE1MzkwM1oYDzIxMDMwMTA5MTUzOTAzWjBI 5 | MQswCQYDVQQGEwJqczEPMA0GA1UECAwGbm9kZWNhMRQwEgYDVQQKDAtubnRwLXNl 6 | cnZlcjESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 7 | MIIBCgKCAQEAlg0dnUDw4tmKeBB5A/lQFoNgxiw+8Jo0BUAU3k47SrEkWGEH8xhx 8 | spGeM7HDoBicbMQV6vcNSOT1Q5kSC5NhaImxG5FhkuwhR9wwmbb1b/+dRltjdCh4 9 | wngwOnz5hDm3mPkvEoW7XEE7s40Pgz9PnK94I4hhuYBb3cWMauUyjjRGkn967EbJ 10 | Wd4p8z3gy42POXpNZpk/7Po7sRr+UJn9Uesj9GXx7oU8QTWQYlmWFOISjaK7wJ98 11 | Ytk/Gu+zwohgSjrU9rultW/xUaRzCrXe5P5npFR1m+XQYC4O78r5Zz7pYuPdgfrJ 12 | RIxRV7Kw/ZBxGyvYe6WfmUo4osPVtsLIlQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB 13 | AQAFDpIKvIShO5iX3j++/JYFES8jdK+I8oaU7UZ7/6ijF2UD0gQFe+DqmXcWu1Gg 14 | lSKa/tvZzesJrDpTuwYjyh3OSUpAIvbJxLE8Qi8lMmujHNnWm+kfVCrr/4SI3Vio 15 | EjEkilsgToAU80G6YY7p/6uNv0N4upGWp8ZXNploi8LYLt+EH30AQJgUs5u4d6qT 16 | IhVdWEDvXrBjTtpS2PfkEyNT121RyjzqCw9suf/cnNpIcirO+KnfpWocCW5GFdm1 17 | xQP3ygmkVkplkEgIhQSGzBye+soitIs91KlLSNZguK5NsLhDR50iUQ5CpcDQpl57 18 | GsOJu+XHWwKWc+XwnXfiyesT 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/fixtures/server-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAlg0dnUDw4tmKeBB5A/lQFoNgxiw+8Jo0BUAU3k47SrEkWGEH 3 | 8xhxspGeM7HDoBicbMQV6vcNSOT1Q5kSC5NhaImxG5FhkuwhR9wwmbb1b/+dRltj 4 | dCh4wngwOnz5hDm3mPkvEoW7XEE7s40Pgz9PnK94I4hhuYBb3cWMauUyjjRGkn96 5 | 7EbJWd4p8z3gy42POXpNZpk/7Po7sRr+UJn9Uesj9GXx7oU8QTWQYlmWFOISjaK7 6 | wJ98Ytk/Gu+zwohgSjrU9rultW/xUaRzCrXe5P5npFR1m+XQYC4O78r5Zz7pYuPd 7 | gfrJRIxRV7Kw/ZBxGyvYe6WfmUo4osPVtsLIlQIDAQABAoIBADUyszQkaQLUQ45r 8 | sKvjASzqKS45U5sz4IuX+44RSF4jzZHz3MCoAu9fRypmtMeW0iaRon+qVTBp1DbC 9 | Fy65csiAQKVYdrKDOw1iGGz2+69YfacLEYiGLMItoFOsKT5ixB/dAH5doV6E1ijD 10 | MyPCF3SilUJrwNHmmytWNYFUfwcBIKbPWUy6q/z6KFGI+YUIvDh8rAD7M785bAWq 11 | k8Im4vh1Bj+OVUyYztxqA3JVFAP8dEi9SnLdQMyDc7GyXSkynCj3vdOH+WnP1c0h 12 | dI9nojHjEOXRVQeWjOlQ53ybQk4HUUp3EmWD1DErjd5QWF1hrkRN6mkjcVqXt2W5 13 | i4SQVuUCgYEAxF0929bjn743EfnO78LIpGliVyzbNR31HjW6/T0QDj8LBi9hiyeq 14 | +7nuh9BSDD5qKefaowbWXuNYkl4CMenbmZUXwmrMfJXTZMfUfYPEe/hkXYvX6MEd 15 | boEOxezpcEWVTHG7G/nBi1XvOCZRBFCIUbo8KfDJ/lEtDau7p1BuvnMCgYEAw58r 16 | yGZQWN4T6e8g4R/WehYwvyEJvOhexprpnNPqISVoVVZY7p3jD7HfuHSsDaTLcXca 17 | h7fLbsSz+47iK+D8Ikga9y+vEgKnYkCNP9VHHBtQh9gB6X5hbQi9G3JfoAMuJ4bu 18 | mB7beN3buTe/XOjbs9KM6OoZEjmzqTEV8JxAUtcCgYEAvifxZHgHvEUu/Uhlvkdp 19 | l9W59uOocBrPqW6s4tmEN9eTLG0rz98dNGJM/Nae7d5vXp95WdCgPgl5V1yUUZO/ 20 | Jk58ULitx6Qrr3fYbafx2X+kafanom2Iu99c2AzhzuiDeSDV6nSFmhIg88YfRMdc 21 | C5EG/DKC2bXwpEF/GrrIU8MCgYBux1+q783GVZTfYCy1cCssHg7i3Zm/IbQyMh3j 22 | Utp+hMmNsgVQs5aXF7rCoUehvlX7XmBmxP1uL/Rgm6yW/qSp4T1sB9PTli8l47pZ 23 | kLThRNKY6wlCKfCQJ2e3+FAQtFxZw/6vpKHS04iPXfN/cNqh/bUQXSlveb+1K3fq 24 | NwHyJwKBgD6vVzPQ1VQ1Zb4LWDgvjTqv0GsD3S45DfSvApAWax3E4RqAf0ymigcE 25 | 44FubXyp60OU7/JI9MG1CCqSJGg4uq5m4Apa1mYFHJjLknfBdzRrm3BjMJmZEKSH 26 | h3/fE0PEEViAMbdDYY2OXxT9B8q4qSUfs1wt5Fj/U+p+MHS+FBOA 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/flatten-stream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const flatten = require('../lib/flatten-stream'); 5 | const asline = require('./helpers').asline; 6 | const { Readable, Writable } = require('stream'); 7 | 8 | 9 | describe('flatten-stream', function () { 10 | it('should concatenate strings', function () { 11 | let stream = flatten(); 12 | 13 | Readable.from([ 'qwe', 'rty', '.' ]) 14 | .pipe(stream); 15 | 16 | return asline(stream).expect('.', 'qwe\r\nrty\r\n.'); 17 | }); 18 | 19 | 20 | it('should flatten arrays', function () { 21 | let stream = flatten(); 22 | 23 | Readable.from([ 'qwe', [ 'foo', 'bar', [ 1, 2, 3 ] ], 'rty', '.' ]) 24 | .pipe(stream); 25 | 26 | return asline(stream).expect('.', 'qwe\r\nfoo\r\nbar\r\n1\r\n2\r\n3\r\nrty\r\n.'); 27 | }); 28 | 29 | 30 | it('should support nested streams', function () { 31 | let stream = flatten(); 32 | 33 | Readable.from([ 1, 2, Readable.from([ 'a', 'b' ]), 3, 4, Readable.from([ 'c', 'd' ]), '.' ]) 34 | .pipe(stream); 35 | }); 36 | 37 | 38 | it('should start reading from next stream when previous is finished', function () { 39 | let stream = flatten(); 40 | 41 | // stream that writes 'foo', 'bar' and never ends 42 | let src1 = new Readable({ 43 | read() { 44 | if (!this._called) { 45 | this.push('foo'); 46 | this.push('bar'); 47 | this._called = true; 48 | } 49 | }, 50 | objectMode: true 51 | }); 52 | 53 | let src2 = new Readable({ 54 | read() { 55 | throw new Error('it should never read from here'); 56 | }, 57 | objectMode: true 58 | }); 59 | 60 | Readable.from([ src1, 'str', src2 ]) 61 | .pipe(stream); 62 | 63 | return asline(stream).expect('foo').expect('bar').end(); 64 | }); 65 | 66 | 67 | it('should end stream when null is sent', function (next) { 68 | let stream = flatten(); 69 | 70 | let guard = new Readable({ 71 | read() { 72 | throw new Error('it should never read from here'); 73 | }, 74 | objectMode: true 75 | }); 76 | 77 | assert.strictEqual(stream.writable, true); 78 | 79 | // using nested array because from2([ null ]) will just close input stream 80 | Readable.from([ [ 'foo', null, 'bar', guard ] ]) 81 | .pipe(stream); 82 | 83 | let buffer = []; 84 | 85 | stream.on('data', d => buffer.push(d)); 86 | stream.on('end', function () { 87 | assert.strictEqual(buffer.length, 1); 88 | assert.strictEqual(buffer.toString(), 'foo\r\n'); 89 | next(); 90 | }); 91 | }); 92 | 93 | 94 | it('should destroy all input streams when stream is destroyed', async function () { 95 | let stream = flatten(); 96 | 97 | let src1 = new Readable({ 98 | read() { this.push('123'); this.push(null); }, 99 | objectMode: true 100 | }); 101 | 102 | let src2_ended = false; 103 | let src2 = new Readable({ 104 | read() { 105 | if (!this.read_called) { 106 | this.push('456'); 107 | stream.destroy(); 108 | this.read_called = true; 109 | } 110 | }, 111 | objectMode: true 112 | }); 113 | src2.destroy = () => { src2_ended = true; }; 114 | 115 | let src3_ended = false; 116 | let src3 = new Readable({ 117 | read() { throw new Error('should never be called'); }, 118 | objectMode: true 119 | }); 120 | src3.destroy = () => { src3_ended = true; }; 121 | 122 | stream.write([ src1, src2, src3 ]); 123 | 124 | let buffer = []; 125 | 126 | return new Promise(resolve => { 127 | stream.on('data', d => buffer.push(d)); 128 | stream.on('end', function () { 129 | assert.strictEqual(buffer.length, 1); 130 | assert.strictEqual(buffer.toString(), '123\r\n'); 131 | assert.strictEqual(src2_ended, true, 'src2 ended'); 132 | assert.strictEqual(src3_ended, true, 'src3 ended'); 133 | resolve(); 134 | }); 135 | }); 136 | }); 137 | 138 | 139 | it('should pause when recipient stream overflows', function (next) { 140 | let stream = flatten({ highWaterMark: 0 }); 141 | 142 | let count = 0; 143 | let next_called = false; 144 | 145 | let src1 = new Readable({ 146 | read() { 147 | this.push('qwe'); 148 | 149 | if (!next_called) next(); 150 | next_called = true; 151 | 152 | // prevent buffering all the data somewhere 153 | if (count > 100) next(new Error('reading too much data')); 154 | }, 155 | objectMode: true, 156 | highWaterMark: 0 157 | }); 158 | 159 | let src3 = new Readable({ 160 | read() { throw new Error('should never be called'); }, 161 | objectMode: true 162 | }); 163 | 164 | let dst = new Writable({ 165 | write() {}, 166 | highWaterMark: 0 167 | }); 168 | 169 | stream.pipe(dst); 170 | 171 | stream.write([ src1, src3 ]); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /test/helpers/asline.js: -------------------------------------------------------------------------------- 1 | // Stream test runner 2 | // 3 | 'use strict'; 4 | 5 | 6 | const assert = require('assert'); 7 | const pipeline = require('stream').pipeline; 8 | const split2 = require('split2'); 9 | const util = require('util'); 10 | 11 | 12 | class AsLine { 13 | constructor(stream, options = {}) { 14 | this._promise = Promise.resolve(); 15 | this._input = split2(); 16 | this._output = stream; 17 | this._timeout = options.timeout; 18 | this._linebreak = options.linebreak || '\r\n'; 19 | 20 | pipeline(stream, this._input, () => {}); 21 | } 22 | 23 | 24 | send(str) { 25 | this._promise = this._promise.then(() => new Promise((resolve, reject) => { 26 | this._output.write(str + '\n', err => { 27 | if (err) setImmediate(() => reject(err)); 28 | else setImmediate(() => resolve()); 29 | }); 30 | })); 31 | 32 | return this; 33 | } 34 | 35 | 36 | // reads input line-by-line until `fn` returns true 37 | _read(fn) { 38 | let on_readable; 39 | let buffer = []; 40 | 41 | this._promise = this._promise.then(() => { 42 | let promise = new Promise(resolve => { 43 | on_readable = () => { 44 | let data; 45 | 46 | while ((data = this._input.read()) !== null) { 47 | buffer.push(data); 48 | 49 | if (fn(data)) { 50 | this._input.removeListener('readable', on_readable); 51 | resolve(buffer.join(this._linebreak)); 52 | break; 53 | } 54 | } 55 | }; 56 | 57 | this._input.on('readable', on_readable); 58 | on_readable(); 59 | }); 60 | 61 | if (Number.isFinite(this._timeout) && this._timeout > 0) { 62 | promise = Promise.race([ 63 | promise, 64 | new Promise((resolve, reject) => { 65 | setTimeout(() => { 66 | if (on_readable) this._input.removeListener('readable', on_readable); 67 | reject(new Error('Operation timed out, buffer:\n' + buffer.join('\n'))); 68 | }, this._timeout); 69 | }) 70 | ]); 71 | } 72 | 73 | return promise; 74 | }); 75 | 76 | return this; 77 | } 78 | 79 | 80 | expect(stop, match) { 81 | if (arguments.length < 2) { 82 | match = stop; 83 | stop = 1; 84 | } 85 | 86 | let read_fn; 87 | 88 | if (typeof stop === 'function') { 89 | // regular function 90 | read_fn = stop; 91 | } else if (typeof stop.test === 'function') { 92 | // regexp 93 | read_fn = buf => stop.test(buf); 94 | } else if (typeof stop === 'string') { 95 | // string 96 | read_fn = buf => (buf === stop); 97 | } else if (typeof stop === 'number') { 98 | // number of lines 99 | let lines = stop; 100 | read_fn = () => (--lines <= 0); 101 | } 102 | 103 | this._promise = this._read(read_fn)._promise.then(actual => { 104 | if (typeof match === 'function') { 105 | assert.strictEqual(match(actual), true, util.inspect(actual)); 106 | } else if (typeof match.test === 'function') { 107 | assert.strictEqual(match.test(actual), true, `${util.inspect(match)} ~ ${util.inspect(actual)}`); 108 | } else { 109 | assert.strictEqual(actual, match); 110 | } 111 | 112 | return actual; 113 | }); 114 | 115 | return this; 116 | } 117 | 118 | 119 | skip(lines = 1) { 120 | this._promise = this._read(() => (--lines <= 0))._promise.then(() => {}); 121 | 122 | return this; 123 | } 124 | 125 | 126 | // Closes stream 127 | // 128 | end() { 129 | this._promise = this._promise.then(() => { 130 | this._input.end(); 131 | this._output.end(); 132 | }); 133 | 134 | return this; 135 | } 136 | 137 | 138 | then(...args) { 139 | this._promise = this._promise.then(...args); 140 | 141 | return this; 142 | } 143 | 144 | 145 | catch(...args) { 146 | this._promise = this._promise.catch(...args); 147 | 148 | return this; 149 | } 150 | } 151 | 152 | 153 | module.exports = (...args) => new AsLine(...args); 154 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | exports.asline = require('./asline'); 5 | exports.mock_db = require('./mock_db'); 6 | -------------------------------------------------------------------------------- /test/helpers/mock_db.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const fs = require('fs'); 5 | const yaml = require('js-yaml'); 6 | const { Readable } = require('stream'); 7 | 8 | 9 | module.exports = function mock_db(nntp, fixture_path) { 10 | 11 | let { groups, messages } = yaml.load(fs.readFileSync(fixture_path)); 12 | 13 | nntp._getGroups = function (session, ts, wildmat) { 14 | let result = groups 15 | .filter(g => !ts || g.create_ts > ts) 16 | .filter(g => (wildmat ? wildmat.test(g.name) : true)); 17 | 18 | return Readable.from(result); 19 | }; 20 | 21 | nntp._selectGroup = function (session, group_id) { 22 | let grp = groups.filter(g => g.name === group_id)[0]; 23 | 24 | if (grp) { 25 | session.group = Object.assign({}, grp); 26 | session.group.current_article = grp.total ? grp.min_index : 0; 27 | 28 | return Promise.resolve(true); 29 | } 30 | 31 | return Promise.resolve(false); 32 | }; 33 | 34 | nntp._getArticle = function (session, message_id) { 35 | let match, msg; 36 | 37 | match = message_id.match(/^<([^<>]+)>$/); 38 | 39 | if (match) { 40 | let id = match[1]; 41 | msg = messages.filter(m => m.id === id)[0]; 42 | 43 | } else { 44 | match = message_id.match(/^(\d+)$/); 45 | 46 | if (match) { 47 | let index = Number(match[1]); 48 | 49 | msg = messages 50 | .filter(m => m.group === session.group.name) 51 | .filter(m => m.index === index)[0]; 52 | } 53 | } 54 | 55 | // Yaml data can have unnecesary tail 56 | if (msg) { 57 | msg.head = msg.head.trimRight(); 58 | msg.body = msg.body.trimRight(); 59 | } 60 | 61 | return Promise.resolve(msg || null); 62 | }; 63 | 64 | nntp._getRange = function (session, first, last) { 65 | let result = messages 66 | .filter(m => m.group === session.group.name) 67 | .filter(m => m.index >= first && m.index <= last); 68 | 69 | return Readable.from(result); 70 | }; 71 | 72 | nntp._getNewNews = function (session, ts, wildmat) { 73 | let result = messages 74 | .filter(msg => !ts || msg.ts > ts) 75 | .filter(msg => (wildmat ? wildmat.test(msg.group) : true)); 76 | 77 | return Readable.from(result); 78 | }; 79 | 80 | nntp._buildHead = function (session, msg) { return msg.head; }; 81 | 82 | nntp._buildBody = function (session, msg) { return msg.body; }; 83 | 84 | nntp._buildHeaderField = function (session, msg, field) { 85 | switch (field) { 86 | case ':lines': 87 | return msg.body.split('\n').length.toString(); 88 | 89 | case ':bytes': 90 | return Buffer.byteLength(msg.body).toString(); 91 | 92 | case 'message-id': 93 | return '<' + msg.id + '>'; 94 | 95 | default: 96 | let match = msg.head 97 | .split('\n') 98 | .map(str => str.match(/^(.*?):\s*(.*)$/)) 99 | .filter(m => m && m[1].toLowerCase() === field); 100 | 101 | return match.length ? match[0][2] : null; 102 | } 103 | }; 104 | 105 | nntp._authenticate = function (session) { 106 | return Promise.resolve( 107 | session.authinfo_user === 'foo' && 108 | session.authinfo_pass === 'bar' 109 | ); 110 | }; 111 | 112 | nntp._onError = function (err) { 113 | // make tests fail; we can't just throw error because node.js 114 | // does not yet abort on unhandled exceptions 115 | /* eslint-disable no-console */ 116 | console.log(err); 117 | process.exit(1); 118 | }; 119 | 120 | return nntp; 121 | }; 122 | -------------------------------------------------------------------------------- /test/pipeline.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | 5 | const assert = require('assert'); 6 | const net = require('net'); 7 | const stream = require('stream'); 8 | const asline = require('./helpers').asline; 9 | const Server = require('..'); 10 | 11 | 12 | describe('pipeline', function () { 13 | let port, socket, client, nntp; 14 | 15 | before(function () { 16 | // Disable default channel encryption check 17 | nntp = new Server({ secure: true }); 18 | 19 | // listen on random port 20 | return nntp.listen('nntp://localhost:0').then(() => { 21 | port = nntp.server.address().port; 22 | }); 23 | }); 24 | 25 | 26 | after(function () { 27 | return nntp.close(); 28 | }); 29 | 30 | 31 | function not_implemented() { throw new Error('not implemented'); } 32 | function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } 33 | 34 | function throw_err(err) { 35 | // make tests fail; we can't just throw error because node.js 36 | // does not yet abort on unhandled exceptions 37 | /* eslint-disable no-console */ 38 | console.log(err); 39 | process.exit(1); 40 | } 41 | 42 | beforeEach(function (callback) { 43 | nntp._getGroups = not_implemented; 44 | nntp._selectGroup = not_implemented; 45 | nntp._getArticle = not_implemented; 46 | nntp._getRange = not_implemented; 47 | nntp._getNewNews = not_implemented; 48 | nntp._buildHead = not_implemented; 49 | nntp._buildBody = not_implemented; 50 | nntp._buildHeaderField = not_implemented; 51 | nntp._authenticate = not_implemented; 52 | nntp._onError = throw_err; 53 | 54 | socket = net.connect(port, err => { 55 | client = asline(socket, { timeout: 2000 }).expect(/^201/); 56 | callback(err); 57 | }); 58 | }); 59 | 60 | 61 | afterEach(function () { 62 | return client.end(); 63 | }); 64 | 65 | 66 | it('should execute commands sequentially', function () { 67 | let log = []; 68 | 69 | nntp._selectGroup = async function (session, name) { 70 | let result; 71 | log.push('start ' + name); 72 | 73 | switch (name) { 74 | case 'a': 75 | await delay(10); 76 | session.group = { name: 'a', min_index: 1, max_index: 2, total: 2 }; 77 | result = true; 78 | break; 79 | 80 | case 'b': 81 | await delay(10); 82 | result = false; 83 | break; 84 | 85 | case 'c': 86 | await delay(10); 87 | session.group = { name: 'c', min_index: 1, max_index: 2, total: 2 }; 88 | result = true; 89 | break; 90 | } 91 | 92 | log.push('stop ' + name); 93 | return result; 94 | }; 95 | 96 | return client 97 | .send('GROUP a\r\nGROUP b\r\nGROUP c') 98 | .expect(/^211 .*? a$/) 99 | .expect(/^411 /) 100 | .expect(/^211 .*? c$/) 101 | .then(() => { 102 | assert.deepStrictEqual(log, [ 'start a', 'stop a', 'start b', 'stop b', 'start c', 'stop c' ]); 103 | }); 104 | }); 105 | 106 | 107 | it('should stop executing commands when connection is closed', function () { 108 | let log = []; 109 | let close_fn; 110 | let on_close = new Promise(resolve => { close_fn = resolve; }); 111 | 112 | nntp._selectGroup = async function (session, name) { 113 | let result; 114 | log.push('start ' + name); 115 | 116 | switch (name) { 117 | case 'a': 118 | session.group = { name: 'a', min_index: 1, max_index: 2, total: 2 }; 119 | result = true; 120 | break; 121 | 122 | case 'b': 123 | await on_close; 124 | result = false; 125 | break; 126 | 127 | case 'c': 128 | throw new Error('should never be called'); 129 | } 130 | 131 | log.push('stop ' + name); 132 | return result; 133 | }; 134 | 135 | return client 136 | .send('GROUP a\r\nGROUP b\r\nGROUP c') 137 | .expect(/^211 .*? a$/) 138 | .then(() => { assert.deepStrictEqual(log, [ 'start a', 'stop a', 'start b' ]); }) 139 | .end() 140 | .then(() => delay(10)) 141 | .then(() => { close_fn(); }) 142 | .then(() => delay(10)) 143 | .then(() => { assert.deepStrictEqual(log, [ 'start a', 'stop a', 'start b', 'stop b' ]); }); 144 | }); 145 | 146 | 147 | it('should report error when command fails', function () { 148 | let error; 149 | 150 | nntp._onError = function (err) { 151 | if (error) throw new Error('onError called twice'); 152 | error = err; 153 | }; 154 | 155 | nntp._selectGroup = async function () { 156 | let err = new Error('a bug is here'); 157 | err.code = 'ETEST'; 158 | throw err; 159 | }; 160 | 161 | return client 162 | .send('GROUP foobar') 163 | .expect(/^403 /) 164 | .then(() => { 165 | assert.strictEqual(error.code, 'ETEST'); 166 | assert.strictEqual(error.nntp_command, 'GROUP foobar'); 167 | }); 168 | }); 169 | 170 | 171 | it('should handle errors from streams', function () { 172 | let error; 173 | 174 | nntp._onError = function (err) { 175 | if (error) throw new Error('onError called twice'); 176 | error = err; 177 | }; 178 | 179 | nntp._getGroups = async function () { 180 | return new stream.Readable({ 181 | read() { 182 | let err = new Error('Test stream error'); 183 | err.code = 'ETEST'; 184 | this.emit('error', err); 185 | }, 186 | objectMode: true 187 | }); 188 | }; 189 | 190 | let wait_for_close = new Promise(resolve => { 191 | socket.on('close', resolve); 192 | }); 193 | 194 | return client 195 | .send('LIST') 196 | .then(() => wait_for_close) 197 | .expect(/^215 /) 198 | .end(); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /test/security.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const fs = require('fs'); 5 | const net = require('net'); 6 | const join = require('path').join; 7 | const tls = require('tls'); 8 | const asline = require('./helpers').asline; 9 | const Server = require('..'); 10 | 11 | 12 | describe('security', function () { 13 | let port_plain, client_plain, socket_plain, nntp_plain; 14 | let port_tls, client_tls, socket_tls, nntp_tls; 15 | 16 | before(function () { 17 | nntp_plain = new Server({ secure: false }); 18 | 19 | // listen on random port 20 | return nntp_plain.listen('nntp://localhost:0').then(() => { 21 | port_plain = nntp_plain.server.address().port; 22 | }); 23 | }); 24 | 25 | 26 | after(function () { 27 | return nntp_plain.close(); 28 | }); 29 | 30 | 31 | beforeEach(function (callback) { 32 | socket_plain = net.connect(port_plain, err => { 33 | client_plain = asline(socket_plain, { timeout: 2000 }).expect(/^201/); 34 | callback(err); 35 | }); 36 | }); 37 | 38 | 39 | afterEach(function () { 40 | return client_plain.end(); 41 | }); 42 | 43 | 44 | before(function () { 45 | nntp_tls = new Server({ 46 | secure: true, 47 | tls: { 48 | key: fs.readFileSync(join(__dirname, 'fixtures', 'server-key.pem')), 49 | cert: fs.readFileSync(join(__dirname, 'fixtures', 'server-cert.pem')) 50 | } 51 | }); 52 | 53 | // listen on random port 54 | return nntp_tls.listen('nntps://localhost:0').then(() => { 55 | port_tls = nntp_tls.server.address().port; 56 | }); 57 | }); 58 | 59 | 60 | after(function () { 61 | return nntp_tls.close(); 62 | }); 63 | 64 | 65 | beforeEach(function (callback) { 66 | socket_tls = tls.connect(port_tls, { 67 | ca: [ fs.readFileSync(join(__dirname, 'fixtures', 'server-cert.pem')) ] 68 | }, err => { 69 | client_tls = asline(socket_tls, { timeout: 2000 }).expect(/^201/); 70 | callback(err); 71 | }); 72 | }); 73 | 74 | 75 | afterEach(function () { 76 | return client_tls.end(); 77 | }); 78 | 79 | 80 | it('should allow auth over secure channel only', function () { 81 | return Promise.resolve() 82 | .then(() => client_plain 83 | .send('AUTHINFO USER test') 84 | .expect(/^483 /)) 85 | .then(() => client_tls 86 | .send('AUTHINFO USER test') 87 | .expect(/^381 /)); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/wildmat.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const wildmat = require('../lib/wildmat'); 6 | 7 | 8 | describe('wildmat', function () { 9 | it('should not match empty string', function () { 10 | assert.strictEqual(wildmat('*').test(''), false); 11 | }); 12 | 13 | it('should perform exact match', function () { 14 | assert.strictEqual(wildmat('foo,!bar,baz').test('foo'), true); 15 | assert.strictEqual(wildmat('foo,!bar,baz').test('bar'), false); 16 | assert.strictEqual(wildmat('foo,!bar,baz').test('baz'), true); 17 | }); 18 | 19 | it('should return rightmost match taking into account negate', function () { 20 | assert.strictEqual(wildmat('foo,!foo,foo').test('foo'), true); 21 | assert.strictEqual(wildmat('foo,!foo,bar').test('foo'), false); 22 | assert.strictEqual(wildmat('foo,!foo,foo,!foo').test('foo'), false); 23 | }); 24 | 25 | it('should pass RFC3977 examples', function () { 26 | assert.strictEqual(wildmat('a*,!*b,*c*').test('aaa'), true); 27 | assert.strictEqual(wildmat('a*,!*b,*c*').test('abb'), false); 28 | assert.strictEqual(wildmat('a*,!*b,*c*').test('ccb'), true); 29 | assert.strictEqual(wildmat('a*,!*b,*c*').test('xxx'), false); 30 | }); 31 | 32 | it('should work with astral characters', function () { 33 | assert.strictEqual(wildmat('𐌀?𐌂').test('𐌀𐌁𐌂'), true); 34 | assert.strictEqual(wildmat('???').test('𐌀𐌁𐌂'), true); 35 | }); 36 | 37 | it('should perform wildcard match', function () { 38 | assert.strictEqual(wildmat('foo*bar').test('foobar'), true); 39 | assert.strictEqual(wildmat('foo**bar').test('foobar'), true); 40 | assert.strictEqual(wildmat('f*r').test('foobar'), true); 41 | assert.strictEqual(wildmat('*foobar*').test('foobar'), true); 42 | assert.strictEqual(wildmat('f????r').test('foobar'), true); 43 | assert.strictEqual(wildmat('*').test('foobar'), true); 44 | assert.strictEqual(wildmat('foo?bar').test('foobar'), false); 45 | assert.strictEqual(wildmat('foo??*bar').test('foobar'), false); 46 | assert.strictEqual(wildmat('?').test('foobar'), false); 47 | assert.strictEqual(wildmat('z*z').test('foobar'), false); 48 | }); 49 | 50 | it('should generate correct regexps for wildcards', function () { 51 | assert.strictEqual(wildmat('*').source, '^(.+)$'); 52 | assert.strictEqual(wildmat('?').source, '^(.)$'); 53 | assert.strictEqual(wildmat('?*').source, '^(..*)$'); 54 | assert.strictEqual(wildmat('**').source, '^(.+)$'); 55 | assert.strictEqual(wildmat('????').source, '^(....)$'); 56 | assert.strictEqual(wildmat('?*?*?').source, '^(..*..*.)$'); 57 | }); 58 | 59 | it('should throw on invalid wildmat', function () { 60 | assert.throws(() => wildmat('[foo-bar]')); 61 | assert.throws(() => { wildmat(''); }); 62 | assert.throws(() => { wildmat(','); }); 63 | assert.throws(() => { wildmat('foo,,bar'); }); 64 | assert.throws(() => { wildmat('['); }); 65 | assert.throws(() => { wildmat(']'); }); 66 | assert.throws(() => { wildmat('!'); }); 67 | assert.throws(() => { wildmat('!foo'); }); 68 | }); 69 | 70 | it('should not allow computation-heavy patterns', function () { 71 | // /.*?a.*?a.*?a.*?a.*?!/.test('a'.repeat(100)) hangs up, 72 | // so we should limit an amount of asterisks somehow 73 | // 74 | assert.throws(() => wildmat('*a*a*a*a*a*!')); 75 | assert.throws(() => wildmat('***')); 76 | }); 77 | }); 78 | --------------------------------------------------------------------------------