@20syldev/api 4.2.0 → 4.4.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 (98) hide show
  1. package/README.md +3 -3
  2. package/dist/app.js +4 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/config/versions.js +19 -0
  5. package/dist/config/versions.js.map +1 -1
  6. package/dist/constants.js +2 -2
  7. package/dist/constants.js.map +1 -1
  8. package/dist/modules/v3/chat.js +1 -1
  9. package/dist/modules/v3/chat.js.map +1 -1
  10. package/dist/modules/v3/domain.js +1 -1
  11. package/dist/modules/v3/domain.js.map +1 -1
  12. package/dist/modules/v3/hyperplanning.js +1 -1
  13. package/dist/modules/v3/hyperplanning.js.map +1 -1
  14. package/dist/modules/v3/personal.js +1 -1
  15. package/dist/modules/v3/personal.js.map +1 -1
  16. package/dist/modules/v3/username.js +1 -1
  17. package/dist/modules/v3/username.js.map +1 -1
  18. package/dist/modules/v4/captcha.js +60 -23
  19. package/dist/modules/v4/captcha.js.map +1 -1
  20. package/dist/modules/v4/chat.js +1 -1
  21. package/dist/modules/v4/chat.js.map +1 -1
  22. package/dist/modules/v4/color.js +44 -35
  23. package/dist/modules/v4/color.js.map +1 -1
  24. package/dist/modules/v4/colors.js +54 -0
  25. package/dist/modules/v4/colors.js.map +1 -0
  26. package/dist/modules/v4/convert.js +93 -19
  27. package/dist/modules/v4/convert.js.map +1 -1
  28. package/dist/modules/v4/domain.js +1 -1
  29. package/dist/modules/v4/domain.js.map +1 -1
  30. package/dist/modules/v4/hash.js +11 -4
  31. package/dist/modules/v4/hash.js.map +1 -1
  32. package/dist/modules/v4/hyperplanning.js +1 -1
  33. package/dist/modules/v4/hyperplanning.js.map +1 -1
  34. package/dist/modules/v4/palette.js +2 -43
  35. package/dist/modules/v4/palette.js.map +1 -1
  36. package/dist/modules/v4/personal.js +1 -1
  37. package/dist/modules/v4/personal.js.map +1 -1
  38. package/dist/modules/v4/placeholder.js +108 -52
  39. package/dist/modules/v4/placeholder.js.map +1 -1
  40. package/dist/modules/v4/qrcode.js +79 -6
  41. package/dist/modules/v4/qrcode.js.map +1 -1
  42. package/dist/modules/v4/username.js +1 -1
  43. package/dist/modules/v4/username.js.map +1 -1
  44. package/dist/modules/v4.js +6 -0
  45. package/dist/modules/v4.js.map +1 -1
  46. package/dist/routes/delete.js +16 -17
  47. package/dist/routes/delete.js.map +1 -1
  48. package/dist/routes/get.js +212 -13
  49. package/dist/routes/get.js.map +1 -1
  50. package/dist/routes/index.js +2 -2
  51. package/dist/routes/index.js.map +1 -1
  52. package/dist/routes/patch.js +2 -3
  53. package/dist/routes/patch.js.map +1 -1
  54. package/dist/routes/post.js +10 -3
  55. package/dist/routes/post.js.map +1 -1
  56. package/dist/utils/colors.js +54 -0
  57. package/dist/utils/colors.js.map +1 -0
  58. package/dist/utils/helpers.js +26 -0
  59. package/dist/utils/helpers.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/app.ts +4 -0
  62. package/src/config/versions.ts +16 -0
  63. package/src/constants.ts +0 -2
  64. package/src/middleware/cors.ts +1 -1
  65. package/src/modules/v4/captcha.ts +84 -27
  66. package/src/modules/v4/chat.ts +25 -2
  67. package/src/modules/v4/color.ts +56 -52
  68. package/src/modules/v4/convert.ts +100 -20
  69. package/src/modules/v4/domain.ts +1 -1
  70. package/src/modules/v4/geo.ts +53 -0
  71. package/src/modules/v4/hash.ts +18 -4
  72. package/src/modules/v4/hyperplanning.ts +1 -1
  73. package/src/modules/v4/palette.ts +63 -0
  74. package/src/modules/v4/personal.ts +1 -1
  75. package/src/modules/v4/placeholder.ts +189 -0
  76. package/src/modules/v4/qrcode.ts +108 -6
  77. package/src/modules/v4/tic_tac_toe.ts +33 -1
  78. package/src/modules/v4/username.ts +1 -1
  79. package/src/modules/v4.ts +3 -0
  80. package/src/routes/delete.ts +71 -0
  81. package/src/routes/get.ts +147 -15
  82. package/src/routes/patch.ts +44 -0
  83. package/src/routes/post.ts +11 -3
  84. package/src/utils/colors.ts +91 -0
  85. package/src/utils/helpers.ts +90 -0
  86. package/src/utils/response.ts +8 -0
  87. package/tests/integration/api.test.ts +113 -25
  88. package/tests/unit/captcha.test.ts +61 -0
  89. package/tests/unit/chat.test.ts +53 -0
  90. package/tests/unit/color.test.ts +24 -4
  91. package/tests/unit/convert.test.ts +26 -1
  92. package/tests/unit/geo.test.ts +45 -0
  93. package/tests/unit/hash.test.ts +17 -10
  94. package/tests/unit/palette.test.ts +53 -0
  95. package/tests/unit/placeholder.test.ts +57 -0
  96. package/tests/unit/qrcode.test.ts +82 -0
  97. package/tests/unit/tic_tac_toe.test.ts +55 -0
  98. package/src/modules/v4/utils.ts +0 -38
