neekuro 2.0.4 → 2.2.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/CHANGELOG.md +51 -0
- package/README.md +74 -6
- package/package.json +17 -11
- package/sources/classes/SFW.js +292 -45
- 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 +21 -7
- package/sources/classes/user/User.js +7 -7
- package/sources/main/index.js +6 -4
- package/sources/rest/APIClient.js +73 -31
- package/sources/typings/index.d.ts +53 -29
- package/sources/utils/imageLoader.js +146 -0
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//#region constants
|
|
1
|
+
//#region constants
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* Categoria SFW
|
|
@@ -9,7 +7,7 @@ import { Response } from "node-fetch";
|
|
|
9
7
|
* // obtiene el objeto SFW
|
|
10
8
|
* const SFW = require('neekuro').SFW
|
|
11
9
|
*/
|
|
12
|
-
export const SFW: {
|
|
10
|
+
export const SFW: {
|
|
13
11
|
/**
|
|
14
12
|
* Funcion principal para obtener gifs de cierta categoria
|
|
15
13
|
* @param category category: corresponde a la subcategoria de SFW
|
|
@@ -25,8 +23,18 @@ export const SFW: {
|
|
|
25
23
|
* // obtener anime
|
|
26
24
|
* cuddle.getAnime();
|
|
27
25
|
*/
|
|
28
|
-
getGif<T extends SfwCategories>(category: T, gif: GifMap[T]): Promise<NekoGif>
|
|
29
|
-
|
|
26
|
+
getGif<T extends SfwCategories>(category: T, gif: GifMap[T]): Promise<NekoGif>
|
|
27
|
+
/** Obtiene todos los GIF disponibles y utiliza la caché interna. */
|
|
28
|
+
getGifs<T extends SfwCategories>(category: T, gif: GifMap[T]): Promise<NekoGif[]>
|
|
29
|
+
/** Limpia toda la caché de imágenes. */
|
|
30
|
+
clearCache(): void
|
|
31
|
+
/** Limpia la caché de una categoría completa o de una subcategoría. */
|
|
32
|
+
clearCache<T extends SfwCategories>(category: T, gif?: GifMap[T]): void
|
|
33
|
+
/** Cambia la URL HTTPS utilizada para consultar SFW. */
|
|
34
|
+
setBaseURL(url: string): void
|
|
35
|
+
/** Configura el TTL en milisegundos. El valor cero desactiva la caché. */
|
|
36
|
+
setCacheTTL(milliseconds: number): void
|
|
37
|
+
}
|
|
30
38
|
|
|
31
39
|
//#endregion
|
|
32
40
|
|
|
@@ -109,8 +117,9 @@ export interface WelcomeTextData {
|
|
|
109
117
|
text_color?: string
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
export interface WelcomeData {
|
|
113
|
-
font: FontNames;
|
|
120
|
+
export interface WelcomeData {
|
|
121
|
+
font: FontNames;
|
|
122
|
+
layout: 'center' | 'manual';
|
|
114
123
|
width: number;
|
|
115
124
|
height: number;
|
|
116
125
|
background: WelcomeBackgroundData;
|
|
@@ -137,22 +146,28 @@ export interface WelcomeData {
|
|
|
137
146
|
};
|
|
138
147
|
}
|
|
139
148
|
|
|
140
|
-
export interface NekoGifInterface {
|
|
141
|
-
url: string | null
|
|
142
|
-
anime?: string | null
|
|
143
|
-
}
|
|
149
|
+
export interface NekoGifInterface {
|
|
150
|
+
url: string | null
|
|
151
|
+
anime?: string | null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface GifData {
|
|
155
|
+
url: string
|
|
156
|
+
anime: string
|
|
157
|
+
}
|
|
144
158
|
|
|
145
159
|
export declare interface GifMap {
|
|
146
160
|
action: ActionGifs
|
|
147
161
|
reaction: ReactionGifs
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
export interface GifResponse extends ApiResponse {
|
|
151
|
-
data
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
export interface GifResponse extends ApiResponse {
|
|
165
|
+
data: GifData
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface GifListResponse extends ApiResponse {
|
|
169
|
+
data: GifData[]
|
|
170
|
+
}
|
|
156
171
|
|
|
157
172
|
export interface ApiResponse {
|
|
158
173
|
message?: string,
|
|
@@ -187,7 +202,10 @@ export class Welcome {
|
|
|
187
202
|
* @param height Alto en píxeles o 'default' para 450px
|
|
188
203
|
* @throws {NekoError} Si los parámetros son inválidos
|
|
189
204
|
*/
|
|
190
|
-
setResolution(width: number | 'default', height: number | 'default'): this;
|
|
205
|
+
setResolution(width: number | 'default', height: number | 'default'): this;
|
|
206
|
+
|
|
207
|
+
/** Selecciona distribución automática centrada o coordenadas manuales. */
|
|
208
|
+
setLayout(layout: 'center' | 'manual'): this;
|
|
191
209
|
|
|
192
210
|
/**
|
|
193
211
|
* Configura la tipografía para el texto de la imagen
|
|
@@ -253,7 +271,7 @@ export class User {
|
|
|
253
271
|
* puedes establecer el token a partir de aqui, pero recomiendo usar el metodo dedicado
|
|
254
272
|
* @param token Token de la api
|
|
255
273
|
*/
|
|
256
|
-
constructor(token
|
|
274
|
+
constructor(token?: string);
|
|
257
275
|
/**
|
|
258
276
|
* establece el token para usar la api
|
|
259
277
|
* @example
|
|
@@ -301,7 +319,10 @@ export class BaseUser {
|
|
|
301
319
|
* Obten el token del usuario si este ya se estableció
|
|
302
320
|
* @returns {string} Token de usuario
|
|
303
321
|
*/
|
|
304
|
-
public static getToken(): string
|
|
322
|
+
public static getToken(): string | null
|
|
323
|
+
|
|
324
|
+
/** Versión interna que cambia cuando se establece un token diferente. */
|
|
325
|
+
public static getTokenVersion(): number
|
|
305
326
|
|
|
306
327
|
/**
|
|
307
328
|
* Establece el token "global" para todo el paquete
|
|
@@ -314,7 +335,7 @@ export class BaseUser {
|
|
|
314
335
|
* Clase APIClient
|
|
315
336
|
* Se encarga de hacer las solicitudes al servidor
|
|
316
337
|
*/
|
|
317
|
-
export class APIClient {
|
|
338
|
+
export class APIClient {
|
|
318
339
|
/**
|
|
319
340
|
* Instanciamiento de la clase
|
|
320
341
|
* @param baseUrl baseUrl: link principal de la api
|
|
@@ -328,8 +349,8 @@ export class APIClient {
|
|
|
328
349
|
* @param endpoint establece la ruta a la que se quiere comunicar en la api
|
|
329
350
|
* @throws {APIError} solo si sucede un error entre solicitudes
|
|
330
351
|
*/
|
|
331
|
-
public get(endpoint: string): Promise<
|
|
332
|
-
}
|
|
352
|
+
public get<T extends ApiResponse = GifResponse>(endpoint: string, options?: { timeout?: number }): Promise<T>
|
|
353
|
+
}
|
|
333
354
|
|
|
334
355
|
/**
|
|
335
356
|
* Clase APIError
|
|
@@ -362,23 +383,26 @@ export class APIError extends Error {
|
|
|
362
383
|
/**
|
|
363
384
|
* Instanciamiento de un error de api
|
|
364
385
|
* @param endpoint endpoint: ruta solicitada
|
|
365
|
-
* @param response
|
|
386
|
+
* @param response respuesta HTTP o representación de un error de red
|
|
366
387
|
* @param data data: objeto dado por la api
|
|
367
388
|
*/
|
|
368
|
-
constructor(endpoint: string, response:
|
|
389
|
+
constructor(endpoint: string, response: { url?: string; status?: number }, data: ApiResponse);
|
|
369
390
|
}
|
|
370
391
|
|
|
371
392
|
/**
|
|
372
393
|
* Clase NekoError
|
|
373
394
|
* Errores personalizados respecto al paquete
|
|
374
395
|
*/
|
|
375
|
-
export class NekoError extends Error {
|
|
396
|
+
export class NekoError extends Error {
|
|
376
397
|
/**
|
|
377
398
|
* Instanciamiento de un error del paquete
|
|
378
399
|
* @param name name: nombre del error
|
|
379
400
|
* @param message message: mensaje del error
|
|
380
401
|
*/
|
|
381
402
|
constructor(name: string, message: string);
|
|
382
|
-
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Alias heredado de versiones 2.0.x. Prefiere NekoError. */
|
|
406
|
+
export { NekoError as Error };
|
|
383
407
|
|
|
384
|
-
//#endregion
|
|
408
|
+
//#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 };
|