@20syldev/api 4.3.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.
Files changed (88) hide show
  1. package/README.md +3 -3
  2. package/dist/config/env.js +5 -1
  3. package/dist/config/env.js.map +1 -1
  4. package/dist/config/plans.js +44 -0
  5. package/dist/config/plans.js.map +1 -0
  6. package/dist/config/versions.js +10 -0
  7. package/dist/config/versions.js.map +1 -1
  8. package/dist/constants.js +1 -2
  9. package/dist/constants.js.map +1 -1
  10. package/dist/middleware/cors.js +7 -2
  11. package/dist/middleware/cors.js.map +1 -1
  12. package/dist/middleware/error.js +3 -1
  13. package/dist/middleware/error.js.map +1 -1
  14. package/dist/middleware/ratelimit.js +39 -21
  15. package/dist/middleware/ratelimit.js.map +1 -1
  16. package/dist/modules/v4/captcha.js +60 -23
  17. package/dist/modules/v4/captcha.js.map +1 -1
  18. package/dist/modules/v4/chat.js +1 -1
  19. package/dist/modules/v4/chat.js.map +1 -1
  20. package/dist/modules/v4/color.js +44 -35
  21. package/dist/modules/v4/color.js.map +1 -1
  22. package/dist/modules/v4/colors.js +54 -0
  23. package/dist/modules/v4/colors.js.map +1 -0
  24. package/dist/modules/v4/convert.js +93 -19
  25. package/dist/modules/v4/convert.js.map +1 -1
  26. package/dist/modules/v4/domain.js +1 -1
  27. package/dist/modules/v4/domain.js.map +1 -1
  28. package/dist/modules/v4/hash.js +11 -4
  29. package/dist/modules/v4/hash.js.map +1 -1
  30. package/dist/modules/v4/hyperplanning.js +32 -2
  31. package/dist/modules/v4/hyperplanning.js.map +1 -1
  32. package/dist/modules/v4/palette.js +2 -43
  33. package/dist/modules/v4/palette.js.map +1 -1
  34. package/dist/modules/v4/personal.js +1 -1
  35. package/dist/modules/v4/personal.js.map +1 -1
  36. package/dist/modules/v4/placeholder.js +5 -10
  37. package/dist/modules/v4/placeholder.js.map +1 -1
  38. package/dist/modules/v4/qrcode.js +79 -6
  39. package/dist/modules/v4/qrcode.js.map +1 -1
  40. package/dist/modules/v4/username.js +1 -1
  41. package/dist/modules/v4/username.js.map +1 -1
  42. package/dist/routes/delete.js +14 -14
  43. package/dist/routes/delete.js.map +1 -1
  44. package/dist/routes/get.js +73 -17
  45. package/dist/routes/get.js.map +1 -1
  46. package/dist/routes/post.js +11 -3
  47. package/dist/routes/post.js.map +1 -1
  48. package/dist/utils/colors.js +93 -0
  49. package/dist/utils/colors.js.map +1 -0
  50. package/dist/utils/helpers.js +77 -0
  51. package/dist/utils/helpers.js.map +1 -0
  52. package/dist/utils/response.js +18 -2
  53. package/dist/utils/response.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/config/env.ts +6 -1
  56. package/src/config/plans.ts +61 -0
  57. package/src/config/versions.ts +10 -0
  58. package/src/constants.ts +1 -2
  59. package/src/middleware/cors.ts +8 -2
  60. package/src/middleware/error.ts +10 -2
  61. package/src/middleware/ratelimit.ts +42 -20
  62. package/src/modules/v4/captcha.ts +84 -27
  63. package/src/modules/v4/chat.ts +1 -1
  64. package/src/modules/v4/color.ts +56 -52
  65. package/src/modules/v4/convert.ts +100 -20
  66. package/src/modules/v4/domain.ts +1 -1
  67. package/src/modules/v4/hash.ts +18 -4
  68. package/src/modules/v4/hyperplanning.ts +28 -2
  69. package/src/modules/v4/palette.ts +3 -43
  70. package/src/modules/v4/personal.ts +1 -1
  71. package/src/modules/v4/placeholder.ts +6 -9
  72. package/src/modules/v4/qrcode.ts +108 -6
  73. package/src/modules/v4/username.ts +1 -1
  74. package/src/routes/get.ts +75 -19
  75. package/src/routes/post.ts +11 -3
  76. package/src/utils/colors.ts +91 -0
  77. package/src/utils/helpers.ts +90 -0
  78. package/src/utils/response.ts +18 -2
  79. package/tests/integration/api.test.ts +89 -7
  80. package/tests/unit/captcha.test.ts +50 -12
  81. package/tests/unit/chat.test.ts +4 -1
  82. package/tests/unit/color.test.ts +24 -4
  83. package/tests/unit/convert.test.ts +26 -1
  84. package/tests/unit/hash.test.ts +17 -10
  85. package/tests/unit/hyperplanning.test.ts +19 -0
  86. package/tests/unit/qrcode.test.ts +72 -4
  87. package/tests/unit/tic_tac_toe.test.ts +4 -1
  88. package/src/modules/v4/utils.ts +0 -38
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.3.0",
2
+ "version": "4.5.0",
3
3
  "name": "@20syldev/api",