package/src/modules/v4.ts CHANGED
@@ -6,10 +6,13 @@ export { default as convert } from './v4/convert.js';
6
6
  export { default as dice } from './v4/dice.js';
7
7
  export { default as domain } from './v4/domain.js';
8
8
  export * as encode from './v4/encode.js';
9
+ export { default as geo } from './v4/geo.js';
9
10
  export { default as hash } from './v4/hash.js';
10
11
  export { default as hyperplanning } from './v4/hyperplanning.js';
11
12
  export { default as levenshtein } from './v4/levenshtein.js';
13
+ export { default as palette } from './v4/palette.js';
12
14
  export { default as personal } from './v4/personal.js';
15
+ export { default as placeholder } from './v4/placeholder.js';
13
16
  export { default as qrcode } from './v4/qrcode.js';
14
17
  export { default as statistics } from './v4/statistics.js';
15
18
  export * as text from './v4/text.js';
@@ -0,0 +1,71 @@
1
+ import { Router, type Request, type Response } from 'express';
2
+ import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
+ import { error } from '../utils/response.js';
4
+
5
+ const router = Router();
6
+
7
+ // Clear a private chat
8
+ router.delete('/:version/chat/:token', (req: Request, res: Response) => {
9
+ if (req.version !== 'v4') {
10
+ error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/chat`);
11
+ return;
12
+ }
13
+
14
+ const token = req.params.token as string;
15
+ const { username, session } = (req.body as Record<string, string>) || {};
16
+
17
+ if (!username) {
18
+ error(res, 400, 'Please provide a username (?username={username})');
19
+ return;
20
+ }
21
+ if (!session) {
22
+ error(res, 400, 'Please provide a valid session ID (&session={ID})');
23
+ return;
24
+ }
25
+
26
+ try {
27
+ const result = req.module.chat('clear', {
28
+ username,
29
+ token,
30
+ session,
31
+ storage: chatStorage,
32
+ });
33
+ res.jsonResponse(result);
34
+ } catch (err) {
35
+ error(res, 400, (err as Error).message);
36
+ }
37
+ });
38
+
39
+ // Forfeit a tic-tac-toe game
40
+ router.delete('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
41
+ if (req.version !== 'v4') {
42
+ error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/tic-tac-toe`);
43
+ return;
44
+ }
45
+
46
+ const game = req.params.game as string;
47
+ const { username, session } = (req.body as Record<string, string>) || {};
48
+
49
+ if (!username) {
50
+ error(res, 400, 'Please provide a username (?username={username})');
51
+ return;
52
+ }
53
+ if (!session) {
54
+ error(res, 400, 'Please provide a valid session ID (&session={ID})');
55
+ return;
56
+ }
57
+
58
+ try {
59
+ const result = req.module.tic_tac_toe('forfeit', {
60
+ username,
61
+ session,
62
+ game,
63
+ storage: ticTacToeStorage,
64
+ });
65
+ res.jsonResponse(result);
66
+ } catch (err) {
67
+ error(res, 400, (err as Error).message);
68
+ }
69
+ });
70
+
71
+ export default router;
package/src/routes/get.ts CHANGED
@@ -5,6 +5,10 @@ import { ipLimits } from '../storage/index.js';
5
5
  import { chatStorage } from '../storage/index.js';
