@tryghost/brute-knex 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +104 -0
  3. package/index.d.ts +59 -0
  4. package/index.js +188 -0
  5. package/package.json +58 -0
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Copyright (c) 2014, llambda <xxgsoftware@gmail.com>
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14
+
15
+ ~
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @tryghost/brute-knex
2
+
3
+ Knex-backed store for [express-brute](https://github.com/AdamPflug/express-brute) that persists rate-limit state in SQL databases.
4
+
5
+ [![NPM Version][npm-version-image]][npm-url]
6
+ [![NPM Downloads][npm-downloads-image]][npm-url]
7
+ [![Node.js Version][node-image]][node-url]
8
+ [![NPM][npm-image]][npm-url]
9
+
10
+ ## What It Does
11
+
12
+ `@tryghost/brute-knex` lets `express-brute` share brute-force counters through a Knex table instead of keeping them in process memory. It can create its storage table automatically, use a caller-provided Knex instance, or fall back to a local SQLite database when no Knex instance is supplied.
13
+
14
+ The package supports Node.js `>=20.20.0`. CI exercises the store against SQLite, MySQL, and Postgres, with additional MySQL coverage for existing consumers that provide Knex `0.21.6`.
15
+
16
+ ## Installation
17
+
18
+ Install the package and the Knex database driver your app uses:
19
+
20
+ ```sh
21
+ npm install @tryghost/brute-knex knex mysql2
22
+ ```
23
+
24
+ Use `pg` instead of `mysql2` for Postgres, or `sqlite3` for SQLite.
25
+
26
+ ## Usage
27
+
28
+ ```js
29
+ const ExpressBrute = require('express-brute');
30
+ const Knex = require('knex');
31
+ const BruteKnex = require('@tryghost/brute-knex');
32
+
33
+ const knex = Knex({
34
+ client: 'mysql2',
35
+ connection: {
36
+ host: '127.0.0.1',
37
+ user: 'root',
38
+ password: 'root',
39
+ database: 'brute_knex'
40
+ }
41
+ });
42
+
43
+ const store = new BruteKnex({
44
+ knex,
45
+ tablename: 'brute'
46
+ });
47
+
48
+ const bruteforce = new ExpressBrute(store, {
49
+ freeRetries: 2
50
+ });
51
+ ```
52
+
53
+ See [example.js](example.js) for a complete Express route.
54
+
55
+ ## Options
56
+
57
+ - `tablename`: table name for brute-force records. Defaults to `brute`.
58
+ - `knex`: Knex instance to use. If omitted, `brute-knex` creates a SQLite database at `./brute-knex.sqlite`.
59
+ - `createTable`: set to `false` when the table already exists and should not be created automatically.
60
+
61
+ The table stores `key`, `firstRequest`, `lastRequest`, `lifetime`, and `count` columns. Timestamps are stored as UTC millisecond values.
62
+
63
+ ## Development
64
+
65
+ This repo uses the organisation default Node.js `22` for local development through `.nvmrc`, and pnpm `10.x` through Corepack. Package support still starts at Node.js `20.20.0` for existing consumers.
66
+
67
+ ```sh
68
+ corepack enable
69
+ pnpm install --frozen-lockfile
70
+ pnpm test
71
+ pnpm lint
72
+ ```
73
+
74
+ Database-specific test commands:
75
+
76
+ ```sh
77
+ pnpm run test:e2e:sqlite
78
+ pnpm run test:e2e:mysql
79
+ pnpm run test:e2e:postgres
80
+ ```
81
+
82
+ The MySQL and Postgres commands expect local test databases unless they are running inside the GitHub Actions service containers. The test suite reads standard connection env vars such as `MYSQL_HOST`, `MYSQL_DATABASE`, `POSTGRES_HOST`, and `POSTGRES_DB`.
83
+
84
+ ## Releasing
85
+
86
+ Releases are handled by [`@tryghost/pro-ship`](https://www.npmjs.com/package/@tryghost/pro-ship):
87
+
88
+ ```sh
89
+ pnpm ship
90
+ ```
91
+
92
+ The `ship` script bumps the version, creates the release commit and tag, pushes them, and publishes `@tryghost/brute-knex` to npm.
93
+
94
+ ## Copyright & License
95
+
96
+ Copyright (c) 2014, llambda <xxgsoftware@gmail.com>.
97
+ Released under the [ISC license](LICENSE).
98
+
99
+ [npm-version-image]: https://img.shields.io/npm/v/@tryghost/brute-knex.svg
100
+ [npm-downloads-image]: https://img.shields.io/npm/dm/@tryghost/brute-knex.svg
101
+ [npm-image]: https://nodei.co/npm/@tryghost/brute-knex.png?downloads=true&downloadRank=true&stars=true
102
+ [npm-url]: https://npmjs.org/package/@tryghost/brute-knex
103
+ [node-image]: https://img.shields.io/node/v/@tryghost/brute-knex.svg
104
+ [node-url]: https://nodejs.org/download/
package/index.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { Knex } from 'knex';
2
+
3
+ declare namespace KnexStore {
4
+ type TimestampInput = Date | number | string;
5
+
6
+ interface StoredValueInput {
7
+ count: number;
8
+ firstRequest: TimestampInput;
9
+ lastRequest: TimestampInput;
10
+ }
11
+
12
+ interface StoredValue {
13
+ count: number;
14
+ firstRequest: Date;
15
+ lastRequest: Date;
16
+ }
17
+
18
+ interface Options {
19
+ createTable?: boolean;
20
+ knex?: Knex;
21
+ tablename?: string;
22
+ }
23
+
24
+ interface Defaults {
25
+ createTable: boolean;
26
+ tablename: string;
27
+ }
28
+
29
+ type ResultCallback<TResult = unknown> = (error: Error | null, result?: TResult) => void;
30
+ type GetCallback = (error: Error | null, value?: StoredValue | null) => void;
31
+ }
32
+
33
+ declare class KnexStore {
34
+ static defaults: KnexStore.Defaults;
35
+ static defaultsKnex: Knex.Config;
36
+
37
+ constructor(options?: KnexStore.Options);
38
+
39
+ knex: Knex;
40
+ options: KnexStore.Options & KnexStore.Defaults;
41
+ ready: Promise<void>;
42
+
43
+ set(
44
+ key: string,
45
+ value: KnexStore.StoredValueInput,
46
+ lifetime: number,
47
+ callback?: KnexStore.ResultCallback,
48
+ ): Promise<unknown>;
49
+
50
+ get(key: string, callback?: KnexStore.GetCallback): Promise<KnexStore.StoredValue | null>;
51
+
52
+ reset(key: string, callback?: KnexStore.ResultCallback): Promise<unknown>;
53
+
54
+ increment(key: string, lifetime: number, callback?: KnexStore.ResultCallback): Promise<unknown>;
55
+
56
+ clearExpired(callback?: KnexStore.ResultCallback): Promise<unknown>;
57
+ }
58
+
59
+ export = KnexStore;
package/index.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ var AbstractClientStore = require('express-brute/lib/AbstractClientStore');
3
+ var _ = require('lodash');
4
+
5
+ function rethrowAsync(error) {
6
+ setTimeout(function () {
7
+ throw error;
8
+ }, 0);
9
+ }
10
+
11
+ function withCallback(promise, callback) {
12
+ promise = Promise.resolve(promise);
13
+
14
+ if (typeof callback === 'function') {
15
+ var callbackPromise = promise.then(
16
+ function (result) {
17
+ callback(null, result);
18
+ },
19
+ function (error) {
20
+ callback(error);
21
+ },
22
+ );
23
+
24
+ callbackPromise.catch(rethrowAsync);
25
+ }
26
+
27
+ return promise;
28
+ }
29
+
30
+ /**
31
+ * we are using bigInteger to store a UTC timestamp
32
+ * alternative would be using moment-timezone to store YYYY-MM-DD HH:mm:ss (but it does not contain ms)
33
+ *
34
+ * @type {module.exports}
35
+ */
36
+ var KnexStore = (module.exports = function (options) {
37
+ var self = this;
38
+
39
+ options = options || {};
40
+
41
+ AbstractClientStore.apply(this, arguments);
42
+ this.options = _.extend({}, KnexStore.defaults, options);
43
+
44
+ if (this.options.knex) {
45
+ this.knex = this.options.knex;
46
+ } else {
47
+ this.knex = require('knex')(KnexStore.defaultsKnex);
48
+ }
49
+
50
+ if (options.createTable === false) {
51
+ self.ready = Promise.resolve();
52
+ } else {
53
+ self.ready = self.knex.schema.hasTable(self.options.tablename).then(function (exists) {
54
+ if (!exists) {
55
+ return self.knex.schema.createTable(self.options.tablename, function (table) {
56
+ table.string('key');
57
+ table.bigInteger('firstRequest').nullable();
58
+ table.bigInteger('lastRequest').nullable();
59
+ table.bigInteger('lifetime').nullable();
60
+ table.integer('count');
61
+ });
62
+ }
63
+ });
64
+ }
65
+
66
+ self.ready = Promise.resolve(self.ready);
67
+ });
68
+ KnexStore.prototype = Object.create(AbstractClientStore.prototype);
69
+
70
+ KnexStore.prototype.set = function (key, value, lifetime, callback) {
71
+ var self = this;
72
+ lifetime = lifetime || 0;
73
+
74
+ return withCallback(
75
+ self.ready.then(function () {
76
+ return self.knex.transaction(function (trx) {
77
+ return trx
78
+ .select('*')
79
+ .forUpdate()
80
+ .from(self.options.tablename)
81
+ .where('key', '=', key)
82
+ .then(function (foundKeys) {
83
+ if (foundKeys.length == 0) {
84
+ return trx.from(self.options.tablename).insert({
85
+ key: key,
86
+ lifetime: new Date(Date.now() + lifetime * 1000).getTime(),
87
+ lastRequest: new Date(value.lastRequest).getTime(),
88
+ firstRequest: new Date(value.firstRequest).getTime(),
89
+ count: value.count,
90
+ });
91
+ } else {
92
+ return trx(self.options.tablename)
93
+ .where('key', '=', key)
94
+ .update({
95
+ lifetime: new Date(Date.now() + lifetime * 1000).getTime(),
96
+ count: value.count,
97
+ lastRequest: new Date(value.lastRequest).getTime(),
98
+ });
99
+ }
100
+ });
101
+ });
102
+ }),
103
+ callback,
104
+ );
105
+ };
106
+
107
+ KnexStore.prototype.get = function (key, callback) {
108
+ var self = this;
109
+ return withCallback(
110
+ self.ready
111
+ .then(function () {
112
+ return self.clearExpired();
113
+ })
114
+ .then(function () {
115
+ return self.knex.select('*').from(self.options.tablename).where('key', '=', key);
116
+ })
117
+ .then(function (response) {
118
+ var o = null;
119
+ if (response[0]) {
120
+ o = {};
121
+ o.lastRequest = new Date(response[0].lastRequest);
122
+ o.firstRequest = new Date(response[0].firstRequest);
123
+ o.count = response[0].count;
124
+ }
125
+ return o;
126
+ }),
127
+ callback,
128
+ );
129
+ };
130
+ KnexStore.prototype.reset = function (key, callback) {
131
+ var self = this;
132
+ return withCallback(
133
+ self.ready.then(function () {
134
+ return self.knex(self.options.tablename).where('key', '=', key).del();
135
+ }),
136
+ callback,
137
+ );
138
+ };
139
+
140
+ KnexStore.prototype.increment = function (key, lifetime, callback) {
141
+ var self = this;
142
+ return withCallback(
143
+ self.get(key).then(function (result) {
144
+ if (result) {
145
+ return self
146
+ .knex(self.options.tablename)
147
+ .increment('count', 1)
148
+ .where('key', '=', key);
149
+ } else {
150
+ return self.knex(self.options.tablename).insert({
151
+ key: key,
152
+ firstRequest: new Date().getTime(),
153
+ lastRequest: new Date().getTime(),
154
+ lifetime: new Date(Date.now() + lifetime * 1000).getTime(),
155
+ count: 1,
156
+ });
157
+ }
158
+ }),
159
+ callback,
160
+ );
161
+ };
162
+
163
+ KnexStore.prototype.clearExpired = function (callback) {
164
+ var self = this;
165
+ return withCallback(
166
+ self.ready.then(function () {
167
+ return self
168
+ .knex(self.options.tablename)
169
+ .del()
170
+ .where('lifetime', '<', new Date().getTime());
171
+ }),
172
+ callback,
173
+ );
174
+ };
175
+
176
+ KnexStore.defaults = {
177
+ tablename: 'brute',
178
+ createTable: true,
179
+ };
180
+
181
+ KnexStore.defaultsKnex = {
182
+ client: 'sqlite3',
183
+ // debug: true,
184
+ connection: {
185
+ filename: './brute-knex.sqlite',
186
+ },
187
+ useNullAsDefault: true,
188
+ };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@tryghost/brute-knex",
3
+ "version": "3.0.0",
4
+ "description": "A Knex.js store for express-brute",
5
+ "keywords": [
6
+ "express",
7
+ "brute",
8
+ "knex"
9
+ ],
10
+ "license": "ISC",
11
+ "private": false,
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "main": "index.js",
16
+ "types": "index.d.ts",
17
+ "files": [
18
+ "index.js",
19
+ "index.d.ts",
20
+ "LICENSE",
21
+ "README.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20.20.0"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git@github.com:TryGhost/brute-knex.git"
29
+ },
30
+ "devDependencies": {
31
+ "@tryghost/pro-ship": "1.1.4",
32
+ "@vitest/coverage-v8": "4.1.9",
33
+ "mysql2": "3.22.5",
34
+ "oxfmt": "0.56.0",
35
+ "oxlint": "1.71.0",
36
+ "pg": "8.22.0",
37
+ "sqlite3": "6.0.1",
38
+ "typescript": "6.0.3",
39
+ "vitest": "4.1.9"
40
+ },
41
+ "dependencies": {
42
+ "express-brute": "1.0.1",
43
+ "knex": "2.5.1",
44
+ "lodash": "4.18.1"
45
+ },
46
+ "scripts": {
47
+ "format": "oxfmt .",
48
+ "lint": "oxlint --quiet && oxfmt --check . && pnpm run test:types",
49
+ "lint:fix": "oxlint --fix && oxfmt .",
50
+ "preship": "pnpm lint && pnpm test",
51
+ "ship": "pro-ship --publish",
52
+ "test": "vitest run --coverage",
53
+ "test:e2e:sqlite": "BRUTE_KNEX_DIALECTS=sqlite vitest run --coverage",
54
+ "test:e2e:mysql": "BRUTE_KNEX_DIALECTS=mysql vitest run --coverage",
55
+ "test:e2e:postgres": "BRUTE_KNEX_DIALECTS=postgres vitest run --coverage",
56
+ "test:types": "tsc --noEmit -p tsconfig.types.json"
57
+ }
58
+ }