@20syldev/api 4.4.0 → 4.5.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/README.md +3 -3
- package/dist/config/env.js +5 -1
- package/dist/config/env.js.map +1 -1
- package/dist/config/plans.js +44 -0
- package/dist/config/plans.js.map +1 -0
- package/dist/constants.js +1 -0
- package/dist/constants.js.map +1 -1
- package/dist/middleware/cors.js +7 -2
- package/dist/middleware/cors.js.map +1 -1
- package/dist/middleware/error.js +3 -1
- package/dist/middleware/error.js.map +1 -1
- package/dist/middleware/ratelimit.js +39 -21
- package/dist/middleware/ratelimit.js.map +1 -1
- package/dist/modules/v3/chat.js +1 -1
- package/dist/modules/v3/chat.js.map +1 -1
- package/dist/modules/v3/domain.js +1 -1
- package/dist/modules/v3/domain.js.map +1 -1
- package/dist/modules/v3/hyperplanning.js +1 -1
- package/dist/modules/v3/hyperplanning.js.map +1 -1
- package/dist/modules/v3/personal.js +1 -1
- package/dist/modules/v3/personal.js.map +1 -1
- package/dist/modules/v3/username.js +1 -1
- package/dist/modules/v3/username.js.map +1 -1
- package/dist/modules/v4/convert.js.map +1 -1
- package/dist/modules/v4/hyperplanning.js +31 -1
- package/dist/modules/v4/hyperplanning.js.map +1 -1
- package/dist/routes/get.js +9 -8
- package/dist/routes/get.js.map +1 -1
- package/dist/routes/post.js +2 -1
- package/dist/routes/post.js.map +1 -1
- package/dist/utils/colors.js +59 -20
- package/dist/utils/colors.js.map +1 -1
- package/dist/utils/helpers.js +67 -16
- package/dist/utils/helpers.js.map +1 -1
- package/dist/utils/response.js +18 -2
- package/dist/utils/response.js.map +1 -1
- package/package.json +1 -1
- package/src/config/env.ts +6 -1
- package/src/config/plans.ts +61 -0
- package/src/constants.ts +1 -0
- package/src/middleware/cors.ts +8 -2
- package/src/middleware/error.ts +10 -2
- package/src/middleware/ratelimit.ts +42 -20
- package/src/modules/v4/hyperplanning.ts +27 -1
- package/src/routes/get.ts +4 -4
- package/src/utils/response.ts +10 -2
- package/tests/integration/api.test.ts +38 -0
- package/tests/unit/hyperplanning.test.ts +19 -0
|
@@ -1,45 +1,53 @@
|
|
|
1
1
|
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
-
import { env } from '../config/env.js';
|
|
3
2
|
import { ipLimits } from '../storage/index.js';
|
|
4
|
-
import { SESSION_TTL } from '../constants.js';
|
|
3
|
+
import { RATE_LIMIT_WINDOW, SESSION_TTL } from '../constants.js';
|
|
5
4
|
import { error } from '../utils/response.js';
|
|
5
|
+
import { getPlan, globalLimit } from '../config/plans.js';
|
|
6
6
|
|
|
7
7
|
let requests = 0;
|
|
8
8
|
let resetTime = Date.now() + SESSION_TTL;
|
|
9
9
|
|
|
10
|
+
const burstTracker: Record<string, number[]> = {};
|
|
11
|
+
|
|
12
|
+
// Cleanup stale IPs every hour
|
|
13
|
+
setInterval(() => {
|
|
14
|
+
const currentHour = String(Math.floor(Date.now() / 3600000) % 24);
|
|
15
|
+
for (const ip of Object.keys(ipLimits)) {
|
|
16
|
+
const hours = Object.keys(ipLimits[ip]!);
|
|
17
|
+
if (hours.length === 0 || (hours.length === 1 && hours[0] !== currentHour)) {
|
|
18
|
+
delete ipLimits[ip];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const ip of Object.keys(burstTracker)) {
|
|
24
|
+
burstTracker[ip] = burstTracker[ip]!.filter((t) => now - t < RATE_LIMIT_WINDOW);
|
|
25
|
+
if (burstTracker[ip]!.length === 0) delete burstTracker[ip];
|
|
26
|
+
}
|
|
27
|
+
}, SESSION_TTL);
|
|
28
|
+
|
|
10
29
|
export function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
11
|
-
const ip =
|
|
30
|
+
const ip = req.ip || req.socket.remoteAddress || '';
|
|
12
31
|
const token = req.headers.authorization?.split(' ')[1] || '';
|
|
13
32
|
|
|
14
33
|
const now = Date.now();
|
|
15
34
|
const minute = String(Math.floor(now / 60000) % 60);
|
|
16
35
|
const hour = String(Math.floor(now / 3600000) % 24);
|
|
17
36
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
token === 'undefined'
|
|
21
|
-
) {
|
|
37
|
+
const match = getPlan(token);
|
|
38
|
+
if (!match) {
|
|
22
39
|
error(res, 401, 'Invalid token.');
|
|
23
40
|
return;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
if (env.BUSINESS_TOKEN_LIST.includes(token) && !env.BUSINESS_TOKEN_LIST.includes('undefined')) {
|
|
28
|
-
requestLimit = env.BUSINESS_LIMIT;
|
|
29
|
-
} else if (env.PRO_TOKEN_LIST.includes(token) && !env.PRO_TOKEN_LIST.includes('undefined')) {
|
|
30
|
-
requestLimit = env.PRO_LIMIT;
|
|
31
|
-
} else if (env.ADVANCED_TOKEN_LIST.includes(token) && !env.ADVANCED_TOKEN_LIST.includes('undefined')) {
|
|
32
|
-
requestLimit = env.ADVANCED_LIMIT;
|
|
33
|
-
} else {
|
|
34
|
-
requestLimit = env.DEFAULT_LIMIT;
|
|
35
|
-
}
|
|
43
|
+
const { plan } = match;
|
|
36
44
|
|
|
37
45
|
if (now > resetTime) {
|
|
38
46
|
requests = 0;
|
|
39
47
|
resetTime = now + SESSION_TTL;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
if (++requests > Math.max(
|
|
50
|
+
if (++requests > Math.max(globalLimit, plan.hourly)) {
|
|
43
51
|
error(res, 429, 'Global rate limit exceeded.');
|
|
44
52
|
return;
|
|
45
53
|
}
|
|
@@ -49,12 +57,26 @@ export function rateLimitMiddleware(req: Request, res: Response, next: NextFunct
|
|
|
49
57
|
return;
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
// Burst protection per tier
|
|
61
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
62
|
+
if (!burstTracker[ip]) burstTracker[ip] = [];
|
|
63
|
+
burstTracker[ip] = burstTracker[ip]!.filter((t) => now - t < RATE_LIMIT_WINDOW);
|
|
64
|
+
burstTracker[ip]!.push(now);
|
|
65
|
+
|
|
66
|
+
if (burstTracker[ip]!.length > plan.burst) {
|
|
67
|
+
error(res, 429, 'Too many requests, please slow down.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Hourly rate limit per IP
|
|
52
73
|
if (!ipLimits[ip]) ipLimits[ip] = {};
|
|
53
74
|
if (!ipLimits[ip]![hour]) ipLimits[ip]![hour] = {};
|
|
54
75
|
ipLimits[ip]![hour]![minute] = (ipLimits[ip]![hour]![minute] ?? 0) + 1;
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
77
|
+
const hourTotal = Object.values(ipLimits[ip]![hour]!).reduce((sum, count) => sum + count, 0);
|
|
78
|
+
if (hourTotal > plan.hourly) {
|
|
79
|
+
error(res, 429, `You have exceeded the limit of ${plan.hourly} requests per hour.`);
|
|
58
80
|
return;
|
|
59
81
|
}
|
|
60
82
|
|
|
@@ -11,8 +11,34 @@ interface CalendarEvent {
|
|
|
11
11
|
end: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function blocked(hostname: string): boolean {
|
|
15
|
+
const list = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]', 'metadata.google.internal'];
|
|
16
|
+
if (list.includes(hostname)) return true;
|
|
17
|
+
|
|
18
|
+
const parts = hostname.split('.').map(Number);
|
|
19
|
+
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
|
20
|
+
if (parts[0] === 10) return true;
|
|
21
|
+
if (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) return true;
|
|
22
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
23
|
+
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
24
|
+
if (parts[0] === 0) return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export default async function hyperplanning(url: string, detail?: string): Promise<CalendarEvent[]> {
|
|
15
|
-
|
|
31
|
+
let parsed: URL;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error('Invalid URL.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (parsed.protocol !== 'https:') throw new Error('Only HTTPS URLs are allowed.');
|
|
39
|
+
if (blocked(parsed.hostname)) throw new Error('Access to private/internal hosts is not allowed.');
|
|
40
|
+
|
|
41
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
16
42
|
|
|
17
43
|
if (!response.ok || !(response.headers.get('content-type') || '').includes('text/calendar')) {
|
|
18
44
|
throw new Error('Invalid ICS file format.');
|
package/src/routes/get.ts
CHANGED
|
@@ -61,7 +61,7 @@ router.get('/:version/algorithms', (req: Request, res: Response) => {
|
|
|
61
61
|
const { version } = req.params;
|
|
62
62
|
|
|
63
63
|
const algorithms = req.module.algorithms as Record<string, (v: string, v2?: string) => unknown>;
|
|
64
|
-
if (!algorithms || !algorithms
|
|
64
|
+
if (!algorithms || !method || !Object.hasOwn(algorithms, method as string)) {
|
|
65
65
|
error(res, 400, 'Please provide a valid algorithm (?method={algorithm})', `${version}/algorithms`);
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
@@ -217,7 +217,7 @@ router.get('/:version/encode', (req: Request, res: Response) => {
|
|
|
217
217
|
error(res, 404, `Endpoint not available in ${version}.`, `${version}/encode`);
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
|
-
if (!method || !encode
|
|
220
|
+
if (!method || !Object.hasOwn(encode, method as string)) {
|
|
221
221
|
error(res, 400, 'Please provide a valid method (?method={method})', `${version}/encode`);
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
@@ -434,7 +434,7 @@ router.get('/:version/text', (req: Request, res: Response) => {
|
|
|
434
434
|
error(res, 404, `Endpoint not available in ${version}.`, `${version}/text`);
|
|
435
435
|
return;
|
|
436
436
|
}
|
|
437
|
-
if (!method || !textMod
|
|
437
|
+
if (!method || !Object.hasOwn(textMod, method as string)) {
|
|
438
438
|
error(res, 400, 'Please provide a valid method (?method={slug|stats|lorem|number})', `${version}/text`);
|
|
439
439
|
return;
|
|
440
440
|
}
|
|
@@ -471,7 +471,7 @@ router.get('/:version/validate', (req: Request, res: Response) => {
|
|
|
471
471
|
error(res, 404, `Endpoint not available in ${version}.`, `${version}/validate`);
|
|
472
472
|
return;
|
|
473
473
|
}
|
|
474
|
-
if (!type || !validate
|
|
474
|
+
if (!type || !Object.hasOwn(validate, type as string)) {
|
|
475
475
|
error(res, 400, 'Please provide a valid type (?type={luhn|iban|email})', `${version}/validate`);
|
|
476
476
|
return;
|
|
477
477
|
}
|
package/src/utils/response.ts
CHANGED
|
@@ -10,10 +10,18 @@ import { DOCS_URL, STATUS_MESSAGES } from '../constants.js';
|
|
|
10
10
|
* @param docPath - Optional documentation path appended to the base URL
|
|
11
11
|
*/
|
|
12
12
|
export function error(res: Response, status: number, message: string, docPath?: string): void {
|
|
13
|
-
|
|
13
|
+
const body = {
|
|
14
14
|
message: STATUS_MESSAGES[status] ?? 'Error',
|
|
15
15
|
error: message,
|
|
16
16
|
documentation: docPath ? `${DOCS_URL}/${docPath}` : DOCS_URL,
|
|
17
17
|
status: String(status),
|
|
18
|
-
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (typeof res.jsonResponse === 'function') {
|
|
21
|
+
res.status(status).jsonResponse(body);
|
|
22
|
+
} else {
|
|
23
|
+
res.status(status)
|
|
24
|
+
.setHeader('Content-Type', 'application/json')
|
|
25
|
+
.send(JSON.stringify(body, null, 2));
|
|
26
|
+
}
|
|
19
27
|
}
|
|
@@ -536,3 +536,41 @@ describe('POST /v4/token', () => {
|
|
|
536
536
|
assert.equal(status, 400);
|
|
537
537
|
});
|
|
538
538
|
});
|
|
539
|
+
|
|
540
|
+
// --- Security ---
|
|
541
|
+
|
|
542
|
+
describe('Security headers', () => {
|
|
543
|
+
test('returns X-Content-Type-Options and X-Frame-Options', async () => {
|
|
544
|
+
const res = await fetch(`${baseUrl}/v4/color`);
|
|
545
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
546
|
+
assert.equal(res.headers.get('x-frame-options'), 'DENY');
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('Payload size limit', () => {
|
|
551
|
+
test('body > 10kb returns 413', async () => {
|
|
552
|
+
const res = await fetch(`${baseUrl}/v4/hash`, {
|
|
553
|
+
method: 'POST',
|
|
554
|
+
headers: { 'Content-Type': 'application/json' },
|
|
555
|
+
body: JSON.stringify({ text: 'A'.repeat(20000), method: 'sha256' }),
|
|
556
|
+
});
|
|
557
|
+
assert.equal(res.status, 413);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe('Prototype access on dynamic endpoints', () => {
|
|
562
|
+
test('algorithms?method=toString returns 400', async () => {
|
|
563
|
+
const { status } = await getJson('/v4/algorithms?method=toString');
|
|
564
|
+
assert.equal(status, 400);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('encode?method=constructor returns 400', async () => {
|
|
568
|
+
const { status } = await getJson('/v4/encode?method=constructor&text=hello');
|
|
569
|
+
assert.equal(status, 400);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('validate?type=hasOwnProperty returns 400', async () => {
|
|
573
|
+
const { status } = await getJson('/v4/validate?type=hasOwnProperty&value=test');
|
|
574
|
+
assert.equal(status, 400);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
@@ -70,6 +70,25 @@ describe('hyperplanning', () => {
|
|
|
70
70
|
const events = await hyperplanning('https://fake.test/cal.ics');
|
|
71
71
|
assert.equal(events.length, 0);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
test('throws on HTTP URL', async () => {
|
|
75
|
+
await assert.rejects(() => hyperplanning('http://fake.test/cal.ics'), /Only HTTPS/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('throws on private IP', async () => {
|
|
79
|
+
await assert.rejects(() => hyperplanning('https://127.0.0.1/cal.ics'), /private/);
|
|
80
|
+
await assert.rejects(() => hyperplanning('https://192.168.1.1/cal.ics'), /private/);
|
|
81
|
+
await assert.rejects(() => hyperplanning('https://10.0.0.1/cal.ics'), /private/);
|
|
82
|
+
await assert.rejects(() => hyperplanning('https://169.254.169.254/latest'), /private/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('throws on localhost', async () => {
|
|
86
|
+
await assert.rejects(() => hyperplanning('https://localhost/cal.ics'), /private/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('throws on invalid URL', async () => {
|
|
90
|
+
await assert.rejects(() => hyperplanning('not-a-url'), /Invalid URL/);
|
|
91
|
+
});
|
|
73
92
|
});
|
|
74
93
|
|
|
75
94
|
before(() => {
|