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.
@@ -1,6 +1,4 @@
1
- import { Response } from "node-fetch";
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
- url: string;
153
- anime: string;
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: string);
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<GifResponse>
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 response: respuesta por node-fetch
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: Awaited<Response>, data: ApiResponse);
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 };