@technomoron/api-server-base 1.1.2 → 1.1.4
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/README.txt +3 -1
- package/dist/cjs/api-server-base.cjs +207 -61
- package/dist/cjs/api-server-base.d.ts +14 -2
- package/dist/cjs/auth-storage.d.ts +6 -0
- package/dist/esm/api-server-base.d.ts +14 -2
- package/dist/esm/api-server-base.js +207 -61
- package/dist/esm/auth-storage.d.ts +6 -0
- package/package.json +1 -1
package/README.txt
CHANGED
|
@@ -127,7 +127,9 @@ Request Lifecycle
|
|
|
127
127
|
|
|
128
128
|
Client IP Helpers
|
|
129
129
|
-----------------
|
|
130
|
-
|
|
130
|
+
Call `apiReq.getClientInfo()` when you need the entire client fingerprint captured during request hydration. It returns the raw user-agent string plus derived browser/OS/device labels along with the computed `ip` and `ipchain`.
|
|
131
|
+
|
|
132
|
+
Call `apiReq.getClientIp()` to obtain the most likely client address, skipping loopback entries collected from proxy headers. Use `apiReq.getClientIpChain()` when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' `req.ip`/`req.ips` and the underlying socket. Both helpers reuse the cached payload returned by `apiReq.getClientInfo()`.
|
|
131
133
|
|
|
132
134
|
Extending the Base Classes
|
|
133
135
|
--------------------------
|
|
@@ -110,6 +110,111 @@ function extractForwardedHeader(header) {
|
|
|
110
110
|
}
|
|
111
111
|
return ips;
|
|
112
112
|
}
|
|
113
|
+
function detectBrowser(userAgent) {
|
|
114
|
+
const browserMatchers = [
|
|
115
|
+
{ label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
|
|
116
|
+
{ label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
117
|
+
{ label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
118
|
+
{ label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
|
|
119
|
+
{ label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
|
|
120
|
+
{ label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
|
|
121
|
+
{ label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
|
|
122
|
+
{ label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
|
|
123
|
+
{ label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
|
|
124
|
+
{ label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
|
|
125
|
+
{ label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
|
|
126
|
+
];
|
|
127
|
+
for (const matcher of browserMatchers) {
|
|
128
|
+
const m = userAgent.match(matcher.pattern);
|
|
129
|
+
if (m) {
|
|
130
|
+
const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
|
|
131
|
+
return version ? `${matcher.label} ${version}` : matcher.label;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
function detectOs(userAgent) {
|
|
137
|
+
const osMatchers = [
|
|
138
|
+
{
|
|
139
|
+
label: 'Windows',
|
|
140
|
+
pattern: /Windows NT ([\d.]+)/i,
|
|
141
|
+
transform: (match) => `Windows ${match[1]}`
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
label: 'iOS',
|
|
145
|
+
pattern: /iPhone OS ([\d_]+)/i,
|
|
146
|
+
transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
label: 'iPadOS',
|
|
150
|
+
pattern: /iPad; CPU OS ([\d_]+)/i,
|
|
151
|
+
transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
label: 'macOS',
|
|
155
|
+
pattern: /Mac OS X ([\d_]+)/i,
|
|
156
|
+
transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
label: 'Android',
|
|
160
|
+
pattern: /Android ([\d.]+)/i,
|
|
161
|
+
transform: (match) => `Android ${match[1]}`
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
label: 'ChromeOS',
|
|
165
|
+
pattern: /CrOS [^ ]+ ([\d.]+)/i,
|
|
166
|
+
transform: (match) => `ChromeOS ${match[1]}`
|
|
167
|
+
},
|
|
168
|
+
{ label: 'Linux', pattern: /Linux/i },
|
|
169
|
+
{ label: 'Unix', pattern: /X11/i }
|
|
170
|
+
];
|
|
171
|
+
for (const matcher of osMatchers) {
|
|
172
|
+
const m = userAgent.match(matcher.pattern);
|
|
173
|
+
if (m) {
|
|
174
|
+
return matcher.transform ? matcher.transform(m) : matcher.label;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
function detectDevice(userAgent, osLabel) {
|
|
180
|
+
if (/iPhone/i.test(userAgent)) {
|
|
181
|
+
return 'iPhone';
|
|
182
|
+
}
|
|
183
|
+
if (/iPad/i.test(userAgent)) {
|
|
184
|
+
return 'iPad';
|
|
185
|
+
}
|
|
186
|
+
if (/iPod/i.test(userAgent)) {
|
|
187
|
+
return 'iPod';
|
|
188
|
+
}
|
|
189
|
+
if (/Android/i.test(userAgent)) {
|
|
190
|
+
const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
|
|
191
|
+
if (match) {
|
|
192
|
+
return match[1];
|
|
193
|
+
}
|
|
194
|
+
return 'Android Device';
|
|
195
|
+
}
|
|
196
|
+
if (/Macintosh/i.test(userAgent)) {
|
|
197
|
+
return 'Mac';
|
|
198
|
+
}
|
|
199
|
+
if (/Windows/i.test(userAgent)) {
|
|
200
|
+
return 'PC';
|
|
201
|
+
}
|
|
202
|
+
if (/CrOS/i.test(userAgent)) {
|
|
203
|
+
return 'Chromebook';
|
|
204
|
+
}
|
|
205
|
+
return osLabel;
|
|
206
|
+
}
|
|
207
|
+
function parseClientAgent(userAgentHeader) {
|
|
208
|
+
const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
|
|
209
|
+
const ua = typeof raw === 'string' ? raw.trim() : '';
|
|
210
|
+
if (!ua) {
|
|
211
|
+
return { ua: '', browser: '', os: '', device: '' };
|
|
212
|
+
}
|
|
213
|
+
const os = detectOs(ua);
|
|
214
|
+
const browser = detectBrowser(ua);
|
|
215
|
+
const device = detectDevice(ua, os);
|
|
216
|
+
return { ua, browser, os, device };
|
|
217
|
+
}
|
|
113
218
|
function isLoopbackAddress(ip) {
|
|
114
219
|
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
|
|
115
220
|
return true;
|
|
@@ -122,6 +227,71 @@ function isLoopbackAddress(ip) {
|
|
|
122
227
|
}
|
|
123
228
|
return false;
|
|
124
229
|
}
|
|
230
|
+
function collectClientIpChain(req) {
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
const result = [];
|
|
233
|
+
const pushNormalized = (ip) => {
|
|
234
|
+
if (!ip || seen.has(ip)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
seen.add(ip);
|
|
238
|
+
result.push(ip);
|
|
239
|
+
};
|
|
240
|
+
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
241
|
+
pushNormalized(ip);
|
|
242
|
+
}
|
|
243
|
+
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
244
|
+
pushNormalized(ip);
|
|
245
|
+
}
|
|
246
|
+
const realIp = req.headers['x-real-ip'];
|
|
247
|
+
if (Array.isArray(realIp)) {
|
|
248
|
+
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
249
|
+
}
|
|
250
|
+
else if (typeof realIp === 'string') {
|
|
251
|
+
pushNormalized(normalizeIpAddress(realIp));
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(req.ips)) {
|
|
254
|
+
for (const ip of req.ips) {
|
|
255
|
+
pushNormalized(normalizeIpAddress(ip));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (typeof req.ip === 'string') {
|
|
259
|
+
pushNormalized(normalizeIpAddress(req.ip));
|
|
260
|
+
}
|
|
261
|
+
const socketAddress = req.socket?.remoteAddress;
|
|
262
|
+
if (typeof socketAddress === 'string') {
|
|
263
|
+
pushNormalized(normalizeIpAddress(socketAddress));
|
|
264
|
+
}
|
|
265
|
+
const connectionAddress = req.connection?.remoteAddress;
|
|
266
|
+
if (typeof connectionAddress === 'string') {
|
|
267
|
+
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
function selectClientIp(chain) {
|
|
272
|
+
for (const ip of chain) {
|
|
273
|
+
if (!isLoopbackAddress(ip)) {
|
|
274
|
+
return ip;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return chain[0] ?? null;
|
|
278
|
+
}
|
|
279
|
+
function buildClientInfo(req) {
|
|
280
|
+
const agent = parseClientAgent(req.headers['user-agent']);
|
|
281
|
+
const ipchain = collectClientIpChain(req);
|
|
282
|
+
const ip = selectClientIp(ipchain);
|
|
283
|
+
return {
|
|
284
|
+
...agent,
|
|
285
|
+
ip,
|
|
286
|
+
ipchain
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function ensureClientInfo(apiReq) {
|
|
290
|
+
if (!apiReq.clientInfo) {
|
|
291
|
+
apiReq.clientInfo = buildClientInfo(apiReq.req);
|
|
292
|
+
}
|
|
293
|
+
return apiReq.clientInfo;
|
|
294
|
+
}
|
|
125
295
|
class ApiError extends Error {
|
|
126
296
|
constructor({ code, message, data, errors }) {
|
|
127
297
|
const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
|
|
@@ -133,6 +303,13 @@ class ApiError extends Error {
|
|
|
133
303
|
}
|
|
134
304
|
}
|
|
135
305
|
exports.ApiError = ApiError;
|
|
306
|
+
function isApiErrorLike(candidate) {
|
|
307
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const maybeError = candidate;
|
|
311
|
+
return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
|
|
312
|
+
}
|
|
136
313
|
function fillConfig(config) {
|
|
137
314
|
return {
|
|
138
315
|
apiPort: config.apiPort ?? 3101,
|
|
@@ -413,13 +590,17 @@ class ApiServer {
|
|
|
413
590
|
return async (req, res, next) => {
|
|
414
591
|
void next;
|
|
415
592
|
try {
|
|
416
|
-
const apiReq =
|
|
593
|
+
const apiReq = {
|
|
417
594
|
server: this,
|
|
418
595
|
req,
|
|
419
596
|
res,
|
|
420
597
|
token: '',
|
|
421
|
-
tokenData: null
|
|
422
|
-
|
|
598
|
+
tokenData: null,
|
|
599
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
600
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
601
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
602
|
+
};
|
|
603
|
+
this.currReq = apiReq;
|
|
423
604
|
if (this.config.hydrateGetBody) {
|
|
424
605
|
hydrateGetBody(apiReq.req);
|
|
425
606
|
}
|
|
@@ -428,16 +609,31 @@ class ApiServer {
|
|
|
428
609
|
}
|
|
429
610
|
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
430
611
|
await this.authorize(apiReq, auth.req);
|
|
431
|
-
const
|
|
612
|
+
const handlerResult = await handler(apiReq);
|
|
613
|
+
if (!Array.isArray(handlerResult)) {
|
|
614
|
+
throw new ApiError({
|
|
615
|
+
code: 500,
|
|
616
|
+
message: 'Handler result must be an array: [status, data?, message?]'
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
const [code, data = null, rawMessage = 'Success'] = handlerResult;
|
|
620
|
+
if (typeof code !== 'number' || Number.isNaN(code)) {
|
|
621
|
+
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
622
|
+
}
|
|
623
|
+
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
432
624
|
res.status(code).json({ code, message, data });
|
|
433
625
|
}
|
|
434
626
|
catch (error) {
|
|
435
|
-
if (error instanceof ApiError) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
627
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
628
|
+
const apiError = error;
|
|
629
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
630
|
+
? apiError.errors
|
|
631
|
+
: {};
|
|
632
|
+
res.status(apiError.code).json({
|
|
633
|
+
code: apiError.code,
|
|
634
|
+
message: apiError.message,
|
|
635
|
+
data: apiError.data ?? null,
|
|
636
|
+
errors: normalizedErrors
|
|
441
637
|
});
|
|
442
638
|
}
|
|
443
639
|
else {
|
|
@@ -445,62 +641,12 @@ class ApiServer {
|
|
|
445
641
|
code: 500,
|
|
446
642
|
message: this.guessExceptionText(error),
|
|
447
643
|
data: null,
|
|
448
|
-
errors:
|
|
644
|
+
errors: {}
|
|
449
645
|
});
|
|
450
646
|
}
|
|
451
647
|
}
|
|
452
648
|
};
|
|
453
649
|
}
|
|
454
|
-
getClientIp(req) {
|
|
455
|
-
const chain = this.getClientIpChain(req);
|
|
456
|
-
for (const ip of chain) {
|
|
457
|
-
if (!isLoopbackAddress(ip)) {
|
|
458
|
-
return ip;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return chain[0] ?? null;
|
|
462
|
-
}
|
|
463
|
-
getClientIpChain(req) {
|
|
464
|
-
const seen = new Set();
|
|
465
|
-
const result = [];
|
|
466
|
-
const pushNormalized = (ip) => {
|
|
467
|
-
if (!ip || seen.has(ip)) {
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
seen.add(ip);
|
|
471
|
-
result.push(ip);
|
|
472
|
-
};
|
|
473
|
-
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
474
|
-
pushNormalized(ip);
|
|
475
|
-
}
|
|
476
|
-
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
477
|
-
pushNormalized(ip);
|
|
478
|
-
}
|
|
479
|
-
const realIp = req.headers['x-real-ip'];
|
|
480
|
-
if (Array.isArray(realIp)) {
|
|
481
|
-
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
482
|
-
}
|
|
483
|
-
else if (typeof realIp === 'string') {
|
|
484
|
-
pushNormalized(normalizeIpAddress(realIp));
|
|
485
|
-
}
|
|
486
|
-
if (Array.isArray(req.ips)) {
|
|
487
|
-
for (const ip of req.ips) {
|
|
488
|
-
pushNormalized(normalizeIpAddress(ip));
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
if (typeof req.ip === 'string') {
|
|
492
|
-
pushNormalized(normalizeIpAddress(req.ip));
|
|
493
|
-
}
|
|
494
|
-
const socketAddress = req.socket?.remoteAddress;
|
|
495
|
-
if (typeof socketAddress === 'string') {
|
|
496
|
-
pushNormalized(normalizeIpAddress(socketAddress));
|
|
497
|
-
}
|
|
498
|
-
const connectionAddress = req.connection?.remoteAddress;
|
|
499
|
-
if (typeof connectionAddress === 'string') {
|
|
500
|
-
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
501
|
-
}
|
|
502
|
-
return result;
|
|
503
|
-
}
|
|
504
650
|
api(module) {
|
|
505
651
|
const router = express_1.default.Router();
|
|
506
652
|
module.server = this;
|
|
@@ -46,6 +46,20 @@ export interface ApiRequest {
|
|
|
46
46
|
res: Response;
|
|
47
47
|
tokenData?: ApiTokenData | null;
|
|
48
48
|
token?: string;
|
|
49
|
+
clientInfo?: ClientInfo;
|
|
50
|
+
getClientInfo: () => ClientInfo;
|
|
51
|
+
getClientIp: () => string | null;
|
|
52
|
+
getClientIpChain: () => string[];
|
|
53
|
+
}
|
|
54
|
+
export interface ClientAgentProfile {
|
|
55
|
+
ua: string;
|
|
56
|
+
browser: string;
|
|
57
|
+
os: string;
|
|
58
|
+
device: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ClientInfo extends ClientAgentProfile {
|
|
61
|
+
ip: string | null;
|
|
62
|
+
ipchain: string[];
|
|
49
63
|
}
|
|
50
64
|
export { ApiModule } from './api-module.js';
|
|
51
65
|
export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
|
|
@@ -121,8 +135,6 @@ export declare class ApiServer {
|
|
|
121
135
|
private verifyJWT;
|
|
122
136
|
private authenticate;
|
|
123
137
|
private handle_request;
|
|
124
|
-
getClientIp(req: RequestWithStuff): string | null;
|
|
125
|
-
getClientIpChain(req: RequestWithStuff): string[];
|
|
126
138
|
api<T extends ApiModule<any>>(module: T): this;
|
|
127
139
|
dumpRequest(apiReq: ApiRequest): void;
|
|
128
140
|
}
|
|
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
|
|
|
4
4
|
domain?: string;
|
|
5
5
|
fingerprint?: string;
|
|
6
6
|
label?: string;
|
|
7
|
+
browser?: string;
|
|
8
|
+
device?: string;
|
|
9
|
+
ip?: string;
|
|
10
|
+
os?: string;
|
|
7
11
|
scope?: string | string[];
|
|
8
12
|
revokeSessions?: 'device' | 'domain' | 'client' | 'user';
|
|
9
13
|
}
|
|
10
14
|
export interface AuthTokenData extends AuthTokenMetadata {
|
|
11
15
|
access: string;
|
|
12
16
|
expires?: Date;
|
|
17
|
+
issuedAt?: Date;
|
|
18
|
+
lastSeenAt?: Date;
|
|
13
19
|
refresh: string;
|
|
14
20
|
userId: AuthIdentifier;
|
|
15
21
|
}
|
|
@@ -46,6 +46,20 @@ export interface ApiRequest {
|
|
|
46
46
|
res: Response;
|
|
47
47
|
tokenData?: ApiTokenData | null;
|
|
48
48
|
token?: string;
|
|
49
|
+
clientInfo?: ClientInfo;
|
|
50
|
+
getClientInfo: () => ClientInfo;
|
|
51
|
+
getClientIp: () => string | null;
|
|
52
|
+
getClientIpChain: () => string[];
|
|
53
|
+
}
|
|
54
|
+
export interface ClientAgentProfile {
|
|
55
|
+
ua: string;
|
|
56
|
+
browser: string;
|
|
57
|
+
os: string;
|
|
58
|
+
device: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ClientInfo extends ClientAgentProfile {
|
|
61
|
+
ip: string | null;
|
|
62
|
+
ipchain: string[];
|
|
49
63
|
}
|
|
50
64
|
export { ApiModule } from './api-module.js';
|
|
51
65
|
export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
|
|
@@ -121,8 +135,6 @@ export declare class ApiServer {
|
|
|
121
135
|
private verifyJWT;
|
|
122
136
|
private authenticate;
|
|
123
137
|
private handle_request;
|
|
124
|
-
getClientIp(req: RequestWithStuff): string | null;
|
|
125
|
-
getClientIpChain(req: RequestWithStuff): string[];
|
|
126
138
|
api<T extends ApiModule<any>>(module: T): this;
|
|
127
139
|
dumpRequest(apiReq: ApiRequest): void;
|
|
128
140
|
}
|
|
@@ -103,6 +103,111 @@ function extractForwardedHeader(header) {
|
|
|
103
103
|
}
|
|
104
104
|
return ips;
|
|
105
105
|
}
|
|
106
|
+
function detectBrowser(userAgent) {
|
|
107
|
+
const browserMatchers = [
|
|
108
|
+
{ label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
|
|
109
|
+
{ label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
110
|
+
{ label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
|
|
111
|
+
{ label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
|
|
112
|
+
{ label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
|
|
113
|
+
{ label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
|
|
114
|
+
{ label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
|
|
115
|
+
{ label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
|
|
116
|
+
{ label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
|
|
117
|
+
{ label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
|
|
118
|
+
{ label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
|
|
119
|
+
];
|
|
120
|
+
for (const matcher of browserMatchers) {
|
|
121
|
+
const m = userAgent.match(matcher.pattern);
|
|
122
|
+
if (m) {
|
|
123
|
+
const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
|
|
124
|
+
return version ? `${matcher.label} ${version}` : matcher.label;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
function detectOs(userAgent) {
|
|
130
|
+
const osMatchers = [
|
|
131
|
+
{
|
|
132
|
+
label: 'Windows',
|
|
133
|
+
pattern: /Windows NT ([\d.]+)/i,
|
|
134
|
+
transform: (match) => `Windows ${match[1]}`
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
label: 'iOS',
|
|
138
|
+
pattern: /iPhone OS ([\d_]+)/i,
|
|
139
|
+
transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: 'iPadOS',
|
|
143
|
+
pattern: /iPad; CPU OS ([\d_]+)/i,
|
|
144
|
+
transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
label: 'macOS',
|
|
148
|
+
pattern: /Mac OS X ([\d_]+)/i,
|
|
149
|
+
transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
label: 'Android',
|
|
153
|
+
pattern: /Android ([\d.]+)/i,
|
|
154
|
+
transform: (match) => `Android ${match[1]}`
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
label: 'ChromeOS',
|
|
158
|
+
pattern: /CrOS [^ ]+ ([\d.]+)/i,
|
|
159
|
+
transform: (match) => `ChromeOS ${match[1]}`
|
|
160
|
+
},
|
|
161
|
+
{ label: 'Linux', pattern: /Linux/i },
|
|
162
|
+
{ label: 'Unix', pattern: /X11/i }
|
|
163
|
+
];
|
|
164
|
+
for (const matcher of osMatchers) {
|
|
165
|
+
const m = userAgent.match(matcher.pattern);
|
|
166
|
+
if (m) {
|
|
167
|
+
return matcher.transform ? matcher.transform(m) : matcher.label;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
function detectDevice(userAgent, osLabel) {
|
|
173
|
+
if (/iPhone/i.test(userAgent)) {
|
|
174
|
+
return 'iPhone';
|
|
175
|
+
}
|
|
176
|
+
if (/iPad/i.test(userAgent)) {
|
|
177
|
+
return 'iPad';
|
|
178
|
+
}
|
|
179
|
+
if (/iPod/i.test(userAgent)) {
|
|
180
|
+
return 'iPod';
|
|
181
|
+
}
|
|
182
|
+
if (/Android/i.test(userAgent)) {
|
|
183
|
+
const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
|
|
184
|
+
if (match) {
|
|
185
|
+
return match[1];
|
|
186
|
+
}
|
|
187
|
+
return 'Android Device';
|
|
188
|
+
}
|
|
189
|
+
if (/Macintosh/i.test(userAgent)) {
|
|
190
|
+
return 'Mac';
|
|
191
|
+
}
|
|
192
|
+
if (/Windows/i.test(userAgent)) {
|
|
193
|
+
return 'PC';
|
|
194
|
+
}
|
|
195
|
+
if (/CrOS/i.test(userAgent)) {
|
|
196
|
+
return 'Chromebook';
|
|
197
|
+
}
|
|
198
|
+
return osLabel;
|
|
199
|
+
}
|
|
200
|
+
function parseClientAgent(userAgentHeader) {
|
|
201
|
+
const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
|
|
202
|
+
const ua = typeof raw === 'string' ? raw.trim() : '';
|
|
203
|
+
if (!ua) {
|
|
204
|
+
return { ua: '', browser: '', os: '', device: '' };
|
|
205
|
+
}
|
|
206
|
+
const os = detectOs(ua);
|
|
207
|
+
const browser = detectBrowser(ua);
|
|
208
|
+
const device = detectDevice(ua, os);
|
|
209
|
+
return { ua, browser, os, device };
|
|
210
|
+
}
|
|
106
211
|
function isLoopbackAddress(ip) {
|
|
107
212
|
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
|
|
108
213
|
return true;
|
|
@@ -115,6 +220,71 @@ function isLoopbackAddress(ip) {
|
|
|
115
220
|
}
|
|
116
221
|
return false;
|
|
117
222
|
}
|
|
223
|
+
function collectClientIpChain(req) {
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const result = [];
|
|
226
|
+
const pushNormalized = (ip) => {
|
|
227
|
+
if (!ip || seen.has(ip)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
seen.add(ip);
|
|
231
|
+
result.push(ip);
|
|
232
|
+
};
|
|
233
|
+
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
234
|
+
pushNormalized(ip);
|
|
235
|
+
}
|
|
236
|
+
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
237
|
+
pushNormalized(ip);
|
|
238
|
+
}
|
|
239
|
+
const realIp = req.headers['x-real-ip'];
|
|
240
|
+
if (Array.isArray(realIp)) {
|
|
241
|
+
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
242
|
+
}
|
|
243
|
+
else if (typeof realIp === 'string') {
|
|
244
|
+
pushNormalized(normalizeIpAddress(realIp));
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(req.ips)) {
|
|
247
|
+
for (const ip of req.ips) {
|
|
248
|
+
pushNormalized(normalizeIpAddress(ip));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (typeof req.ip === 'string') {
|
|
252
|
+
pushNormalized(normalizeIpAddress(req.ip));
|
|
253
|
+
}
|
|
254
|
+
const socketAddress = req.socket?.remoteAddress;
|
|
255
|
+
if (typeof socketAddress === 'string') {
|
|
256
|
+
pushNormalized(normalizeIpAddress(socketAddress));
|
|
257
|
+
}
|
|
258
|
+
const connectionAddress = req.connection?.remoteAddress;
|
|
259
|
+
if (typeof connectionAddress === 'string') {
|
|
260
|
+
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
function selectClientIp(chain) {
|
|
265
|
+
for (const ip of chain) {
|
|
266
|
+
if (!isLoopbackAddress(ip)) {
|
|
267
|
+
return ip;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return chain[0] ?? null;
|
|
271
|
+
}
|
|
272
|
+
function buildClientInfo(req) {
|
|
273
|
+
const agent = parseClientAgent(req.headers['user-agent']);
|
|
274
|
+
const ipchain = collectClientIpChain(req);
|
|
275
|
+
const ip = selectClientIp(ipchain);
|
|
276
|
+
return {
|
|
277
|
+
...agent,
|
|
278
|
+
ip,
|
|
279
|
+
ipchain
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function ensureClientInfo(apiReq) {
|
|
283
|
+
if (!apiReq.clientInfo) {
|
|
284
|
+
apiReq.clientInfo = buildClientInfo(apiReq.req);
|
|
285
|
+
}
|
|
286
|
+
return apiReq.clientInfo;
|
|
287
|
+
}
|
|
118
288
|
export class ApiError extends Error {
|
|
119
289
|
constructor({ code, message, data, errors }) {
|
|
120
290
|
const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
|
|
@@ -125,6 +295,13 @@ export class ApiError extends Error {
|
|
|
125
295
|
this.errors = errors !== undefined ? errors : {};
|
|
126
296
|
}
|
|
127
297
|
}
|
|
298
|
+
function isApiErrorLike(candidate) {
|
|
299
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const maybeError = candidate;
|
|
303
|
+
return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
|
|
304
|
+
}
|
|
128
305
|
function fillConfig(config) {
|
|
129
306
|
return {
|
|
130
307
|
apiPort: config.apiPort ?? 3101,
|
|
@@ -405,13 +582,17 @@ export class ApiServer {
|
|
|
405
582
|
return async (req, res, next) => {
|
|
406
583
|
void next;
|
|
407
584
|
try {
|
|
408
|
-
const apiReq =
|
|
585
|
+
const apiReq = {
|
|
409
586
|
server: this,
|
|
410
587
|
req,
|
|
411
588
|
res,
|
|
412
589
|
token: '',
|
|
413
|
-
tokenData: null
|
|
414
|
-
|
|
590
|
+
tokenData: null,
|
|
591
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
592
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
593
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain
|
|
594
|
+
};
|
|
595
|
+
this.currReq = apiReq;
|
|
415
596
|
if (this.config.hydrateGetBody) {
|
|
416
597
|
hydrateGetBody(apiReq.req);
|
|
417
598
|
}
|
|
@@ -420,16 +601,31 @@ export class ApiServer {
|
|
|
420
601
|
}
|
|
421
602
|
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
422
603
|
await this.authorize(apiReq, auth.req);
|
|
423
|
-
const
|
|
604
|
+
const handlerResult = await handler(apiReq);
|
|
605
|
+
if (!Array.isArray(handlerResult)) {
|
|
606
|
+
throw new ApiError({
|
|
607
|
+
code: 500,
|
|
608
|
+
message: 'Handler result must be an array: [status, data?, message?]'
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const [code, data = null, rawMessage = 'Success'] = handlerResult;
|
|
612
|
+
if (typeof code !== 'number' || Number.isNaN(code)) {
|
|
613
|
+
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
614
|
+
}
|
|
615
|
+
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
424
616
|
res.status(code).json({ code, message, data });
|
|
425
617
|
}
|
|
426
618
|
catch (error) {
|
|
427
|
-
if (error instanceof ApiError) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
619
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
620
|
+
const apiError = error;
|
|
621
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
622
|
+
? apiError.errors
|
|
623
|
+
: {};
|
|
624
|
+
res.status(apiError.code).json({
|
|
625
|
+
code: apiError.code,
|
|
626
|
+
message: apiError.message,
|
|
627
|
+
data: apiError.data ?? null,
|
|
628
|
+
errors: normalizedErrors
|
|
433
629
|
});
|
|
434
630
|
}
|
|
435
631
|
else {
|
|
@@ -437,62 +633,12 @@ export class ApiServer {
|
|
|
437
633
|
code: 500,
|
|
438
634
|
message: this.guessExceptionText(error),
|
|
439
635
|
data: null,
|
|
440
|
-
errors:
|
|
636
|
+
errors: {}
|
|
441
637
|
});
|
|
442
638
|
}
|
|
443
639
|
}
|
|
444
640
|
};
|
|
445
641
|
}
|
|
446
|
-
getClientIp(req) {
|
|
447
|
-
const chain = this.getClientIpChain(req);
|
|
448
|
-
for (const ip of chain) {
|
|
449
|
-
if (!isLoopbackAddress(ip)) {
|
|
450
|
-
return ip;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
return chain[0] ?? null;
|
|
454
|
-
}
|
|
455
|
-
getClientIpChain(req) {
|
|
456
|
-
const seen = new Set();
|
|
457
|
-
const result = [];
|
|
458
|
-
const pushNormalized = (ip) => {
|
|
459
|
-
if (!ip || seen.has(ip)) {
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
seen.add(ip);
|
|
463
|
-
result.push(ip);
|
|
464
|
-
};
|
|
465
|
-
for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
|
|
466
|
-
pushNormalized(ip);
|
|
467
|
-
}
|
|
468
|
-
for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
|
|
469
|
-
pushNormalized(ip);
|
|
470
|
-
}
|
|
471
|
-
const realIp = req.headers['x-real-ip'];
|
|
472
|
-
if (Array.isArray(realIp)) {
|
|
473
|
-
realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
|
|
474
|
-
}
|
|
475
|
-
else if (typeof realIp === 'string') {
|
|
476
|
-
pushNormalized(normalizeIpAddress(realIp));
|
|
477
|
-
}
|
|
478
|
-
if (Array.isArray(req.ips)) {
|
|
479
|
-
for (const ip of req.ips) {
|
|
480
|
-
pushNormalized(normalizeIpAddress(ip));
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
if (typeof req.ip === 'string') {
|
|
484
|
-
pushNormalized(normalizeIpAddress(req.ip));
|
|
485
|
-
}
|
|
486
|
-
const socketAddress = req.socket?.remoteAddress;
|
|
487
|
-
if (typeof socketAddress === 'string') {
|
|
488
|
-
pushNormalized(normalizeIpAddress(socketAddress));
|
|
489
|
-
}
|
|
490
|
-
const connectionAddress = req.connection?.remoteAddress;
|
|
491
|
-
if (typeof connectionAddress === 'string') {
|
|
492
|
-
pushNormalized(normalizeIpAddress(connectionAddress));
|
|
493
|
-
}
|
|
494
|
-
return result;
|
|
495
|
-
}
|
|
496
642
|
api(module) {
|
|
497
643
|
const router = express.Router();
|
|
498
644
|
module.server = this;
|
|
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
|
|
|
4
4
|
domain?: string;
|
|
5
5
|
fingerprint?: string;
|
|
6
6
|
label?: string;
|
|
7
|
+
browser?: string;
|
|
8
|
+
device?: string;
|
|
9
|
+
ip?: string;
|
|
10
|
+
os?: string;
|
|
7
11
|
scope?: string | string[];
|
|
8
12
|
revokeSessions?: 'device' | 'domain' | 'client' | 'user';
|
|
9
13
|
}
|
|
10
14
|
export interface AuthTokenData extends AuthTokenMetadata {
|
|
11
15
|
access: string;
|
|
12
16
|
expires?: Date;
|
|
17
|
+
issuedAt?: Date;
|
|
18
|
+
lastSeenAt?: Date;
|
|
13
19
|
refresh: string;
|
|
14
20
|
userId: AuthIdentifier;
|
|
15
21
|
}
|