@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.
- 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/config/versions.js +10 -0
- package/dist/config/versions.js.map +1 -1
- package/dist/constants.js +1 -2
- 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/v4/captcha.js +60 -23
- package/dist/modules/v4/captcha.js.map +1 -1
- package/dist/modules/v4/chat.js +1 -1
- package/dist/modules/v4/chat.js.map +1 -1
- package/dist/modules/v4/color.js +44 -35
- package/dist/modules/v4/color.js.map +1 -1
- package/dist/modules/v4/colors.js +54 -0
- package/dist/modules/v4/colors.js.map +1 -0
- package/dist/modules/v4/convert.js +93 -19
- package/dist/modules/v4/convert.js.map +1 -1
- package/dist/modules/v4/domain.js +1 -1
- package/dist/modules/v4/domain.js.map +1 -1
- package/dist/modules/v4/hash.js +11 -4
- package/dist/modules/v4/hash.js.map +1 -1
- package/dist/modules/v4/hyperplanning.js +32 -2
- package/dist/modules/v4/hyperplanning.js.map +1 -1
- package/dist/modules/v4/palette.js +2 -43
- package/dist/modules/v4/palette.js.map +1 -1
- package/dist/modules/v4/personal.js +1 -1
- package/dist/modules/v4/personal.js.map +1 -1
- package/dist/modules/v4/placeholder.js +5 -10
- package/dist/modules/v4/placeholder.js.map +1 -1
- package/dist/modules/v4/qrcode.js +79 -6
- package/dist/modules/v4/qrcode.js.map +1 -1
- package/dist/modules/v4/username.js +1 -1
- package/dist/modules/v4/username.js.map +1 -1
- package/dist/routes/delete.js +14 -14
- package/dist/routes/delete.js.map +1 -1
- package/dist/routes/get.js +73 -17
- package/dist/routes/get.js.map +1 -1
- package/dist/routes/post.js +11 -3
- package/dist/routes/post.js.map +1 -1
- package/dist/utils/colors.js +93 -0
- package/dist/utils/colors.js.map +1 -0
- package/dist/utils/helpers.js +77 -0
- package/dist/utils/helpers.js.map +1 -0
- 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/config/versions.ts +10 -0
- package/src/constants.ts +1 -2
- 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/captcha.ts +84 -27
- package/src/modules/v4/chat.ts +1 -1
- package/src/modules/v4/color.ts +56 -52
- package/src/modules/v4/convert.ts +100 -20
- package/src/modules/v4/domain.ts +1 -1
- package/src/modules/v4/hash.ts +18 -4
- package/src/modules/v4/hyperplanning.ts +28 -2
- package/src/modules/v4/palette.ts +3 -43
- package/src/modules/v4/personal.ts +1 -1
- package/src/modules/v4/placeholder.ts +6 -9
- package/src/modules/v4/qrcode.ts +108 -6
- package/src/modules/v4/username.ts +1 -1
- package/src/routes/get.ts +75 -19
- package/src/routes/post.ts +11 -3
- package/src/utils/colors.ts +91 -0
- package/src/utils/helpers.ts +90 -0
- package/src/utils/response.ts +18 -2
- package/tests/integration/api.test.ts +89 -7
- package/tests/unit/captcha.test.ts +50 -12
- package/tests/unit/chat.test.ts +4 -1
- package/tests/unit/color.test.ts +24 -4
- package/tests/unit/convert.test.ts +26 -1
- package/tests/unit/hash.test.ts +17 -10
- package/tests/unit/hyperplanning.test.ts +19 -0
- package/tests/unit/qrcode.test.ts +72 -4
- package/tests/unit/tic_tac_toe.test.ts +4 -1
- package/src/modules/v4/utils.ts +0 -38
package/package.json
CHANGED
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
|
+
}
|
package/src/config/versions.ts
CHANGED
|
@@ -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
|
};
|
package/src/middleware/cors.ts
CHANGED
|
@@ -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')));
|
package/src/middleware/error.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
|
@@ -1,49 +1,106 @@
|
|
|
1
1
|
import { createCanvas } from 'canvas';
|
|
2
|
+
import { normalizeColor } from '../../utils/colors.js';
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
-
|
|
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
|
|
7
|
-
|
|
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 =
|
|
54
|
+
ctx.fillStyle = bg;
|
|
14
55
|
ctx.fillRect(0, 0, width, height);
|
|
15
56
|
|
|
16
|
-
for (let i = 0; i <
|
|
17
|
-
ctx.strokeStyle =
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
ctx.
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
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 =
|
|
32
|
-
|
|
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 +
|
|
36
|
-
ctx.rotate((Math.random() - 0.5) *
|
|
37
|
-
ctx.fillText(text[i]!, -
|
|
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 +=
|
|
96
|
+
x += charWidth;
|
|
41
97
|
}
|
|
42
98
|
|
|
43
|
-
for (let i = 0; i <
|
|
44
|
-
ctx.fillStyle =
|
|
45
|
-
|
|
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
|
}
|
package/src/modules/v4/chat.ts
CHANGED
package/src/modules/v4/color.ts
CHANGED
|
@@ -1,62 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
61
|
+
hex: rgbToHex(r, g, b),
|
|
58
62
|
rgb: `rgb(${r}, ${g}, ${b})`,
|
|
59
|
-
hsl: `hsl(${
|
|
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('%, ')}%)`,
|