6
6
  import { DOCS_URL, GITHUB_CACHE_TTL } from '../constants.js';
7
7
  import { error } from '../utils/response.js';
8
+ import { since } from '../utils/helpers.js';
9
+ import type { QRCodeOptions, QRCodeResult } from '../modules/v4/qrcode.js';
10
+ import type { CaptchaOptions, CaptchaResult } from '../modules/v4/captcha.js';
11
+ import type { ColorResult } from '../modules/v4/color.js';
8
12
 
9
13
  const router = Router();
10
14
 
@@ -72,16 +76,29 @@ router.get('/:version/algorithms', (req: Request, res: Response) => {
72
76
 
73
77
  // Generate captcha
74
78
  router.get('/:version/captcha', (req: Request, res: Response) => {
75
- const text = req.query.text as string;
76
-
77
- if (!text) {
78
- error(res, 400, 'Please provide a valid argument (?text={text})', `${req.version}/captcha`);
79
- return;
80
- }
81
-
82
79
  try {
83
- const result = req.module.captcha(text);
84
- res.type('png').send(result);
80
+ if (since(req.version, 4)) {
81
+ const captchaFn = req.module.captcha as (o: CaptchaOptions) => CaptchaResult;
82
+ const result = captchaFn({
83
+ text: req.query.text as string | undefined,
84
+ length: req.query.length ? Number(req.query.length) : undefined,
85
+ width: req.query.width ? Number(req.query.width) : undefined,
86
+ height: req.query.height ? Number(req.query.height) : undefined,
87
+ noise: req.query.noise as CaptchaOptions['noise'],
88
+ bg: req.query.bg as string | undefined,
89
+ color: req.query.color as string | undefined,
90
+ });
91
+ res.set('X-Captcha-Text', result.text);
92
+ res.type('png').send(result.body);
93
+ } else {
94
+ const text = req.query.text as string;
95
+ if (!text) {
96
+ error(res, 400, 'Please provide a valid argument (?text={text})', `${req.version}/captcha`);
97
+ return;
98
+ }
99
+ const result = (req.module.captcha as (t: string) => Buffer)(text);
100
+ res.type('png').send(result);
101
+ }
85
102
  } catch (err) {
86
103
  error(res, 400, (err as Error).message, `${req.version}/captcha`);
87
104
  }
@@ -108,8 +125,15 @@ router.get('/:version/chat/private', (_req: Request, res: Response) => {
108
125
  // Generate color
109
126
  router.get('/:version/color', (req: Request, res: Response) => {
110
127
  try {
111
- const result = req.module.color();
112
- res.jsonResponse(result);
128
+ if (since(req.version, 4)) {
129
+ const colorFn = req.module.color as (hex?: string) => ColorResult;
130
+ const hex = req.query.hex as string | undefined;
131
+ const result = colorFn(hex || undefined);
132
+ res.jsonResponse(result);
133
+ } else {
134
+ const result = (req.module.color as () => Record<string, string>)();
135
+ res.jsonResponse(result);
136
+ }
113
137
  } catch (err) {
114
138
  error(res, 400, (err as Error).message, `${req.version}/color`);
115
139
  }
@@ -133,8 +157,18 @@ router.get('/:version/convert', (req: Request, res: Response) => {
133
157
  }
134
158
 
135
159
  try {
136
- const result = req.module.convert(value as string, from as string, to as string);
137
- res.jsonResponse(result);
160
+ if (since(req.version, 4)) {
161
+ const convertFn = req.module.convert as (v: number, f: string, t: string) => Record<string, unknown>;
162
+ const result = convertFn(Number(value), from as string, to as string);
163
+ res.jsonResponse(result);
164
+ } else {
165
+ const result = (req.module.convert as (v: string, f: string, t: string) => Record<string, unknown>)(
166
+ value as string,
167
+ from as string,
168
+ to as string,
169
+ );
170
+ res.jsonResponse(result);
171
+ }
138
172
  } catch (err) {
139
173
  error(res, 400, (err as Error).message, `${req.version}/convert`);
140
174
  }
@@ -196,6 +230,29 @@ router.get('/:version/encode', (req: Request, res: Response) => {
196
230
  }
197
231
  });
198
232
 
233
+ // Geographic distance and bearing between two coordinates
234
+ router.get('/:version/geo', (req: Request, res: Response) => {
235
+ const { lat1, lon1, lat2, lon2 } = req.query;
236
+ const { version } = req.params;
237
+
238
+ const geo = (req.module as { geo?: (a: string, b: string, c: string, d: string) => unknown }).geo;
239
+ if (!geo) {
240
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/geo`);
241
+ return;
242
+ }
243
+ if (lat1 === undefined || lon1 === undefined || lat2 === undefined || lon2 === undefined) {
244
+ error(res, 400, 'Please provide lat1, lon1, lat2 and lon2', `${version}/geo`);
245
+ return;
246
+ }
247
+
248
+ try {
249
+ const result = geo(lat1 as string, lon1 as string, lat2 as string, lon2 as string);
250
+ res.jsonResponse(result);
251
+ } catch (err) {
252
+ error(res, 400, (err as Error).message, `${req.version}/geo`);
253
+ }
254
+ });
255
+
199
256
  // GET planning error
200
257
  router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
201
258
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -242,6 +299,33 @@ router.get('/:version/levenshtein', (req: Request, res: Response) => {
242
299
  }
243
300
  });
244
301
 
302
+ // Generate a color palette from a base color
303
+ router.get('/:version/palette', (req: Request, res: Response) => {
304
+ const { color, type } = req.query;
305
+ const { version } = req.params;
306
+
307
+ const palette = (req.module as { palette?: (c: string, t: string) => unknown }).palette;
308
+ if (!palette) {
309
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/palette`);
310
+ return;
311
+ }
312
+ if (!color) {
313
+ error(res, 400, 'Please provide a base color (?color=#ff6600)', `${version}/palette`);
314
+ return;
315
+ }
316
+ if (!type) {
317
+ error(res, 400, 'Please provide a palette type (&type=complementary)', `${version}/palette`);
318
+ return;
319
+ }
320
+
321
+ try {
322
+ const result = palette(color as string, type as string);
323
+ res.jsonResponse(result);
324
+ } catch (err) {
325
+ error(res, 400, (err as Error).message, `${req.version}/palette`);
326
+ }
327
+ });
328
+
245
329
  // Generate personal data
246
330
  router.get('/:version/personal', (req: Request, res: Response) => {
247
331
  try {
@@ -252,6 +336,32 @@ router.get('/:version/personal', (req: Request, res: Response) => {
252
336
  }
253
337
  });
254
338
 
339
+ // Generate a placeholder image or skeleton
340
+ router.get('/:version/placeholder', (req: Request, res: Response) => {
341
+ const { type = 'image' } = req.query;
342
+ const { version } = req.params;
343
+
344
+ const placeholder = (
345
+ req.module as {
346
+ placeholder?: (
347
+ t: string,
348
+ q: Record<string, string | undefined>,
349
+ ) => { type: string; contentType: string; body: Buffer | string };
350
+ }
351
+ ).placeholder;
352
+ if (!placeholder) {
353
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/placeholder`);
354
+ return;
355
+ }
356
+
357
+ try {
358
+ const result = placeholder(type as string, req.query as Record<string, string | undefined>);
359
+ res.type(result.contentType).send(result.body);
360
+ } catch (err) {
361
+ error(res, 400, (err as Error).message, `${req.version}/placeholder`);
362
+ }
363
+ });
364
+
255
365
  // Generate QR Code
256
366
  router.get('/:version/qrcode', async (req: Request, res: Response) => {
257
367
  const { url } = req.query;
@@ -262,8 +372,30 @@ router.get('/:version/qrcode', async (req: Request, res: Response) => {
262
372
  }
263
373
 
264
374
  try {
265
- const result = await req.module.qrcode(url as string);
266
- res.jsonResponse(result);
375
+ if (since(req.version, 4)) {
376
+ const qrcodeFn = req.module.qrcode as (o: QRCodeOptions) => Promise<QRCodeResult>;
377
+ const result = await qrcodeFn({
378
+ url: url as string,
379
+ size: req.query.size ? Number(req.query.size) : undefined,
380
+ margin: req.query.margin ? Number(req.query.margin) : undefined,
381
+ correction: req.query.correction as QRCodeOptions['correction'],
382
+ dark: req.query.dark as string | undefined,
383
+ light: req.query.light as string | undefined,
384
+ icon: req.query.icon as string | undefined,
385
+ iconSize: req.query.iconSize ? Number(req.query.iconSize) : undefined,
386
+ iconPadding: req.query.iconPadding ? Number(req.query.iconPadding) : undefined,
387
+ iconRadius: req.query.iconRadius ? Number(req.query.iconRadius) : undefined,
388
+ format: req.query.format as QRCodeOptions['format'],
389
+ });
390
+ if (result.contentType === 'application/json') {
391
+ res.jsonResponse(result.body);
392
+ } else {
393
+ res.type(result.contentType).send(result.body);
394
+ }
395
+ } else {
396
+ const result = await (req.module.qrcode as (u: string) => Promise<string>)(url as string);
397
+ res.jsonResponse(result);
398
+ }
267
399
  } catch (err) {
268
400
  error(res, 400, (err as Error).message, `${req.version}/qrcode`);
269
401
  }
@@ -0,0 +1,44 @@
1
+ import { Router, type Request, type Response } from 'express';
2
+ import { ticTacToeStorage } from '../storage/index.js';
3
+ import { error } from '../utils/response.js';
4
+
5
+ const router = Router();
6
+
7
+ // Play a tic-tac-toe move
8
+ router.patch('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
9
+ if (req.version !== 'v4') {
10
+ error(res, 405, 'PATCH is only supported in v4+.', `${req.version}/tic-tac-toe`);
11
+ return;
12
+ }
13
+
14
+ const game = req.params.game as string;
15
+ const { username, move, session } = (req.body as Record<string, string>) || {};
16
+
17
+ if (!username) {
18
+ error(res, 400, 'Please provide a username (?username={username})');
19
+ return;
20
+ }
21
+ if (!move) {
22
+ error(res, 400, 'Please provide a valid move (&move={move})');
23
+ return;
24
+ }
25
+ if (!session) {
26
+ error(res, 400, 'Please provide a valid session ID (&session={ID})');
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const result = req.module.tic_tac_toe('play', {
32
+ username,
33
+ move,
34
+ session,
35
+ game,
36
+ storage: ticTacToeStorage,
37
+ });
38
+ res.jsonResponse(result);
39
+ } catch (err) {
40
+ error(res, 400, (err as Error).message);
41
+ }
42
+ });
43
+
44
+ export default router;
@@ -2,6 +2,8 @@ import { Router, type Request, type Response } from 'express';
2
2
  import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
3
  import { MIN_TOKEN_LENGTH, MAX_TOKEN_LENGTH } from '../constants.js';
4
4
  import { error } from '../utils/response.js';
5
+ import { since } from '../utils/helpers.js';
6
+ import type { HashResult } from '../modules/v4/hash.js';
5
7
 
6
8
  const router = Router();
7
9
 
@@ -64,7 +66,7 @@ router.post('/:version/chat/private', (req: Request, res: Response) => {
64
66
 
65
67
  // Generate hash
66
68
  router.post('/:version/hash', (req: Request, res: Response) => {
67
- const { text, method } = (req.body as Record<string, string>) || {};
69
+ const { text, method, encoding } = (req.body as Record<string, string>) || {};
68
70
 
69
71
  if (!text) {
70
72
  error(res, 400, 'Please provide a text (?text={text})', `${req.version}/hash`);
@@ -76,8 +78,14 @@ router.post('/:version/hash', (req: Request, res: Response) => {
76
78
  }
77
79
 
78
80
  try {
79
- const result = req.module.hash(text, method);
80
- res.jsonResponse(result);
81
+ if (since(req.version, 4)) {
82
+ const hashFn = req.module.hash as (t: string, m: string, e?: string) => HashResult;
83
+ const result = hashFn(text, method, encoding);
84
+ res.jsonResponse(result);
85
+ } else {
86
+ const result = (req.module.hash as (t: string, m: string) => Record<string, string>)(text, method);
87
+ res.jsonResponse(result);
88
+ }
81
89
  } catch (err) {
82
90
  error(res, 400, (err as Error).message, `${req.version}/hash`);
83
91
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Parses a hex color string into an RGB tuple.
3
+ *
4
+ * @param hex - Hex color string (#RRGGBB or RRGGBB)
5
+ * @returns RGB tuple [r, g, b] with values 0-255
6
+ * @throws Error if the hex string is invalid
7
+ */
8
+ export function hexToRgb(hex: string): [number, number, number] {
9
+ const clean = hex.replace('#', '');
10
+ if (!/^[0-9a-fA-F]{6}$/.test(clean)) throw new Error('Invalid HEX color (use #RRGGBB)');
11
+ return [parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16)];
12
+ }
13
+
14
+ /**
15
+ * Converts HSL values to an RGB tuple.
16
+ *
17
+ * @param h - Hue in degrees (0-360)
18
+ * @param s - Saturation (0-1)
19
+ * @param l - Lightness (0-1)
20
+ * @returns RGB tuple [r, g, b] with values 0-255
21
+ */
22
+ export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
23
+ const c = (1 - Math.abs(2 * l - 1)) * s;
24
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
25
+ const m = l - c / 2;
26
+ let r1 = 0,
27
+ g1 = 0,
28
+ b1 = 0;
29
+ if (h < 60) [r1, g1, b1] = [c, x, 0];
30
+ else if (h < 120) [r1, g1, b1] = [x, c, 0];
31
+ else if (h < 180) [r1, g1, b1] = [0, c, x];
32
+ else if (h < 240) [r1, g1, b1] = [0, x, c];
33
+ else if (h < 300) [r1, g1, b1] = [x, 0, c];
34
+ else [r1, g1, b1] = [c, 0, x];
35
+ return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
36
+ }
37
+
38
+ /**
39
+ * Validates and normalizes a hex color string, adding # if missing.
40
+ *
41
+ * @param value - Raw color value (with or without #)
42
+ * @param def - Default color if value is empty
43
+ * @returns Normalized hex color string with #
44
+ * @throws Error if the color format is invalid
45
+ */
46
+ export function normalizeColor(value: string | undefined, def: string): string {
47
+ if (!value) return def;
48
+ const clean = value.startsWith('#') ? value : `#${value}`;
49
+ if (!/^#([0-9a-fA-F]{3}){1,2}$/.test(clean)) throw new Error('Invalid color (use hex like ff6600)');
50
+ return clean;
51
+ }
52
+
53
+ /**
54
+ * Formats an RGB tuple as a hex color string.
55
+ *
56
+ * @param r - Red channel (0-255)
57
+ * @param g - Green channel (0-255)
58
+ * @param b - Blue channel (0-255)
59
+ * @returns Hex color string (#rrggbb)
60
+ */
61
+ export function rgbToHex(r: number, g: number, b: number): string {
62
+ return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
63
+ }
64
+
65
+ /**
66
+ * Converts RGB values to an HSL tuple.
67
+ *
68
+ * @param r - Red channel (0-255)
69
+ * @param g - Green channel (0-255)
70
+ * @param b - Blue channel (0-255)
71
+ * @returns HSL tuple [h (0-360), s (0-1), l (0-1)]
72
+ */
73
+ export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
74
+ const r1 = r / 255,
75
+ g1 = g / 255,
76
+ b1 = b / 255;
77
+ const max = Math.max(r1, g1, b1),
78
+ min = Math.min(r1, g1, b1);
79
+ const l = (max + min) / 2;
80
+
81
+ if (max === min) return [0, 0, l];
82
+
83
+ const d = max - min;
84
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
85
+ let h = 0;
86
+ if (max === r1) h = (g1 - b1) / d + (g1 < b1 ? 6 : 0);
87
+ else if (max === g1) h = (b1 - r1) / d + 2;
88
+ else h = (r1 - g1) / d + 4;
89
+
90
+ return [(h * 60 + 360) % 360, s, l];
91
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tracks requests per user and throws if the rate limit is exceeded.
3
+ *
4
+ * @param rateLimits - Store of timestamps per user
5
+ * @param userId - Identifier of the requesting user
6
+ * @param timestamp - Current timestamp in milliseconds
7
+ * @param window - Time window in milliseconds
8
+ * @param limit - Maximum requests allowed in the window
9
+ * @returns false if within limits
10
+ * @throws Error if rate limit is exceeded
11
+ */
12
+ export function checkRateLimit(
13
+ rateLimits: Record<string, number[]>,
14
+ userId: string,
15
+ timestamp: number,
16
+ window = 10000,
17
+ limit = 50,
18
+ ): boolean {
19
+ rateLimits[userId] = (rateLimits[userId] ?? []).filter((ts) => timestamp - ts < window);
20
+
21
+ if (rateLimits[userId]!.length > limit) {
22
+ const remainingTime = Math.ceil((rateLimits[userId]![0]! + window - timestamp) / 1000);
23
+ throw new Error(`Rate limit exceeded. Try again in ${remainingTime} seconds.`);
24
+ }
25
+
26
+ rateLimits[userId]!.push(timestamp);
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Parses a space-separated environment variable into an array.
32
+ *
33
+ * @param key - The environment variable name
34
+ * @returns The parsed array, or null if not set
35
+ */
36
+ export function envList(key: string): string[] | null {
37
+ const v = process.env[key];
38
+ return v && v !== 'undefined' ? v.split(' ') : null;
39
+ }
40
+
41
+ /**
42
+ * Formats a Date to ISO string without timezone suffix.
43
+ *
44
+ * @param date - The date to format
45
+ * @returns ISO-formatted string without trailing Z
46
+ */
47
+ export function formatDate(date: Date): string {
48
+ return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().replace('Z', '');
49
+ }
50
+
51
+ /**
52
+ * Generates a random IPv4 address.
53
+ *
54
+ * @returns A string in the format "X.X.X.X"
55
+ */
56
+ export function genIP(): string {
57
+ return `${randomNumber(0, 255)}.${randomNumber(0, 255)}.${randomNumber(0, 255)}.${randomNumber(0, 255)}`;
58
+ }
59
+
60
+ /**
61
+ * Returns a random element from an array.
62
+ *
63
+ * @param arr - The source array
64
+ * @returns A random element
65
+ */
66
+ export function random<T>(arr: T[]): T {
67
+ return arr[Math.floor(Math.random() * arr.length)]!;
68
+ }
69
+
70
+ /**
71
+ * Returns a random integer between min and max (inclusive).
72
+ *
73
+ * @param min - Lower bound
74
+ * @param max - Upper bound
75
+ * @returns A random integer in [min, max]
76
+ */
77
+ export function randomNumber(min: number, max: number): number {
78
+ return Math.floor(Math.random() * (max - min + 1)) + min;
79
+ }
80
+
81
+ /**
82
+ * Checks if a version string is at least the given minimum.
83
+ *
84
+ * @param version - Version string (e.g. "v4")
85
+ * @param min - Minimum version number
86
+ * @returns true if the version is >= min
87
+ */
88
+ export function since(version: string, min: number): boolean {
89
+ return parseInt(version.replace('v', '')) >= min;
90
+ }
@@ -1,6 +1,14 @@
1
1
  import type { Response } from 'express';
2
2
  import { DOCS_URL, STATUS_MESSAGES } from '../constants.js';
3
3
 
4
+ /**
5
+ * Sends a standardized JSON error response.
6
+ *
7
+ * @param res - Express response object
8
+ * @param status - HTTP status code
9
+ * @param message - Error description
10
+ * @param docPath - Optional documentation path appended to the base URL
11
+ */
4
12
  export function error(res: Response, status: number, message: string, docPath?: string): void {
5
13
  res.status(status).jsonResponse({
6
14
  message: STATUS_MESSAGES[status] ?? 'Error',