neekuro 2.0.4 → 2.1.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 +36 -2
- package/package.json +12 -7
- package/sources/classes/SFW.js +5 -3
- package/sources/classes/errors/{APIerror.js → APIError.js} +14 -12
- package/sources/classes/errors/Error.js +5 -4
- package/sources/classes/package/NekoGif.js +7 -4
- package/sources/classes/package/{Welcome.js → welcome.js} +162 -96
- package/sources/classes/user/BaseUser.js +5 -2
- package/sources/classes/user/User.js +7 -7
- package/sources/main/index.js +6 -4
- package/sources/rest/APIClient.js +71 -30
- package/sources/typings/index.d.ts +21 -16
- package/sources/utils/imageLoader.js +146 -0
package/README.md
CHANGED
|
@@ -83,13 +83,47 @@ const welcome = new Welcome()
|
|
|
83
83
|
.setDescription('Al servidor de Discord');
|
|
84
84
|
|
|
85
85
|
// Genera y guarda la imagen
|
|
86
|
-
welcome.build()
|
|
86
|
+
welcome.build()
|
|
87
87
|
.then(image => fs.writeFileSync('bienvenida.png', image))
|
|
88
88
|
.then(() => console.log('Imagen creada correctamente!'))
|
|
89
89
|
.catch(err => console.error('Error:', err.message));
|
|
90
90
|
|
|
91
91
|
// nota: como antes, todo viene bien documentado en el paquete, solo sigue los pasos y explora por ti ;3
|
|
92
|
-
```
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Las imágenes remotas deben usar HTTPS. El paquete limita cada descarga a 10 MB,
|
|
95
|
+
aplica un tiempo máximo de 10 segundos, comprueba el formato y bloquea direcciones
|
|
96
|
+
locales o privadas. También puedes proporcionar un `Buffer` JPEG, PNG, GIF o WebP.
|
|
97
|
+
|
|
98
|
+
La resolución admite valores enteros entre 128 y 4096 píxeles. Para regresar al
|
|
99
|
+
tamaño original de 1140 × 520 puedes usar:
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
welcome.setResolution('default', 'default');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Por defecto, el avatar, el título y la descripción forman una columna centrada
|
|
106
|
+
horizontal y verticalmente. El tamaño del avatar y de los textos se adapta cuando
|
|
107
|
+
la resolución es pequeña. Si necesitas coordenadas personalizadas puedes activar
|
|
108
|
+
el modo manual directamente o proporcionar `x`/`y` en cualquiera de los elementos:
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
welcome.setLayout('manual');
|
|
112
|
+
|
|
113
|
+
// Para regresar a la distribución automática:
|
|
114
|
+
welcome.setLayout('center');
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Desarrollo
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
pnpm install
|
|
121
|
+
pnpm run check
|
|
122
|
+
pnpm run pack:check
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`pnpm run check` ejecuta las pruebas funcionales locales y comprueba las
|
|
126
|
+
declaraciones TypeScript. Ninguna de estas pruebas consulta la API remota.
|
|
93
127
|
<hr>
|
|
94
128
|
<center>
|
|
95
129
|
<p>Resultado esperado</p>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "neekuro",
|
|
4
|
-
"version": "2.0
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"description": "Una biblioteca de imágenes con temática de anime para bots de Discord.",
|
|
6
6
|
"main": "sources/main/index.js",
|
|
7
7
|
"types": "sources/typings/index.d.ts",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"contributors": [
|
|
28
|
-
"cacao0027 <
|
|
28
|
+
"cacao0027 <kmzkuro@gmail.com>"
|
|
29
29
|
],
|
|
30
|
-
"author": "cacao0027 <
|
|
30
|
+
"author": "cacao0027 <kmzkuro@gmail.com>",
|
|
31
31
|
"keywords": [
|
|
32
32
|
"anime",
|
|
33
33
|
"acción",
|
|
@@ -40,17 +40,22 @@
|
|
|
40
40
|
"images"
|
|
41
41
|
],
|
|
42
42
|
"license": "ISC",
|
|
43
|
+
"packageManager": "pnpm@11.23.0",
|
|
43
44
|
"scripts": {
|
|
44
|
-
"test": "
|
|
45
|
+
"test": "node --test test/*.test.js",
|
|
46
|
+
"check": "node --check sources/main/index.js && node --test test/*.test.js && tsc -p test/tsconfig.json",
|
|
47
|
+
"pack:check": "npm pack --dry-run"
|
|
45
48
|
},
|
|
46
49
|
"engines": {
|
|
47
50
|
"node": ">=20.12.2"
|
|
48
51
|
},
|
|
49
52
|
"dependencies": {
|
|
50
53
|
"canvas": "^3.1.0",
|
|
51
|
-
"hex-color-regex": "^1.1.0"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
"hex-color-regex": "^1.1.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^24.0.0",
|
|
58
|
+
"typescript": "^5.9.0"
|
|
54
59
|
},
|
|
55
60
|
"repository": {
|
|
56
61
|
"type": "git",
|
package/sources/classes/SFW.js
CHANGED
|
@@ -23,11 +23,13 @@ class SFW {
|
|
|
23
23
|
* @param {GifMap[T]} gif corresponde al gif que quieras obtener por categoria
|
|
24
24
|
* @returns {Promise<NekoGif>}
|
|
25
25
|
*/
|
|
26
|
-
static async getGif(cat, gif) {
|
|
26
|
+
static async getGif(cat, gif) {
|
|
27
27
|
if (!cat) throw new NekoError('NoInput', 'el parametro <cat> es requerido');
|
|
28
28
|
if (!gif) throw new NekoError('NoInput', 'el parametro <gif> es requerido');
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const token = getToken();
|
|
31
|
+
if (!token) throw new NekoError('NoToken', 'Debes establecer un token antes de solicitar un GIF');
|
|
32
|
+
let solicitud = new APIClient(base, { token }), response = null;
|
|
31
33
|
switch (cat) {
|
|
32
34
|
case 'action':
|
|
33
35
|
response = solicitud.get('/action/' + gif + '?random=true')
|
|
@@ -42,4 +44,4 @@ class SFW {
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
module.exports = SFW
|
|
47
|
+
module.exports = SFW
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @typedef {import('../../typings').ApiResponse} ApiResponse
|
|
5
|
-
* @typedef {
|
|
5
|
+
* @typedef {{url?: string, status?: number}} ResponseLike
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -14,15 +14,13 @@ class APIError extends Error {
|
|
|
14
14
|
/**
|
|
15
15
|
* Crea la instancia del error
|
|
16
16
|
* @param {string} endpoint la ruta a la que se realizo la peticion
|
|
17
|
-
* @param {
|
|
17
|
+
* @param {ResponseLike} response respuesta HTTP o representación de un error de red
|
|
18
18
|
* @param {ApiResponse} data respuesta de la api
|
|
19
19
|
*/
|
|
20
|
-
constructor(endpoint, response, data) {
|
|
21
|
-
super();
|
|
20
|
+
constructor(endpoint, response, data) {
|
|
21
|
+
super(APIError.determineMessage(data));
|
|
22
22
|
|
|
23
23
|
this.name = `NekoREST Error [${endpoint}]`
|
|
24
|
-
this.message = this.#determineMessage(data);
|
|
25
|
-
|
|
26
24
|
/**
|
|
27
25
|
* @type {string}
|
|
28
26
|
*/
|
|
@@ -31,7 +29,7 @@ class APIError extends Error {
|
|
|
31
29
|
/**
|
|
32
30
|
* @type {number}
|
|
33
31
|
*/
|
|
34
|
-
this.statusCode = parseInt(response?.status ?? 500);
|
|
32
|
+
this.statusCode = Number.parseInt(response?.status ?? 500, 10);
|
|
35
33
|
|
|
36
34
|
/**
|
|
37
35
|
* @type {ApiResponse}
|
|
@@ -45,7 +43,7 @@ class APIError extends Error {
|
|
|
45
43
|
* @param {ApiResponse} response respuesta de la api
|
|
46
44
|
* @returns {string} mensaje de error
|
|
47
45
|
*/
|
|
48
|
-
|
|
46
|
+
static determineMessage(response = {}) {
|
|
49
47
|
if(response.code === 403) {
|
|
50
48
|
return 'TOKEN ERROR: el token que haz proporcionado es invalido'
|
|
51
49
|
} else
|
|
@@ -54,7 +52,11 @@ class APIError extends Error {
|
|
|
54
52
|
} else {
|
|
55
53
|
return response.message ?? 'descripcion no dada'
|
|
56
54
|
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static fromNetworkError(endpoint, url, message) {
|
|
58
|
+
return new APIError(endpoint, { url, status: 503 }, { code: 503, message });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = APIError
|
|
@@ -10,16 +10,17 @@ class NekoError extends Error {
|
|
|
10
10
|
* @param {string} name Corresponde al nombre del error
|
|
11
11
|
* @param {string} message Mensaje de error
|
|
12
12
|
*/
|
|
13
|
-
constructor(name, message) {
|
|
14
|
-
super(message);
|
|
13
|
+
constructor(name, message) {
|
|
14
|
+
super(message);
|
|
15
15
|
|
|
16
16
|
this.name = `NeeKuro Error [${name}]`
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* @type {string}
|
|
20
20
|
*/
|
|
21
|
-
this.message = message
|
|
21
|
+
this.message = message
|
|
22
|
+
Error.captureStackTrace?.(this, NekoError);
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
module.exports = NekoError
|
|
26
|
+
module.exports = NekoError
|
|
@@ -14,19 +14,22 @@ class NekoGif {
|
|
|
14
14
|
* Recibe la respuesta de la api
|
|
15
15
|
* @param {GifResponse} response respuesta de la api
|
|
16
16
|
*/
|
|
17
|
-
constructor(response) {
|
|
17
|
+
constructor(response) {
|
|
18
|
+
if (!response || typeof response !== 'object' || !response.data) {
|
|
19
|
+
throw new TypeError('La respuesta no contiene datos de imagen');
|
|
20
|
+
}
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* URL del gif
|
|
21
24
|
* @type {string|null}
|
|
22
25
|
*/
|
|
23
|
-
this.url = response.data.url
|
|
26
|
+
this.url = typeof response.data.url === 'string' ? response.data.url : null
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Anime del gif
|
|
27
30
|
* @type {string|null}
|
|
28
31
|
*/
|
|
29
|
-
this.anime = response.data.anime
|
|
32
|
+
this.anime = typeof response.data.anime === 'string' ? response.data.anime : null
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|
|
@@ -47,4 +50,4 @@ class NekoGif {
|
|
|
47
50
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
module.exports = NekoGif
|
|
53
|
+
module.exports = NekoGif
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
const Canvas = require('canvas');
|
|
3
3
|
const NekoError = require('../errors/Error.js');
|
|
4
4
|
const hex_reg = require('hex-color-regex')({ strict: true })
|
|
5
|
-
const path = require('path');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { loadImageSource, parseRemoteURL, validateImageBuffer } = require('../../utils/imageLoader.js');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_WIDTH = 1140;
|
|
10
|
+
const DEFAULT_HEIGHT = 520;
|
|
11
|
+
const MIN_DIMENSION = 128;
|
|
12
|
+
const MAX_DIMENSION = 4096;
|
|
13
|
+
const registeredFonts = new Set();
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
16
|
* @typedef {import('../../typings').FontNames} FontNames
|
|
@@ -29,7 +37,8 @@ class Welcome {
|
|
|
29
37
|
constructor() {
|
|
30
38
|
/**
|
|
31
39
|
* @property {Object} data - Configuración actual de la imagen
|
|
32
|
-
* @property {string} data.font - Fuente actualmente seleccionada
|
|
40
|
+
* @property {string} data.font - Fuente actualmente seleccionada
|
|
41
|
+
* @property {'center'|'manual'} data.layout - Distribución de los elementos
|
|
33
42
|
* @property {number} data.width - Ancho actual de la imagen
|
|
34
43
|
* @property {number} data.height - Alto actual de la imagen
|
|
35
44
|
* @property {Object} data.background - Configuración del fondo
|
|
@@ -54,10 +63,11 @@ class Welcome {
|
|
|
54
63
|
* @property {number} data.description.font_size - Tamaño de fuente de la descripción
|
|
55
64
|
* @property {string} data.description.text_color - Color del texto de la descripción
|
|
56
65
|
*/
|
|
57
|
-
this.data = {
|
|
58
|
-
font: 'arial',
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
this.data = {
|
|
67
|
+
font: 'arial',
|
|
68
|
+
layout: 'center',
|
|
69
|
+
width: DEFAULT_WIDTH,
|
|
70
|
+
height: DEFAULT_HEIGHT,
|
|
61
71
|
background: {
|
|
62
72
|
type: 'color',
|
|
63
73
|
value: '#23272A'
|
|
@@ -83,8 +93,9 @@ class Welcome {
|
|
|
83
93
|
font_size: 35,
|
|
84
94
|
text_color: "#F7F7F7"
|
|
85
95
|
}
|
|
86
|
-
}
|
|
87
|
-
|
|
96
|
+
}
|
|
97
|
+
this.setFont('arial');
|
|
98
|
+
}
|
|
88
99
|
|
|
89
100
|
/**
|
|
90
101
|
* Establece la resolución de la imagen de bienvenida
|
|
@@ -99,22 +110,28 @@ class Welcome {
|
|
|
99
110
|
* // Restablecer a valores por defecto
|
|
100
111
|
* welcome.setResolution('default', 'default');
|
|
101
112
|
*/
|
|
102
|
-
setResolution(width, height) {
|
|
113
|
+
setResolution(width, height) {
|
|
103
114
|
if (!width) throw new NekoError('ResolutionError', 'el parametro <width> no puede quedar vacio');
|
|
104
115
|
if (!height) throw new NekoError('ResolutionError', 'el parametro <height> no puede quedar vacio');
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
this.data.width = width === 'default' ? DEFAULT_WIDTH : validateDimension(width, 'width');
|
|
118
|
+
this.data.height = height === 'default' ? DEFAULT_HEIGHT : validateDimension(height, 'height');
|
|
119
|
+
|
|
120
|
+
return this
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Selecciona la distribución automática centrada o las coordenadas manuales.
|
|
125
|
+
* @param {'center'|'manual'} layout
|
|
126
|
+
* @returns {Welcome}
|
|
127
|
+
*/
|
|
128
|
+
setLayout(layout) {
|
|
129
|
+
if (!['center', 'manual'].includes(layout)) {
|
|
130
|
+
throw new NekoError('LayoutError', 'El layout debe ser "center" o "manual"');
|
|
131
|
+
}
|
|
132
|
+
this.data.layout = layout;
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
118
135
|
|
|
119
136
|
/**
|
|
120
137
|
* Configura la tipografía para el texto de la imagen
|
|
@@ -145,13 +162,18 @@ class Welcome {
|
|
|
145
162
|
case 'custom':
|
|
146
163
|
if (!dir) throw new NekoError('FontError', 'el parametro <dir> no puede quedar vacio en un tipo de fuente customizada');
|
|
147
164
|
if (typeof dir != 'string') throw new NekoError('TypeError', 'el parametro <dir> solo acepta tipo \'string\', recibió ' + typeof dir);
|
|
148
|
-
if (
|
|
149
|
-
ruta = path.
|
|
165
|
+
if (path.extname(dir).toLowerCase() !== '.ttf') throw new NekoError('FontError', 'se necesita un archivo TTF en el parametro <dir>')
|
|
166
|
+
ruta = path.resolve(dir);
|
|
150
167
|
break;
|
|
151
168
|
default: throw new NekoError('FontError', 'el parametro <name> no acepta ningun tipo diferente a los preterminados');
|
|
152
169
|
}
|
|
153
170
|
try {
|
|
154
|
-
|
|
171
|
+
if (!fs.existsSync(ruta)) throw new Error('Font not found');
|
|
172
|
+
const fontKey = `${name}:${ruta}`;
|
|
173
|
+
if (!registeredFonts.has(fontKey)) {
|
|
174
|
+
Canvas.registerFont(ruta, { family: name });
|
|
175
|
+
registeredFonts.add(fontKey);
|
|
176
|
+
}
|
|
155
177
|
} catch (error) {
|
|
156
178
|
throw new NekoError('DirError', 'no se puede encontrar el directorio especificado')
|
|
157
179
|
}
|
|
@@ -206,18 +228,20 @@ class Welcome {
|
|
|
206
228
|
}
|
|
207
229
|
|
|
208
230
|
// validacion de posiciones
|
|
209
|
-
if (data.x) {
|
|
231
|
+
if (data.x !== undefined) {
|
|
210
232
|
if (typeof data.x !== 'number') {
|
|
211
233
|
throw new NekoError('TypeError', `La propiedad <x> solo acepta tipo 'number', recibió ${typeof data.x}`);
|
|
212
234
|
}
|
|
213
|
-
this.data.title.x = data.x;
|
|
235
|
+
this.data.title.x = data.x;
|
|
236
|
+
this.data.layout = 'manual';
|
|
214
237
|
}
|
|
215
238
|
|
|
216
|
-
if (data.y) {
|
|
239
|
+
if (data.y !== undefined) {
|
|
217
240
|
if (typeof data.y !== 'number') {
|
|
218
241
|
throw new NekoError('TypeError', `La propiedad <y> solo acepta tipo 'number', recibió ${typeof data.y}`);
|
|
219
242
|
}
|
|
220
|
-
this.data.title.y = data.y;
|
|
243
|
+
this.data.title.y = data.y;
|
|
244
|
+
this.data.layout = 'manual';
|
|
221
245
|
}
|
|
222
246
|
}
|
|
223
247
|
|
|
@@ -270,18 +294,20 @@ class Welcome {
|
|
|
270
294
|
}
|
|
271
295
|
|
|
272
296
|
// validacion de posiciones
|
|
273
|
-
if (data.x) {
|
|
297
|
+
if (data.x !== undefined) {
|
|
274
298
|
if (typeof data.x !== 'number') {
|
|
275
299
|
throw new NekoError('TypeError', `La propiedad <x> solo acepta tipo 'number', recibió ${typeof data.x}`);
|
|
276
300
|
}
|
|
277
|
-
this.data.description.x = data.x;
|
|
301
|
+
this.data.description.x = data.x;
|
|
302
|
+
this.data.layout = 'manual';
|
|
278
303
|
}
|
|
279
304
|
|
|
280
|
-
if (data.y) {
|
|
305
|
+
if (data.y !== undefined) {
|
|
281
306
|
if (typeof data.y !== 'number') {
|
|
282
307
|
throw new NekoError('TypeError', `La propiedad <y> solo acepta tipo 'number', recibió ${typeof data.y}`);
|
|
283
308
|
}
|
|
284
|
-
this.data.description.y = data.y;
|
|
309
|
+
this.data.description.y = data.y;
|
|
310
|
+
this.data.layout = 'manual';
|
|
285
311
|
}
|
|
286
312
|
}
|
|
287
313
|
|
|
@@ -327,11 +353,9 @@ class Welcome {
|
|
|
327
353
|
}
|
|
328
354
|
|
|
329
355
|
if (typeof value == 'string') {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
throw new NekoError('BackgroundError', 'La URL proporcionada no es válida');
|
|
334
|
-
}
|
|
356
|
+
parseRemoteURL(value, 'el fondo');
|
|
357
|
+
} else {
|
|
358
|
+
validateImageBuffer(value, 'el fondo');
|
|
335
359
|
}
|
|
336
360
|
|
|
337
361
|
this.data.background = { type, value };
|
|
@@ -369,56 +393,33 @@ class Welcome {
|
|
|
369
393
|
}
|
|
370
394
|
|
|
371
395
|
if (typeof source === 'string') {
|
|
372
|
-
|
|
373
|
-
const url = new URL(source);
|
|
374
|
-
|
|
375
|
-
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
376
|
-
throw new NekoError('AvatarError', 'La URL debe usar HTTP/HTTPS');
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const extension = url.pathname.toLowerCase().split('.').pop();
|
|
380
|
-
if (!['jpg', 'jpeg', 'png', 'gif'].includes(extension)) {
|
|
381
|
-
throw new NekoError('AvatarError', 'Formato de imagen no soportado. Usa JPEG, PNG o GIF');
|
|
382
|
-
}
|
|
383
|
-
} catch (error) {
|
|
384
|
-
if (error instanceof NekoError) throw error;
|
|
385
|
-
throw new NekoError('AvatarError', 'La URL del avatar no es válida');
|
|
386
|
-
}
|
|
396
|
+
parseRemoteURL(source, 'el avatar');
|
|
387
397
|
}
|
|
388
398
|
|
|
389
399
|
if (Buffer.isBuffer(source)) {
|
|
390
|
-
|
|
391
|
-
throw new NekoError('AvatarError', 'El Buffer de imagen es demasiado pequeño');
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const header = source.toString('hex', 0, 8);
|
|
395
|
-
const isJPEG = header.startsWith('ffd8ff');
|
|
396
|
-
const isPNG = header.startsWith('89504e470d0a1a0a');
|
|
397
|
-
const isGIF = header.startsWith('47494638');
|
|
398
|
-
|
|
399
|
-
if (!isJPEG && !isPNG && !isGIF) {
|
|
400
|
-
throw new NekoError('AvatarError', 'Formato de imagen no soportado. El Buffer debe ser JPEG, PNG o GIF');
|
|
401
|
-
}
|
|
400
|
+
validateImageBuffer(source, 'el avatar');
|
|
402
401
|
}
|
|
403
402
|
|
|
404
403
|
this.data.avatar.source = source;
|
|
405
404
|
|
|
406
405
|
if (data) {
|
|
407
|
-
if (data.x) {
|
|
406
|
+
if (data.x !== undefined) {
|
|
408
407
|
if (typeof data.x != 'number') {
|
|
409
408
|
throw new NekoError('TypeError', 'La propiedad <x> debe ser un número');
|
|
410
409
|
}
|
|
411
410
|
if (data.x < 0) {
|
|
412
411
|
throw new NekoError('AvatarError', 'La posición X no puede ser negativa');
|
|
413
412
|
}
|
|
414
|
-
this.data.avatar.x = data.x;
|
|
413
|
+
this.data.avatar.x = data.x;
|
|
414
|
+
this.data.layout = 'manual';
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
if (data.y) {
|
|
417
|
+
if (data.y !== undefined) {
|
|
418
418
|
if (typeof data.y != 'number') {
|
|
419
419
|
throw new NekoError('TypeError', 'La propiedad <y> debe ser un número');
|
|
420
420
|
}
|
|
421
|
-
this.data.avatar.y = data.y;
|
|
421
|
+
this.data.avatar.y = data.y;
|
|
422
|
+
this.data.layout = 'manual';
|
|
422
423
|
}
|
|
423
424
|
|
|
424
425
|
if (data.border) {
|
|
@@ -434,10 +435,13 @@ class Welcome {
|
|
|
434
435
|
this.data.avatar.border = data.border;
|
|
435
436
|
}
|
|
436
437
|
|
|
437
|
-
if (data.radio) {
|
|
438
|
-
if (typeof data.radio != 'number') {
|
|
439
|
-
throw new NekoError('TypeError', 'La propiedad <radio> debe ser un número');
|
|
440
|
-
}
|
|
438
|
+
if (data.radio !== undefined) {
|
|
439
|
+
if (typeof data.radio != 'number') {
|
|
440
|
+
throw new NekoError('TypeError', 'La propiedad <radio> debe ser un número');
|
|
441
|
+
}
|
|
442
|
+
if (!Number.isFinite(data.radio) || data.radio <= 0 || data.radio > Math.min(this.data.width, this.data.height) / 2) {
|
|
443
|
+
throw new NekoError('AvatarError', 'El radio debe ser positivo y caber dentro de la imagen');
|
|
444
|
+
}
|
|
441
445
|
this.data.avatar.radio = data.radio;
|
|
442
446
|
}
|
|
443
447
|
}
|
|
@@ -464,8 +468,8 @@ class Welcome {
|
|
|
464
468
|
|
|
465
469
|
try {
|
|
466
470
|
const [bg, avatar] = await Promise.all([
|
|
467
|
-
this.data.background.type == 'image' ?
|
|
468
|
-
|
|
471
|
+
this.data.background.type == 'image' ? loadImageSource(this.data.background.value, 'el fondo') : null,
|
|
472
|
+
loadImageSource(this.data.avatar.source, 'el avatar')
|
|
469
473
|
])
|
|
470
474
|
|
|
471
475
|
const canvas = Canvas.createCanvas(this.data.width, this.data.height)
|
|
@@ -481,35 +485,43 @@ class Welcome {
|
|
|
481
485
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
482
486
|
}
|
|
483
487
|
|
|
484
|
-
const maxTitleWidth = this.data.width * 0.8;
|
|
485
|
-
let titleFontSize = this.data.
|
|
486
|
-
|
|
488
|
+
const maxTitleWidth = this.data.width * 0.8;
|
|
489
|
+
let titleFontSize = this.data.layout === 'center'
|
|
490
|
+
? Math.min(this.data.title.font_size, this.data.height * 0.14)
|
|
491
|
+
: this.data.title.font_size;
|
|
492
|
+
ctx.font = `${titleFontSize}px ${this.data.font}`;
|
|
487
493
|
|
|
488
494
|
while (ctx.measureText(this.data.title.content).width > maxTitleWidth && titleFontSize > 10) {
|
|
489
495
|
titleFontSize -= 1;
|
|
490
496
|
ctx.font = `${titleFontSize}px ${this.data.font}`;
|
|
491
497
|
}
|
|
492
498
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
let descFontSize = this.data.description.font_size;
|
|
499
|
-
ctx.font = `${descFontSize}px ${this.data.font}`;
|
|
499
|
+
const maxDescWidth = this.data.width * 0.9;
|
|
500
|
+
let descFontSize = this.data.layout === 'center'
|
|
501
|
+
? Math.min(this.data.description.font_size, this.data.height * 0.09)
|
|
502
|
+
: this.data.description.font_size;
|
|
503
|
+
ctx.font = `${descFontSize}px ${this.data.font}`;
|
|
500
504
|
|
|
501
505
|
while (ctx.measureText(this.data.description.content).width > maxDescWidth && descFontSize > 8) {
|
|
502
506
|
descFontSize -= 1;
|
|
503
507
|
ctx.font = `${descFontSize}px ${this.data.font}`;
|
|
504
508
|
}
|
|
505
509
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
510
|
+
const layout = this.data.layout === 'center'
|
|
511
|
+
? calculateCenteredLayout(this.data, titleFontSize, descFontSize)
|
|
512
|
+
: {
|
|
513
|
+
avatarX: this.data.avatar.x,
|
|
514
|
+
avatarY: this.data.avatar.y,
|
|
515
|
+
radio: this.data.avatar.radio,
|
|
516
|
+
titleX: this.data.title.x,
|
|
517
|
+
titleY: this.data.title.y,
|
|
518
|
+
descriptionX: this.data.description.x,
|
|
519
|
+
descriptionY: this.data.description.y
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const x = layout.avatarX;
|
|
523
|
+
const y = layout.avatarY;
|
|
524
|
+
const radio = layout.radio;
|
|
513
525
|
|
|
514
526
|
if (this.data.avatar.border) {
|
|
515
527
|
ctx.beginPath();
|
|
@@ -524,8 +536,18 @@ class Welcome {
|
|
|
524
536
|
ctx.arc(x + radio, y + radio, radio, 0, Math.PI * 2, true);
|
|
525
537
|
ctx.closePath();
|
|
526
538
|
ctx.clip();
|
|
527
|
-
ctx.drawImage(avatar, x, y, radio * 2, radio * 2);
|
|
528
|
-
ctx.restore();
|
|
539
|
+
ctx.drawImage(avatar, x, y, radio * 2, radio * 2);
|
|
540
|
+
ctx.restore();
|
|
541
|
+
|
|
542
|
+
ctx.textAlign = 'center';
|
|
543
|
+
ctx.textBaseline = this.data.layout === 'center' ? 'middle' : 'alphabetic';
|
|
544
|
+
ctx.font = `${titleFontSize}px ${this.data.font}`;
|
|
545
|
+
ctx.fillStyle = this.data.title.text_color;
|
|
546
|
+
ctx.fillText(this.data.title.content, layout.titleX, layout.titleY);
|
|
547
|
+
|
|
548
|
+
ctx.font = `${descFontSize}px ${this.data.font}`;
|
|
549
|
+
ctx.fillStyle = this.data.description.text_color;
|
|
550
|
+
ctx.fillText(this.data.description.content, layout.descriptionX, layout.descriptionY);
|
|
529
551
|
|
|
530
552
|
return canvas.toBuffer();
|
|
531
553
|
} catch (error) {
|
|
@@ -535,6 +557,50 @@ class Welcome {
|
|
|
535
557
|
throw new NekoError('GenerateError', 'Error al generar la imagen: '+error.message)
|
|
536
558
|
}
|
|
537
559
|
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function calculateCenteredLayout(data, titleFontSize, descFontSize) {
|
|
563
|
+
const borderSize = data.avatar.border ? 6 : 0;
|
|
564
|
+
const avatarGap = clamp(data.height * 0.045, 16, 32);
|
|
565
|
+
const textGap = clamp(data.height * 0.018, 8, 16);
|
|
566
|
+
const availableHeight = data.height * 0.9;
|
|
567
|
+
const maxRadio = Math.max(
|
|
568
|
+
16,
|
|
569
|
+
(availableHeight - titleFontSize - descFontSize - avatarGap - textGap) / 2 - borderSize
|
|
570
|
+
);
|
|
571
|
+
const radio = Math.min(data.avatar.radio, maxRadio);
|
|
572
|
+
const avatarOuterSize = (radio + borderSize) * 2;
|
|
573
|
+
const contentHeight = avatarOuterSize + avatarGap + titleFontSize + textGap + descFontSize;
|
|
574
|
+
const startY = (data.height - contentHeight) / 2;
|
|
575
|
+
const centerX = data.width / 2;
|
|
576
|
+
const avatarX = centerX - radio;
|
|
577
|
+
const avatarY = startY + borderSize;
|
|
578
|
+
const titleY = startY + avatarOuterSize + avatarGap + titleFontSize / 2;
|
|
579
|
+
const descriptionY = titleY + titleFontSize / 2 + textGap + descFontSize / 2;
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
avatarX,
|
|
583
|
+
avatarY,
|
|
584
|
+
radio,
|
|
585
|
+
titleX: centerX,
|
|
586
|
+
titleY,
|
|
587
|
+
descriptionX: centerX,
|
|
588
|
+
descriptionY
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function clamp(value, min, max) {
|
|
593
|
+
return Math.min(Math.max(value, min), max);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function validateDimension(value, name) {
|
|
597
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || !Number.isFinite(value)) {
|
|
598
|
+
throw new NekoError('TypeError', `el parametro <${name}> debe ser un número entero`);
|
|
599
|
+
}
|
|
600
|
+
if (value < MIN_DIMENSION || value > MAX_DIMENSION) {
|
|
601
|
+
throw new NekoError('ResolutionError', `<${name}> debe estar entre ${MIN_DIMENSION} y ${MAX_DIMENSION} píxeles`);
|
|
602
|
+
}
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
module.exports = Welcome;
|
|
@@ -11,8 +11,11 @@ class BaseUser {
|
|
|
11
11
|
* Establece el token "global" en todo el paquete.
|
|
12
12
|
* @param {string} token token a establecer.
|
|
13
13
|
*/
|
|
14
|
-
static setToken(token) {
|
|
15
|
-
|
|
14
|
+
static setToken(token) {
|
|
15
|
+
if (typeof token !== 'string' || !token.trim()) {
|
|
16
|
+
throw new TypeError('El token debe ser una cadena no vacía');
|
|
17
|
+
}
|
|
18
|
+
tokenGlobal = token;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -12,9 +12,9 @@ class User {
|
|
|
12
12
|
* Puedes establecer el token aqui, sin embargo, recomiendo hacerlo en el metodo dedicado
|
|
13
13
|
* @param {string} token Token de la api
|
|
14
14
|
*/
|
|
15
|
-
constructor(token) {
|
|
16
|
-
this.token_ =
|
|
17
|
-
|
|
15
|
+
constructor(token) {
|
|
16
|
+
this.token_ = undefined;
|
|
17
|
+
if (token !== undefined) this.token(token);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -23,9 +23,9 @@ class User {
|
|
|
23
23
|
* @throws {NekoError} si el token no fue dado o no es una cadena
|
|
24
24
|
*/
|
|
25
25
|
token(token) {
|
|
26
|
-
if(
|
|
27
|
-
if(
|
|
28
|
-
this.token_ = token;
|
|
26
|
+
if(typeof(token) !== 'string') throw new NekoError('InvalidType', `El token debe ser string, no ${typeof(token)}`);
|
|
27
|
+
if(!token.trim()) throw new NekoError('NoToken', 'el parametro <token> es requerido');
|
|
28
|
+
this.token_ = token.trim();
|
|
29
29
|
setToken(this.#getToken());
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -38,4 +38,4 @@ class User {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
module.exports = User
|
|
41
|
+
module.exports = User
|
package/sources/main/index.js
CHANGED
|
@@ -8,7 +8,7 @@ exports.User = require('../classes/user/User.js')
|
|
|
8
8
|
|
|
9
9
|
exports.NekoGif = require('../classes/package/NekoGif.js')
|
|
10
10
|
exports.BaseUser = require('../classes/user/BaseUser.js')
|
|
11
|
-
exports.Welcome = require('../classes/package/
|
|
11
|
+
exports.Welcome = require('../classes/package/welcome.js')
|
|
12
12
|
|
|
13
13
|
// rest
|
|
14
14
|
|
|
@@ -16,9 +16,11 @@ exports.APIClient = require('../rest/APIClient.js')
|
|
|
16
16
|
|
|
17
17
|
// errores
|
|
18
18
|
|
|
19
|
-
exports.APIError = require('../classes/errors/
|
|
20
|
-
exports.
|
|
19
|
+
exports.APIError = require('../classes/errors/APIError.js')
|
|
20
|
+
exports.NekoError = require('../classes/errors/Error.js')
|
|
21
|
+
// Alias conservado por compatibilidad con versiones 2.0.x.
|
|
22
|
+
exports.Error = exports.NekoError
|
|
21
23
|
|
|
22
24
|
//#region "constantes"
|
|
23
25
|
|
|
24
|
-
exports.SFW = require('../classes/SFW.js')
|
|
26
|
+
exports.SFW = require('../classes/SFW.js')
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* @typedef {import('../typings').GifResponse} GifResponse
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const APIError = require('../classes/errors/
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const APIError = require('../classes/errors/APIError.js');
|
|
9
|
+
const NekoError = require('../classes/errors/Error.js');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Clase APIClient
|
|
@@ -19,12 +20,28 @@ class APIClient {
|
|
|
19
20
|
* @param {string} baseURL link principal de la api
|
|
20
21
|
* @param {APIClientData} data objeto para las solicitudes
|
|
21
22
|
*/
|
|
22
|
-
constructor(baseURL, data) {
|
|
23
|
+
constructor(baseURL, data) {
|
|
24
|
+
if (typeof baseURL !== 'string' || !baseURL) {
|
|
25
|
+
throw new NekoError('InvalidBaseURL', 'El parámetro <baseURL> debe ser una URL válida');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let parsedBaseURL;
|
|
29
|
+
try {
|
|
30
|
+
parsedBaseURL = new URL(baseURL);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new NekoError('InvalidBaseURL', 'El parámetro <baseURL> debe ser una URL válida');
|
|
33
|
+
}
|
|
34
|
+
if (parsedBaseURL.protocol !== 'https:') {
|
|
35
|
+
throw new NekoError('InvalidBaseURL', 'La API debe utilizar HTTPS');
|
|
36
|
+
}
|
|
37
|
+
if (!data || typeof data.token !== 'string' || !data.token.trim()) {
|
|
38
|
+
throw new NekoError('NoToken', 'Se requiere un token para utilizar la API');
|
|
39
|
+
}
|
|
23
40
|
/**
|
|
24
41
|
* url principal
|
|
25
42
|
* @type {string}
|
|
26
43
|
*/
|
|
27
|
-
this.baseURL = baseURL
|
|
44
|
+
this.baseURL = baseURL.replace(/\/$/, '')
|
|
28
45
|
/**
|
|
29
46
|
* data
|
|
30
47
|
* @type {APIClientData}
|
|
@@ -37,29 +54,53 @@ class APIClient {
|
|
|
37
54
|
* @returns {Promise<GifResponse>}
|
|
38
55
|
* @throws {APIError} solo si sucede un error entre solicitudes
|
|
39
56
|
*/
|
|
40
|
-
async get(endpoint) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
async get(endpoint, options = {}) {
|
|
58
|
+
if (typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
|
|
59
|
+
throw new NekoError('InvalidEndpoint', 'El endpoint debe comenzar con "/"');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const timeout = Number.isFinite(options.timeout) && options.timeout > 0
|
|
63
|
+
? options.timeout
|
|
64
|
+
: DEFAULT_TIMEOUT_MS;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
|
70
|
+
method: 'GET',
|
|
71
|
+
headers: {
|
|
72
|
+
'Authorization': `${this.data.token}`,
|
|
73
|
+
'Content-Type': 'application/json'
|
|
74
|
+
},
|
|
75
|
+
signal: controller.signal
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const data = await readJson(response);
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
throw new APIError(endpoint, response, data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return data
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof APIError) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const message = error?.name === 'AbortError'
|
|
89
|
+
? `La solicitud excedió el tiempo límite de ${timeout} ms`
|
|
90
|
+
: error?.message ?? 'No fue posible conectar con la API';
|
|
91
|
+
throw APIError.fromNetworkError(endpoint, `${this.baseURL}${endpoint}`, message);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timeoutId);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readJson(response) {
|
|
99
|
+
try {
|
|
100
|
+
return await response.json();
|
|
101
|
+
} catch {
|
|
102
|
+
return { code: response.status, message: 'La API devolvió una respuesta que no es JSON' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
64
105
|
|
|
65
|
-
module.exports = APIClient;
|
|
106
|
+
module.exports = APIClient;
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//#region constants
|
|
1
|
+
//#region constants
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* Categoria SFW
|
|
@@ -109,8 +107,9 @@ export interface WelcomeTextData {
|
|
|
109
107
|
text_color?: string
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
export interface WelcomeData {
|
|
113
|
-
font: FontNames;
|
|
110
|
+
export interface WelcomeData {
|
|
111
|
+
font: FontNames;
|
|
112
|
+
layout: 'center' | 'manual';
|
|
114
113
|
width: number;
|
|
115
114
|
height: number;
|
|
116
115
|
background: WelcomeBackgroundData;
|
|
@@ -147,8 +146,8 @@ export declare interface GifMap {
|
|
|
147
146
|
reaction: ReactionGifs
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
export interface GifResponse extends ApiResponse {
|
|
151
|
-
data
|
|
149
|
+
export interface GifResponse extends ApiResponse {
|
|
150
|
+
data: {
|
|
152
151
|
url: string;
|
|
153
152
|
anime: string;
|
|
154
153
|
}
|
|
@@ -187,7 +186,10 @@ export class Welcome {
|
|
|
187
186
|
* @param height Alto en píxeles o 'default' para 450px
|
|
188
187
|
* @throws {NekoError} Si los parámetros son inválidos
|
|
189
188
|
*/
|
|
190
|
-
setResolution(width: number | 'default', height: number | 'default'): this;
|
|
189
|
+
setResolution(width: number | 'default', height: number | 'default'): this;
|
|
190
|
+
|
|
191
|
+
/** Selecciona distribución automática centrada o coordenadas manuales. */
|
|
192
|
+
setLayout(layout: 'center' | 'manual'): this;
|
|
191
193
|
|
|
192
194
|
/**
|
|
193
195
|
* Configura la tipografía para el texto de la imagen
|
|
@@ -253,7 +255,7 @@ export class User {
|
|
|
253
255
|
* puedes establecer el token a partir de aqui, pero recomiendo usar el metodo dedicado
|
|
254
256
|
* @param token Token de la api
|
|
255
257
|
*/
|
|
256
|
-
constructor(token
|
|
258
|
+
constructor(token?: string);
|
|
257
259
|
/**
|
|
258
260
|
* establece el token para usar la api
|
|
259
261
|
* @example
|
|
@@ -314,7 +316,7 @@ export class BaseUser {
|
|
|
314
316
|
* Clase APIClient
|
|
315
317
|
* Se encarga de hacer las solicitudes al servidor
|
|
316
318
|
*/
|
|
317
|
-
export class APIClient {
|
|
319
|
+
export class APIClient {
|
|
318
320
|
/**
|
|
319
321
|
* Instanciamiento de la clase
|
|
320
322
|
* @param baseUrl baseUrl: link principal de la api
|
|
@@ -328,7 +330,7 @@ export class APIClient {
|
|
|
328
330
|
* @param endpoint establece la ruta a la que se quiere comunicar en la api
|
|
329
331
|
* @throws {APIError} solo si sucede un error entre solicitudes
|
|
330
332
|
*/
|
|
331
|
-
public get(endpoint: string): Promise<GifResponse>
|
|
333
|
+
public get(endpoint: string, options?: { timeout?: number }): Promise<GifResponse>
|
|
332
334
|
}
|
|
333
335
|
|
|
334
336
|
/**
|
|
@@ -362,23 +364,26 @@ export class APIError extends Error {
|
|
|
362
364
|
/**
|
|
363
365
|
* Instanciamiento de un error de api
|
|
364
366
|
* @param endpoint endpoint: ruta solicitada
|
|
365
|
-
* @param response
|
|
367
|
+
* @param response respuesta HTTP o representación de un error de red
|
|
366
368
|
* @param data data: objeto dado por la api
|
|
367
369
|
*/
|
|
368
|
-
constructor(endpoint: string, response:
|
|
370
|
+
constructor(endpoint: string, response: { url?: string; status?: number }, data: ApiResponse);
|
|
369
371
|
}
|
|
370
372
|
|
|
371
373
|
/**
|
|
372
374
|
* Clase NekoError
|
|
373
375
|
* Errores personalizados respecto al paquete
|
|
374
376
|
*/
|
|
375
|
-
export class NekoError extends Error {
|
|
377
|
+
export class NekoError extends Error {
|
|
376
378
|
/**
|
|
377
379
|
* Instanciamiento de un error del paquete
|
|
378
380
|
* @param name name: nombre del error
|
|
379
381
|
* @param message message: mensaje del error
|
|
380
382
|
*/
|
|
381
383
|
constructor(name: string, message: string);
|
|
382
|
-
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Alias heredado de versiones 2.0.x. Prefiere NekoError. */
|
|
387
|
+
export { NekoError as Error };
|
|
383
388
|
|
|
384
|
-
//#endregion
|
|
389
|
+
//#endregion
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const dns = require('node:dns').promises;
|
|
4
|
+
const net = require('node:net');
|
|
5
|
+
const Canvas = require('canvas');
|
|
6
|
+
const NekoError = require('../classes/errors/Error.js');
|
|
7
|
+
|
|
8
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
const IMAGE_TIMEOUT_MS = 10_000;
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
12
|
+
|
|
13
|
+
async function loadImageSource(source, label = 'imagen') {
|
|
14
|
+
if (Buffer.isBuffer(source)) {
|
|
15
|
+
validateImageBuffer(source, label);
|
|
16
|
+
return Canvas.loadImage(source);
|
|
17
|
+
}
|
|
18
|
+
return Canvas.loadImage(await downloadImage(source, label));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function downloadImage(source, label = 'imagen') {
|
|
22
|
+
let currentURL = parseRemoteURL(source, label);
|
|
23
|
+
|
|
24
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
25
|
+
await assertPublicHostname(currentURL.hostname, label);
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timeoutId = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
|
|
28
|
+
let response;
|
|
29
|
+
try {
|
|
30
|
+
response = await fetch(currentURL, {
|
|
31
|
+
redirect: 'manual',
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
headers: { 'User-Agent': 'neekuro/2.1 image-loader' }
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error?.name === 'AbortError'
|
|
37
|
+
? `La descarga de ${label} excedió ${IMAGE_TIMEOUT_MS} ms`
|
|
38
|
+
: `No se pudo descargar ${label}: ${error?.message ?? 'error desconocido'}`;
|
|
39
|
+
throw new NekoError('ImageDownloadError', message);
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(timeoutId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (response.status >= 300 && response.status < 400) {
|
|
45
|
+
const location = response.headers.get('location');
|
|
46
|
+
if (!location || redirects === MAX_REDIRECTS) {
|
|
47
|
+
throw new NekoError('ImageDownloadError', `Demasiadas redirecciones al descargar ${label}`);
|
|
48
|
+
}
|
|
49
|
+
currentURL = parseRemoteURL(new URL(location, currentURL).href, label);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new NekoError('ImageDownloadError', `No se pudo descargar ${label}: HTTP ${response.status}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const contentType = response.headers.get('content-type')?.split(';', 1)[0].toLowerCase();
|
|
57
|
+
if (!contentType || !IMAGE_CONTENT_TYPES.has(contentType)) {
|
|
58
|
+
throw new NekoError('ImageFormatError', `${label} no tiene un formato de imagen compatible`);
|
|
59
|
+
}
|
|
60
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
61
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
|
62
|
+
throw new NekoError('ImageSizeError', `${label} supera el límite de 10 MB`);
|
|
63
|
+
}
|
|
64
|
+
return readLimitedBody(response, label);
|
|
65
|
+
}
|
|
66
|
+
throw new NekoError('ImageDownloadError', `No se pudo descargar ${label}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseRemoteURL(source, label) {
|
|
70
|
+
if (typeof source !== 'string') {
|
|
71
|
+
throw new NekoError('ImageURLerror', `${label} debe ser una URL HTTPS o un Buffer`);
|
|
72
|
+
}
|
|
73
|
+
let url;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(source);
|
|
76
|
+
} catch {
|
|
77
|
+
throw new NekoError('ImageURLerror', `La URL de ${label} no es válida`);
|
|
78
|
+
}
|
|
79
|
+
if (url.protocol !== 'https:' || url.username || url.password) {
|
|
80
|
+
throw new NekoError('ImageURLerror', `La URL de ${label} debe utilizar HTTPS y no incluir credenciales`);
|
|
81
|
+
}
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function assertPublicHostname(hostname, label) {
|
|
86
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
87
|
+
if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized.endsWith('.local')) {
|
|
88
|
+
throw new NekoError('ImageURLerror', `La URL de ${label} apunta a una red local`);
|
|
89
|
+
}
|
|
90
|
+
const addresses = net.isIP(normalized)
|
|
91
|
+
? [{ address: normalized }]
|
|
92
|
+
: await dns.lookup(normalized, { all: true, verbatim: true }).catch(() => []);
|
|
93
|
+
if (!addresses.length || addresses.some(({ address }) => isPrivateAddress(address))) {
|
|
94
|
+
throw new NekoError('ImageURLerror', `La URL de ${label} no apunta a una dirección pública`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPrivateAddress(address) {
|
|
99
|
+
if (net.isIPv4(address)) {
|
|
100
|
+
const [a, b] = address.split('.').map(Number);
|
|
101
|
+
return a === 0 || a === 10 || a === 127 ||
|
|
102
|
+
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
|
103
|
+
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
|
|
104
|
+
(a === 198 && (b === 18 || b === 19)) || a >= 224;
|
|
105
|
+
}
|
|
106
|
+
if (net.isIPv6(address)) {
|
|
107
|
+
const value = address.toLowerCase();
|
|
108
|
+
return value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd') ||
|
|
109
|
+
/^fe[89ab]/.test(value) || value.startsWith('ff') || value.startsWith('2001:db8:') ||
|
|
110
|
+
(value.startsWith('::ffff:') && isPrivateAddress(value.slice(7)));
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readLimitedBody(response, label) {
|
|
116
|
+
if (!response.body) throw new NekoError('ImageDownloadError', `${label} no contiene datos`);
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let size = 0;
|
|
119
|
+
for await (const chunk of response.body) {
|
|
120
|
+
const buffer = Buffer.from(chunk);
|
|
121
|
+
size += buffer.length;
|
|
122
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
123
|
+
await response.body.cancel().catch(() => undefined);
|
|
124
|
+
throw new NekoError('ImageSizeError', `${label} supera el límite de 10 MB`);
|
|
125
|
+
}
|
|
126
|
+
chunks.push(buffer);
|
|
127
|
+
}
|
|
128
|
+
const result = Buffer.concat(chunks);
|
|
129
|
+
validateImageBuffer(result, label);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function validateImageBuffer(buffer, label = 'imagen') {
|
|
134
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) {
|
|
135
|
+
throw new NekoError('ImageFormatError', `${label} no contiene una imagen válida`);
|
|
136
|
+
}
|
|
137
|
+
if (buffer.length > MAX_IMAGE_BYTES) {
|
|
138
|
+
throw new NekoError('ImageSizeError', `${label} supera el límite de 10 MB`);
|
|
139
|
+
}
|
|
140
|
+
const header = buffer.subarray(0, 12).toString('hex');
|
|
141
|
+
const valid = header.startsWith('ffd8ff') || header.startsWith('89504e470d0a1a0a') ||
|
|
142
|
+
header.startsWith('47494638') || (header.slice(0, 8) === '52494646' && header.slice(16, 24) === '57454250');
|
|
143
|
+
if (!valid) throw new NekoError('ImageFormatError', `${label} debe ser JPEG, PNG, GIF o WebP`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { IMAGE_TIMEOUT_MS, MAX_IMAGE_BYTES, isPrivateAddress, loadImageSource, parseRemoteURL, validateImageBuffer };
|