@stamhoofd/redirecter 2.118.1 → 2.120.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.
package/package.json CHANGED
@@ -1,25 +1,27 @@
1
1
  {
2
2
  "name": "@stamhoofd/redirecter",
3
- "version": "2.118.1",
3
+ "version": "2.120.0",
4
+ "type": "module",
4
5
  "main": "index.ts",
5
6
  "license": "UNLICENCED",
6
7
  "scripts": {
7
8
  "dev:full": "wait-on ../../shared/middleware/dist/index.js && concurrently -r 'yarn -s build --watch --preserveWatchOutput' \"wait-on ./dist/src/data/belgium.csv && nodemon --quiet --inspect=5858 --watch dist --watch '../../../shared/*/dist/' --watch '../../shared/*/dist/' --ext .ts,.json,.sql,.js --watch .env.json --delay 1000ms --exec 'node --enable-source-maps ./dist/index.js' --signal SIGTERM\"",
8
- "build": "rm -rf ./dist/src/migrations && rm -rf ./dist/src/seeds && yarn -s copy-assets && tsc -b",
9
- "copy-assets": "rsync --delete --mkpath --exclude='*.ts' --exclude='*.js' -r --checksum ./src/ ./dist/src/",
10
- "build:full": "rm -rf ./dist && yarn build",
9
+ "build": "tsc --build tsconfig.build.json && yarn -s copy-assets",
10
+ "copy-assets": "rsync --delete --mkpath --exclude='*.ts' --exclude='*.js' --exclude='*.map' -r --checksum ./src/ ./dist/",
11
+ "build:full": "rm -rf ./dist && rm -f *.tsbuildinfo && yarn -s build",
11
12
  "start": "yarn build && node ./dist/index.js",
12
- "lint": "eslint"
13
+ "lint": "eslint",
14
+ "test": "vitest"
13
15
  },
14
16
  "devDependencies": {
15
17
  "@types/node": "^22"
16
18
  },
17
19
  "dependencies": {
18
- "@simonbackx/simple-endpoints": "1.20.1",
20
+ "@simonbackx/simple-endpoints": "1.21.0",
19
21
  "@simonbackx/simple-logging": "^1.0.1"
20
22
  },
21
23
  "publishConfig": {
22
24
  "access": "public"
23
25
  },
24
- "gitHead": "7461e7ca17f68233be8a8acf1943f2d7882244fd"
26
+ "gitHead": "f38f79c15ce16b0c8c14743ff3eb61feda5a18d4"
25
27
  }
package/src/boot.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Router, RouterServer } from '@simonbackx/simple-endpoints';
2
- import { Country } from '@stamhoofd/structures';
3
-
2
+ import { LogMiddleware } from '@stamhoofd/backend-middleware';
3
+ import { Country } from '@stamhoofd/types/Country';
4
4
  import { Geolocator } from './classes/Geolocator.js';
5
5
 