4
4
  "description": "Node.js API with multiple features. Check the documentation at https://docs.sylvain.sh",
5
5
  "main": "dist/app.js",
package/src/config/env.ts CHANGED
@@ -18,11 +18,16 @@ export const env = {
18
18
  GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? '',
19
19
 
20
20
  DEFAULT_LIMIT: envNumber('DEFAULT_LIMIT', 2000),
21
+ ADVANCED_LIMIT: envNumber('ADVANCED_LIMIT', 3500),
21
22
  PRO_LIMIT: envNumber('PRO_LIMIT', 6000),
22
- ADVANCED_LIMIT: envNumber('ADVANCED_LIMIT', 4000),
23
23
  BUSINESS_LIMIT: envNumber('BUSINESS_LIMIT', 10000),
24
24
  GLOBAL_LIMIT: envNumber('GLOBAL_LIMIT', 50000),
25
25
 
26
+ DEFAULT_BURST: envNumber('DEFAULT_BURST', 50),
27
+ ADVANCED_BURST: envNumber('ADVANCED_BURST', 80),
28
+ PRO_BURST: envNumber('PRO_BURST', 120),
29
+ BUSINESS_BURST: envNumber('BUSINESS_BURST', 200),
30
+
26
31
  BUSINESS_TOKEN_LIST: envList('BUSINESS_TOKEN_LIST') ?? [],
27
32
  PRO_TOKEN_LIST: envList('PRO_TOKEN_LIST') ?? [],
28
33
  ADVANCED_TOKEN_LIST: envList('ADVANCED_TOKEN_LIST') ?? [],
@@ -0,0 +1,61 @@
1
+ import { env } from './env.js';
2
+
3
+ /**
4
+ * Rate limit plans configuration.
5
+ *
6
+ * hourly — max requests per hour (matches pricing page)
7
+ * burst — max requests per 10-second window
8
+ * tokens — list of bearer tokens for this tier
9
+ *
10
+ * To adjust limits, edit the values below or override via .env:
11
+ * DEFAULT_LIMIT, ADVANCED_LIMIT, PRO_LIMIT, BUSINESS_LIMIT
12
+ * DEFAULT_BURST, ADVANCED_BURST, PRO_BURST, BUSINESS_BURST
13
+ */
14
+
15
+ export interface Plan {
16
+ hourly: number;
17
+ burst: number;
18
+ tokens: string[];
19
+ }
20
+
21
+ export const plans: Record<string, Plan> = {
22
+ business: {
23
+ hourly: env.BUSINESS_LIMIT,
24
+ burst: env.BUSINESS_BURST,
25
+ tokens: env.BUSINESS_TOKEN_LIST,
26
+ },
27
+ pro: {
28
+ hourly: env.PRO_LIMIT,
29
+ burst: env.PRO_BURST,
30
+ tokens: env.PRO_TOKEN_LIST,
31
+ },
32
+ advanced: {
33
+ hourly: env.ADVANCED_LIMIT,
34
+ burst: env.ADVANCED_BURST,
35
+ tokens: env.ADVANCED_TOKEN_LIST,
36
+ },
37
+ default: {
38
+ hourly: env.DEFAULT_LIMIT,
39
+ burst: env.DEFAULT_BURST,
40
+ tokens: [],
41
+ },
42
+ };
43
+
44
+ export const globalLimit = env.GLOBAL_LIMIT; // 50 000/h
45
+
46
+ /**
47
+ * Returns the plan matching a bearer token, or 'default'.
48
+ * Returns null if the token is provided but invalid.
49
+ */
50
+ export function getPlan(token: string): { name: string; plan: Plan } | null {
51
+ if (!token) return { name: 'default', plan: plans.default! };
52
+ if (token === 'undefined') return null;
53
+
54
+ for (const [name, plan] of Object.entries(plans)) {
55
+ if (name === 'default') continue;
56
+ if (plan.tokens.includes('undefined')) continue;
57
+ if (plan.tokens.includes(token)) return { name, plan };
58
+ }
59
+
60
+ return null;
61
+ }
@@ -78,10 +78,20 @@ const v3 = {
78
78
 
79
79
  const v4 = {
80
80
  get: merge(v3.get, [
81
+ {
82
+ name: 'captcha',
83
+ path: '/captcha(&text={text}&length={n}&width={px}&height={px}&noise={low|medium|high}&bg={hex}&color={hex})',
84
+ },
85
+ { name: 'color', path: '/color(&hex={hex})' },
86
+ { name: 'convert', path: '/convert?value={value}&from={unit}&to={unit}' },
81
87
  { name: 'dice', path: '/dice?roll={NdX+M}' },
82
88
  { name: 'encode', path: '/encode?method={method}&text={text}(&shift={shift})' },
83
89
  { name: 'geo', path: '/geo?lat1={lat}&lon1={lon}&lat2={lat}&lon2={lon}' },
84
90
  { name: 'palette', path: '/palette?color={#hex}&type={type}' },
91
+ {
92
+ name: 'qrcode',
93
+ path: '/qrcode?url={URL}(&size={px}&margin={n}&correction={L|M|Q|H}&dark={hex}&light={hex}&icon={URL}&iconSize={px}&iconPadding={px}&iconRadius={px}&format={png|base64})',
94
+ },
85
95
  {
86
96
  name: 'placeholder',
87
97
  path: '/placeholder?type={image|skeleton}&width={w}&height={h}(&bg={hex}&color={hex}&text={text}&rows={n}&avatar={bool})',
package/src/constants.ts CHANGED
@@ -14,8 +14,6 @@ export const MAX_GCD_VALUE = 100_000;
14
14
  export const MAX_PRIME_LIST = 10_000;
15
15
  export const MAX_LEVENSHTEIN_LENGTH = 1000;
16
16
  export const MAX_STRING_LENGTH = 1000;
17
- export const MIN_TEMPERATURE = -273.15;
18
- export const MAX_TEMPERATURE = 1_000_000;
19
17
  export const DOCS_URL = 'https://docs.sylvain.sh';
20
18
  export const DEFAULT_PORT = 3000;
21
19
 
@@ -24,6 +22,7 @@ export const STATUS_MESSAGES: Record<number, string> = {
24
22
  401: 'Unauthorized',
25
23
  404: 'Not Found',
26
24
  405: 'Method Not Allowed',
25
+ 413: 'Payload Too Large',
27
26
  429: 'Too Many Requests',
28
27
  500: 'Internal Server Error',
29
28
  };
@@ -9,8 +9,14 @@ const __dirname = dirname(__filename);
9
9
  export function setupCors(app: Express): void {
10
10
  app.set('trust proxy', 1);
11
11
  app.use(cors({ methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'] }));
12
- app.use(express.urlencoded({ extended: true }));
13
- app.use(express.json());
12
+ app.use(express.urlencoded({ extended: true, limit: '10kb' }));
13
+ app.use(express.json({ limit: '10kb' }));
14
+
15
+ app.use((_req, res, next) => {
16
+ res.setHeader('X-Content-Type-Options', 'nosniff');
17
+ res.setHeader('X-Frame-Options', 'DENY');
18
+ next();
19
+ });
14
20
 
15
21
  app.use('/favicon.ico', express.static(join(__dirname, '..', '..', 'src', 'favicon.ico')));
16
22
  app.use('/robots.txt', express.static(join(__dirname, '..', '..', 'robots.txt')));
@@ -1,7 +1,15 @@
1
1
  import type { Request, Response, NextFunction } from 'express';
2
2
  import { error } from '../utils/response.js';
3
3
 
4
- export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
4
+ export function errorHandler(
5
+ err: Error & { status?: number },
6
+ _req: Request,
7
+ res: Response,
8
+ _next: NextFunction,
9
+ ): void {
5
10
  console.error(err.stack);
6
- error(res, 500, err.message);
11
+
12
+ const status = err.status && err.status >= 400 && err.status < 600 ? err.status : 500;
13
+ const sensitive = status === 500 && /\/[a-z]|\\[a-z]|ECONNREFUSED|ENOENT|EPERM|at\s+\w/i.test(err.message);
14
+ error(res, status, sensitive ? 'An unexpected error occurred.' : err.message);
7
15
  }
@@ -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 = (req.headers['cf-connecting-ip'] as string) || req.socket.remoteAddress || '';
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
- if (
19
- (token && ![...env.BUSINESS_TOKEN_LIST, ...env.PRO_TOKEN_LIST, ...env.ADVANCED_TOKEN_LIST].includes(token)) ||
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
- let requestLimit: number;
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(env.GLOBAL_LIMIT, requestLimit)) {
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
- if (ipLimits[ip]![hour]![minute]! > requestLimit) {
57
- error(res, 429, `You have exceeded the limit of ${requestLimit} requests per hour.`);
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
 
@@ -1,49 +1,106 @@
1
1
  import { createCanvas } from 'canvas';
2
+ import { normalizeColor } from '../../utils/colors.js';
2
3
 
3
- export default function captcha(text: string): Buffer {
4
- if (!text) throw new Error('Text is required to generate a captcha.');
4
+ export interface CaptchaOptions {
5
+ text?: string;
6
+ length?: number;
7
+ width?: number;
8
+ height?: number;
9
+ noise?: 'low' | 'medium' | 'high';
10
+ bg?: string;
11
+ color?: string;
12
+ }
13
+
14
+ export interface CaptchaResult {
15
+ contentType: string;
16
+ body: Buffer;
17
+ text: string;
18
+ }
19
+
20
+ const NOISE_LEVELS = new Set(['low', 'medium', 'high']);
21
+ const NOISE_CONFIG = { low: { lines: 8, dots: 80 }, medium: { lines: 25, dots: 300 }, high: { lines: 50, dots: 600 } };
22
+ const CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
23
+
24
+ function generateText(length: number): string {
25
+ let result = '';
26
+ for (let i = 0; i < length; i++) {
27
+ result += CHARSET[Math.floor(Math.random() * CHARSET.length)];
28
+ }
29
+ return result;
30
+ }
31
+
32
+ function clamp(value: number | undefined, name: string, def: number, min: number, max: number): number {
33
+ if (value === undefined) return def;
34
+ if (isNaN(value)) throw new Error(`${name} must be a number`);
35
+ if (value < min || value > max) throw new Error(`${name} must be between ${min} and ${max}`);
36
+ return Math.floor(value);
37
+ }
38
+
39
+ export default function captcha(options: CaptchaOptions): CaptchaResult {
40
+ const text = options.text || generateText(clamp(options.length, 'length', 6, 1, 20));
41
+ const height = clamp(options.height, 'height', 120, 50, 400);
42
+ const width = clamp(options.width, 'width', text.length * 60, 100, 800);
43
+
44
+ const noise = options.noise ?? 'medium';
45
+ if (!NOISE_LEVELS.has(noise)) throw new Error('noise must be one of: low, medium, high');
46
+ const { lines, dots } = NOISE_CONFIG[noise];
47
+
48
+ const bg = normalizeColor(options.bg, '#ffffff');
49
+ const baseColor = options.color ? normalizeColor(options.color, '#000000') : undefined;
5
50
 
6
- const size = 60,
7
- font = '60px Comic Sans Ms',
8
- width = text.length * size,
9
- height = 120;
10
- const canvas = createCanvas(width, height),
11
- ctx = canvas.getContext('2d');
51
+ const canvas = createCanvas(width, height);
52
+ const ctx = canvas.getContext('2d');
12
53
 
13
- ctx.fillStyle = 'white';
54
+ ctx.fillStyle = bg;
14
55
  ctx.fillRect(0, 0, width, height);
15
56
 
16
- for (let i = 0; i < 20; i++) {
17
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
57
+ for (let i = 0; i < lines; i++) {
58
+ ctx.strokeStyle = `rgba(${Math.floor(Math.random() * 180)}, ${Math.floor(Math.random() * 180)}, ${Math.floor(Math.random() * 180)}, ${0.3 + Math.random() * 0.4})`;
18
59
  ctx.beginPath();
19
- ctx.moveTo(Math.random() * width, Math.random() * height);
20
- ctx.lineTo(Math.random() * width, Math.random() * height);
21
- ctx.lineWidth = Math.random() * 2;
60
+ const x0 = Math.random() * width,
61
+ y0 = Math.random() * height;
62
+ ctx.moveTo(x0, y0);
63
+ ctx.bezierCurveTo(
64
+ Math.random() * width,
65
+ Math.random() * height,
66
+ Math.random() * width,
67
+ Math.random() * height,
68
+ Math.random() * width,
69
+ Math.random() * height,
70
+ );
71
+ ctx.lineWidth = 1 + Math.random() * 2;
22
72
  ctx.stroke();
23
73
  }
24
74
 
25
- let x = (canvas.width + 20 - width) / 2;
75
+ const baseFontSize = Math.floor(height * 0.5);
76
+ const charWidth = width / (text.length + 0.5);
77
+ let x = charWidth * 0.25;
26
78
 
27
79
  for (let i = 0; i < text.length; i++) {
28
- const offsetX = Math.cos(i * 0.3) * 10,
29
- y = height / 2.5 + Math.floor(Math.random() * (height / 2));
80
+ const size = Math.floor(baseFontSize * (0.8 + Math.random() * 0.4));
81
+ const y = height / 2 + (Math.random() - 0.5) * height * 0.3;
30
82
 
31
- ctx.font = font;
32
- ctx.fillStyle = `rgb(${Math.floor(Math.random() * 192)}, ${Math.floor(Math.random() * 192)}, ${Math.floor(Math.random() * 192)})`;
83
+ ctx.font = `${size}px Comic Sans Ms`;
84
+ if (baseColor) {
85
+ ctx.fillStyle = baseColor;
86
+ } else {
87
+ ctx.fillStyle = `rgb(${Math.floor(Math.random() * 180)}, ${Math.floor(Math.random() * 180)}, ${Math.floor(Math.random() * 180)})`;
88
+ }
33
89
 
34
90
  ctx.save();
35
- ctx.translate(x + size / 2, y);
36
- ctx.rotate((Math.random() - 0.5) * 0.5);
37
- ctx.fillText(text[i]!, -size / 2 + offsetX, 0);
91
+ ctx.translate(x + charWidth / 2, y);
92
+ ctx.rotate((Math.random() - 0.5) * 1.2);
93
+ ctx.fillText(text[i]!, -charWidth / 2, 0);
38
94
  ctx.restore();
39
95
 
40
- x += size;
96
+ x += charWidth;
41
97
  }
42
98
 
43
- for (let i = 0; i < 200; i++) {
44
- ctx.fillStyle = 'black';
45
- ctx.fillRect(Math.floor(Math.random() * width), Math.floor(Math.random() * height), 1.2, 1.2);
99
+ for (let i = 0; i < dots; i++) {
100
+ ctx.fillStyle = `rgba(${Math.floor(Math.random() * 180)}, ${Math.floor(Math.random() * 180)}, ${Math.floor(Math.random() * 180)}, ${0.4 + Math.random() * 0.5})`;
101
+ const dotSize = 1 + Math.random() * 2;
102
+ ctx.fillRect(Math.floor(Math.random() * width), Math.floor(Math.random() * height), dotSize, dotSize);
46
103
  }
47
104
 
48
- return canvas.toBuffer('image/png');
105
+ return { contentType: 'image/png', body: canvas.toBuffer('image/png'), text };
49
106
  }
@@ -1,4 +1,4 @@
1
- import { checkRateLimit } from './utils.js';
1
+ import { checkRateLimit } from '../../utils/helpers.js';
2
2
  import { SESSION_TTL } from '../../constants.js';
3
3
  import type { ChatStorage, ChatMessage } from '../../types/storage.js';
4
4
 
@@ -1,62 +1,66 @@
1
- export default function color(): Record<string, string> {
2
- const r = Math.floor(Math.random() * 256),
3
- g = Math.floor(Math.random() * 256),
4
- b = Math.floor(Math.random() * 256);
1
+ import { hexToRgb, rgbToHsl, rgbToHex } from '../../utils/colors.js';
2
+
3
+ export interface ColorResult {
4
+ hex: string;
5
+ rgb: string;
6
+ hsl: string;
7
+ hsv: string;
8
+ hwb: string;
9
+ cmyk: string;
10
+ }
11
+
12
+ function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
13
+ const max = Math.max(r, g, b),
14
+ min = Math.min(r, g, b),
15
+ v = max / 255,
16
+ s = max ? (max - min) / max : 0;
17
+
18
+ if (max === min) return [0, s * 100, v * 100];
19
+
20
+ let h = 0;
21
+ const d = max - min;
22
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
23
+ else if (max === g) h = (b - r) / d + 2;
24
+ else h = (r - g) / d + 4;
5
25
 
6
- const hsl = ((): [number, number, number] => {
7
- const r1 = r / 255,
8
- g1 = g / 255,
9
- b1 = b / 255,
10
- max = Math.max(r1, g1, b1),
11
- min = Math.min(r1, g1, b1),
12
- l = (max + min) / 2;
13
- if (max === min) return [0, 0, l * 100];
14
- const d = max - min,
15
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
16
- const h =
17
- {
18
- [r1]: (g1 - b1) / d + (g1 < b1 ? 6 : 0),
19
- [g1]: (b1 - r1) / d + 2,
20
- [b1]: (r1 - g1) / d + 4,
21
- }[max] ?? 0;
22
- return [(h * 60) % 360, s * 100, l * 100];
23
- })();
26
+ return [(h * 60) % 360, s * 100, v * 100];
27
+ }
28
+
29
+ function rgbToHwb(r: number, g: number, b: number): [number, number, number] {
30
+ const [h] = rgbToHsv(r, g, b);
31
+ const whiteness = Math.min(r, g, b) / 255;
32
+ const blackness = 1 - Math.max(r, g, b) / 255;
33
+ return [h, whiteness * 100, blackness * 100];
34
+ }
24
35
 
25
- const hsv = ((): [number, number, number] => {
26
- const max = Math.max(r, g, b),
27
- min = Math.min(r, g, b),
28
- v = max / 255,
29
- s = max ? (max - min) / max : 0;
30
- const h =
31
- max === min
32
- ? 0
33
- : ({
34
- [r]: (g - b) / (max - min),
35
- [g]: 2 + (b - r) / (max - min),
36
- [b]: 4 + (r - g) / (max - min),
37
- }[max] ?? 0);
38
- return [(h * 60) % 360, s * 100, v * 100];
39
- })();
36
+ function rgbToCmyk(r: number, g: number, b: number): [number, number, number, number] {
37
+ const k = 1 - Math.max(r, g, b) / 255;
38
+ const c = (1 - r / 255 - k) / (1 - k) || 0;
39
+ const m = (1 - g / 255 - k) / (1 - k) || 0;
40
+ const y = (1 - b / 255 - k) / (1 - k) || 0;
41
+ return [c * 100, m * 100, y * 100, k * 100];
42
+ }
43
+
44
+ export default function color(hex?: string): ColorResult {
45
+ let r: number, g: number, b: number;
40
46
 
41
- const hwb = ((): [number, number, number] => {
42
- const [h] = hsv,
43
- whiteness = Math.min(r, g, b) / 255,
44
- blackness = 1 - Math.max(r, g, b) / 255;
45
- return [h, whiteness * 100, blackness * 100];
46
- })();
47
+ if (hex) {
48
+ [r, g, b] = hexToRgb(hex);
49
+ } else {
50
+ r = Math.floor(Math.random() * 256);
51
+ g = Math.floor(Math.random() * 256);
52
+ b = Math.floor(Math.random() * 256);
53
+ }
47
54
 
48
- const cmyk = ((): [number, number, number, number] => {
49
- const k = 1 - Math.max(r, g, b) / 255,
50
- c = (1 - r / 255 - k) / (1 - k) || 0,
51
- m = (1 - g / 255 - k) / (1 - k) || 0,
52
- y = (1 - b / 255 - k) / (1 - k) || 0;
53
- return [c * 100, m * 100, y * 100, k * 100];
54
- })();
55
+ const [h, s, l] = rgbToHsl(r, g, b);
56
+ const hsv = rgbToHsv(r, g, b);
57
+ const hwb = rgbToHwb(r, g, b);
58
+ const cmyk = rgbToCmyk(r, g, b);
55
59
 
56
60
  return {
57
- hex: `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`,
61
+ hex: rgbToHex(r, g, b),
58
62
  rgb: `rgb(${r}, ${g}, ${b})`,
59
- hsl: `hsl(${hsl[0].toFixed(1)}, ${hsl[1].toFixed(1)}%, ${hsl[2].toFixed(1)}%)`,
63
+ hsl: `hsl(${h.toFixed(1)}, ${(s * 100).toFixed(1)}%, ${(l * 100).toFixed(1)}%)`,
60
64
  hsv: `hsv(${hsv[0].toFixed(1)}, ${hsv[1].toFixed(1)}%, ${hsv[2].toFixed(1)}%)`,
61
65
  hwb: `hwb(${hwb[0].toFixed(1)}, ${hwb[1].toFixed(1)}%, ${hwb[2].toFixed(1)}%)`,
62
66
  cmyk: `cmyk(${cmyk.map((x) => x.toFixed(1)).join('%, ')}%)`,