@stamhoofd/redirecter 2.119.0 → 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 +9 -7
- package/src/boot.ts +10 -6
- package/src/classes/Geolocator.test.ts +34 -0
- package/src/classes/Geolocator.ts +175 -37
- package/src/data/belgium.csv +1316 -225
- package/src/data/netherlands.csv +8202 -979
- package/src/endpoints/RedirectEndpoint.ts +4 -16
- package/{index.ts → src/index.ts} +1 -1
- package/stamhoofd.d.ts +2 -0
- package/tests/vitest.global.setup.ts +20 -0
- package/tests/vitest.setup.ts +30 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +9 -33
- package/tsconfig.test.json +17 -0
- package/vitest.config.js +13 -0
- package/eslint.config.mjs +0 -5
package/package.json
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/redirecter",
|
|
3
|
-
"version": "2.
|
|
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": "
|
|
9
|
-
"copy-assets": "rsync --delete --mkpath --exclude='*.ts' --exclude='*.js' -r --checksum ./src/ ./dist/
|
|
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
|
+
"@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": "
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
29
|
-
await router.loadAllEndpoints(
|
|
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/
|
|
1
|
+
import type { Country } from '@stamhoofd/types/Country';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import readline from 'readline';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
16
|
+
throw new Error(`Invalid IP address: ${ip}`);
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
27
|
-
return this.start
|
|
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
|
|
42
|
-
this.ranges.set(country,
|
|
189
|
+
const ranges = this.ranges.get(country) ?? [];
|
|
190
|
+
this.ranges.set(country, ranges);
|
|
43
191
|
|
|
44
|
-
for await (const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|