6
6
  process.on('unhandledRejection', (error: Error) => {
@@ -18,20 +18,24 @@ if (new Date().getTimezoneOffset() !== 0) {
18
18
  }
19
19
 
20
20
  const start = async () => {
21
- await Geolocator.shared.load(__dirname + '/data/belgium.csv', Country.Belgium);
21
+ await Geolocator.shared.load(import.meta.dirname + '/data/belgium.csv', Country.Belgium);
22
22
 
23
23
  // Netherlands not needed, because it is the current default
24
- // await Geolocator.shared.load(__dirname+"/data/netherlands.csv", Country.Netherlands)
24
+ // await Geolocator.shared.load(import.meta.dirname+"/data/netherlands.csv", Country.Netherlands)
25
25
 
26
26
  console.log('Initialising server...');
27
27
  const router = new Router();
28
- await router.loadAllEndpoints(__dirname + '/endpoints');
29
- await router.loadAllEndpoints(__dirname + '/endpoints/*');
28
+ await router.loadAllEndpoints(import.meta.dirname + '/endpoints');
29
+ await router.loadAllEndpoints(import.meta.dirname + '/endpoints/*');
30
30
 
31
31
  const routerServer = new RouterServer(router);
32
32
  routerServer.verbose = false;
33
33
  routerServer.listen(STAMHOOFD.PORT ?? 9090);
34
34
 
35
+ // Send the app version along
36
+ routerServer.addRequestMiddleware(LogMiddleware);
37
+ routerServer.addResponseMiddleware(LogMiddleware);
38
+
35
39
  const shutdown = async () => {
36
40
  console.log('Shutting down...');
37
41
  // Disable keep alive
@@ -0,0 +1,34 @@
1
+ import { Country } from '@stamhoofd/types/Country';
2
+ import { Geolocator } from './Geolocator.js';
3
+
4
+ describe('Geolocator', () => {
5
+ beforeAll(async () => {
6
+ await Geolocator.shared.load(import.meta.dirname + '/../data/belgium.csv', Country.Belgium);
7
+ await Geolocator.shared.load(import.meta.dirname + '/../data/netherlands.csv', Country.Netherlands);
8
+ });
9
+
10
+ test('Returns right', async () => {
11
+ const country = Geolocator.shared.getCountry('185.115.216.1');
12
+ expect(country).toEqual(Country.Belgium)
13
+ });
14
+
15
+ test('Returns BE for range defined like - in file', async () => {
16
+ const country = Geolocator.shared.getCountry('57.233.0.1');
17
+ expect(country).toEqual(Country.Belgium)
18
+ });
19
+
20
+ test('Returns BE ipv6', async () => {
21
+ const country = Geolocator.shared.getCountry('2a02:578:04de:b562:3af1:c074:e829:6d30');
22
+ expect(country).toEqual(Country.Belgium)
23
+ });
24
+
25
+ test('Returns NL ipv6', async () => {
26
+ const country = Geolocator.shared.getCountry('2a00:8760:c819:6e52:d301:a84f:7b2e:19cd');
27
+ expect(country).toEqual(Country.Netherlands)
28
+ });
29
+
30
+ test('Returns NL ipv4', async () => {
31
+ const country = Geolocator.shared.getCountry('37.72.136.125');
32
+ expect(country).toEqual(Country.Netherlands)
33
+ });
34
+ });
@@ -1,30 +1,178 @@
1
- import { Country } from '@stamhoofd/structures';
1
+ import type { Country } from '@stamhoofd/types/Country';
2
2
  import fs from 'fs';
3
3
  import readline from 'readline';
4
4
 
5
- function fromIP(ip: string): Buffer {
6
- const splitted = ip.split('.');
7
- if (splitted.length !== 4) {
8
- throw new Error('Invalid ipv4 address ' + ip);
5
+ type IPVersion = 4 | 6;
6
+
7
+ function getIPVersion(ip: string): IPVersion {
8
+ if (ip.includes(':')) {
9
+ return 6;
10
+ }
11
+
12
+ if (ip.includes('.')) {
13
+ return 4;
9
14
  }
10
15
 
11
- return Buffer.from(splitted.map(s => parseInt(s)));
16
+ throw new Error(`Invalid IP address: ${ip}`);
12
17
  }
13
18
 
14
- class IPRange {
15
- start: Buffer;
16
- end: Buffer; // included
19
+ function ipv4ToBigInt(ip: string): bigint {
20
+ const parts = ip.split('.');
21
+
22
+ if (parts.length !== 4) {
23
+ throw new Error(`Invalid IPv4 address: ${ip}`);
24
+ }
25
+
26
+ let result = 0n;
27
+
28
+ for (const part of parts) {
29
+ const value = Number(part);
30
+
31
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
32
+ throw new Error(`Invalid IPv4 address: ${ip}`);
33
+ }
34
+
35
+ result = (result << 8n) + BigInt(value);
36
+ }
37
+
38
+ return result;
39
+ }
40
+
41
+ function ipv6ToBigInt(ip: string): bigint {
42
+ if (ip.includes(':::')) {
43
+ throw new Error(`Invalid IPv6 address: ${ip}`);
44
+ }
45
+
46
+ const [leftRaw, rightRaw] = ip.split('::');
47
+
48
+ if (ip.split('::').length > 2) {
49
+ throw new Error(`Invalid IPv6 address: ${ip}`);
50
+ }
51
+
52
+ const left = leftRaw ? leftRaw.split(':') : [];
53
+ const right = rightRaw ? rightRaw.split(':') : [];
54
+
55
+ const missing = 8 - left.length - right.length;
56
+
57
+ if (missing < 0) {
58
+ throw new Error(`Invalid IPv6 address: ${ip}`);
59
+ }
60
+
61
+ const groups: string[] = [
62
+ ...left,
63
+ ...Array(missing).fill('0'),
64
+ ...right,
65
+ ];
66
+
67
+ if (groups.length !== 8) {
68
+ throw new Error(`Invalid IPv6 address: ${ip}`);
69
+ }
70
+
71
+ let result = 0n;
72
+
73
+ for (const group of groups) {
74
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) {
75
+ throw new Error(`Invalid IPv6 address: ${ip}`);
76
+ }
77
+
78
+ result = (result << 16n) + BigInt(parseInt(group, 16));
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ function ipToBigInt(ip: string): { version: IPVersion; value: bigint } {
85
+ const version = getIPVersion(ip);
86
+
87
+ return {
88
+ version,
89
+ value: version === 4 ? ipv4ToBigInt(ip) : ipv6ToBigInt(ip),
90
+ };
91
+ }
92
+
93
+ function parseRange(range: string): {
94
+ version: IPVersion;
95
+ start: bigint;
96
+ end: bigint;
97
+ } {
98
+ const trimmed = range.trim();
99
+
100
+ if (trimmed.includes('/')) {
101
+ return parseCIDR(trimmed);
102
+ }
103
+
104
+ const [startRaw, endRaw] = trimmed.split('-');
105
+
106
+ if (!startRaw || !endRaw || trimmed.split('-').length !== 2) {
107
+ throw new Error(`Invalid IP range: ${range}`);
108
+ }
109
+
110
+ const start = ipToBigInt(startRaw.trim());
111
+ const end = ipToBigInt(endRaw.trim());
112
+
113
+ if (start.version !== end.version) {
114
+ throw new Error(`Invalid mixed IP range: ${range}`);
115
+ }
116
+
117
+ if (start.value > end.value) {
118
+ throw new Error(`Invalid reversed IP range: ${range}`);
119
+ }
120
+
121
+ return {
122
+ version: start.version,
123
+ start: start.value,
124
+ end: end.value,
125
+ };
126
+ }
127
+
128
+ function parseCIDR(cidr: string): {
129
+ version: IPVersion;
130
+ start: bigint;
131
+ end: bigint;
132
+ } {
133
+ const [ip, prefixRaw] = cidr.trim().split('/');
134
+
135
+ if (!ip || prefixRaw === undefined) {
136
+ throw new Error(`Invalid CIDR range: ${cidr}`);
137
+ }
138
+
139
+ const { version, value } = ipToBigInt(ip);
140
+ const bits = version === 4 ? 32 : 128;
141
+ const prefix = Number(prefixRaw);
17
142
 
143
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits) {
144
+ throw new Error(`Invalid CIDR prefix: ${cidr}`);
145
+ }
146
+
147
+ const hostBits = BigInt(bits - prefix);
148
+ const size = 1n << hostBits;
149
+ const start = (value / size) * size;
150
+ const end = start + size - 1n;
151
+
152
+ return {
153
+ version,
154
+ start,
155
+ end,
156
+ };
157
+ }
158
+
159
+ class IPRange {
160
+ version: IPVersion;
161
+ start: bigint;
162
+ end: bigint;
18
163
  carrier: string;
19
164
 
20
- constructor(start: string, end: string, carrier: string) {
21
- this.start = fromIP(start);
22
- this.end = fromIP(end);
165
+ constructor(cidr: string, carrier = '') {
166
+ const parsed = parseRange(cidr);
167
+
168
+ this.version = parsed.version;
169
+ this.start = parsed.start;
170
+ this.end = parsed.end;
23
171
  this.carrier = carrier;
24
172
  }
25
173
 
26
- includes(ip: Buffer): boolean {
27
- return this.start.compare(ip) <= 0 && this.end.compare(ip) >= 0;
174
+ includes(version: IPVersion, ip: bigint): boolean {
175
+ return this.version === version && this.start <= ip && ip <= this.end;
28
176
  }
29
177
  }
30
178
 
@@ -38,43 +186,33 @@ export class Geolocator {
38
186
  input: fs.createReadStream(file, { encoding: 'utf-8' }),
39
187
  });
40
188
 
41
- const range = this.ranges.get(country) ?? [];
42
- this.ranges.set(country, range);
189
+ const ranges = this.ranges.get(country) ?? [];
190
+ this.ranges.set(country, ranges);
43
191
 
44
- for await (const line of lineReader) {
45
- const splitted = line.split(',');
46
- if (splitted.length < 2) {
47
- console.error(`Failed to parse line: ${line}`);
192
+ for await (const rawLine of lineReader) {
193
+ const line = rawLine.trim();
194
+
195
+ if (!line || line.startsWith('#')) {
48
196
  continue;
49
197
  }
50
- const from = splitted[0];
51
- const to = splitted[1];
52
198
 
53
- range.push(new IPRange(from, to, splitted[4] ?? ''));
199
+ try {
200
+ ranges.push(new IPRange(line));
201
+ } catch (error) {
202
+ console.error(`Failed to parse line: ${line}`, error);
203
+ }
54
204
  }
55
205
  }
56
206
 
57
207
  getCountry(ip: string): Country | undefined {
58
- const parsed = fromIP(ip);
208
+ const parsed = ipToBigInt(ip);
59
209
 
60
210
  for (const [country, ranges] of this.ranges) {
61
211
  for (const range of ranges) {
62
- if (range.includes(parsed)) {
212
+ if (range.includes(parsed.version, parsed.value)) {
63
213
  return country;
64
214
  }
65
215
  }
66
216
  }
67
217
  }
68
-
69
- getInfo(ip: string): { country: Country; carrier: string } | undefined {
70
- const parsed = fromIP(ip);
71
-
72
- for (const [country, ranges] of this.ranges) {
73
- for (const range of ranges) {
74
- if (range.includes(parsed)) {
75
- return { country, carrier: range.carrier };
76
- }
77
- }
78
- }
79
- }
80
218
  }