create-panal-agent 0.12.0 → 0.13.1
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/package.json +6 -4
- package/template/_package.json +1 -0
- package/template/src/adjuntos.ts +359 -0
- package/template/src/agent.ts +38 -41
- package/template/src/salida.ts +391 -0
- package/template/src/server.ts +30 -4
- package/template/src/vigilante.ts +170 -47
- package/template/src/zip.ts +245 -0
|
@@ -21,6 +21,20 @@
|
|
|
21
21
|
* el enlace de reenvío y se espera. Adivinar sería entregar cualquier cosa
|
|
22
22
|
* anclando su hash, que es peor que no entregar.
|
|
23
23
|
*
|
|
24
|
+
* «MIRADA» NO ES «RESUELTA», y confundirlas costó dos tareas de verdad.
|
|
25
|
+
*
|
|
26
|
+
* La marca guarda hasta dónde se ha ENUMERADO, no hasta dónde se ha resuelto.
|
|
27
|
+
* Antes se escribía al final de cada ronda pasara lo que pasara, así que una
|
|
28
|
+
* tarea que fallaba a mitad —el modelo colgado, el RPC caído— se quedaba por
|
|
29
|
+
* detrás de la marca y no volvía a mirarse nunca. Y lo que la recordaba vivía
|
|
30
|
+
* solo en memoria, o sea que un reinicio conservaba la mitad optimista (la
|
|
31
|
+
* marca, en disco) y perdía la otra (los pendientes, en RAM).
|
|
32
|
+
*
|
|
33
|
+
* Ahora las dos cosas viven en el MISMO archivo y se escriben juntas: la marca
|
|
34
|
+
* dice hasta dónde se enumeró, y `pendientes` lleva las excepciones. Una tarea
|
|
35
|
+
* sale de esa lista cuando de verdad se cierra —entregada, completada,
|
|
36
|
+
* cancelada— y no cuando se intentó algo con ella.
|
|
37
|
+
*
|
|
24
38
|
* SE SONDEA, NO SE ESCUCHAN EVENTOS. `eth_getLogs` del RPC público está
|
|
25
39
|
* limitado a 100 bloques, así que un agente parado veinte minutos ya no puede
|
|
26
40
|
* recuperar su propio hueco. Leer el contador de tareas y mirar las nuevas es
|
|
@@ -40,8 +54,20 @@ export interface VigilanteDeps {
|
|
|
40
54
|
yo: Address;
|
|
41
55
|
/** Dónde guardar hasta dónde se miró. */
|
|
42
56
|
dataDir: string;
|
|
43
|
-
/**
|
|
44
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Trabaja una tarea de la que YA se tiene el encargo.
|
|
59
|
+
*
|
|
60
|
+
* Devuelve si la ENTREGÓ, y ese booleano es media corrección de este archivo.
|
|
61
|
+
* `work()` está escrito para no lanzar nunca —una tarea rota no puede tumbar
|
|
62
|
+
* la ronda entera—, así que desde aquí un reintento que funcionó y uno que
|
|
63
|
+
* volvió a reventar por límite de uso se veían exactamente igual: sin error.
|
|
64
|
+
* El vigilante daba por buena la tarea y dejaba de mirarla.
|
|
65
|
+
*
|
|
66
|
+
* Y no vale con relanzar el error: `work()` también sale limpio cuando la
|
|
67
|
+
* tarea espera adjuntos que no han llegado, que tampoco es haberla resuelto.
|
|
68
|
+
* Hace falta que lo diga, no que se deduzca de que nadie protestó.
|
|
69
|
+
*/
|
|
70
|
+
trabajar: (taskId: bigint, brief: string) => Promise<boolean>;
|
|
45
71
|
/**
|
|
46
72
|
* ¿Se está trabajando esa tarea AHORA MISMO?
|
|
47
73
|
*
|
|
@@ -100,98 +126,180 @@ const GRACIA_MS = 3 * 60 * 1000;
|
|
|
100
126
|
/** Cuántas tareas hacia atrás se miran al arrancar sin marca previa. */
|
|
101
127
|
const REPASO_INICIAL = 50n;
|
|
102
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Tope de la lista de pendientes.
|
|
131
|
+
*
|
|
132
|
+
* Una tarea sale de la lista cuando se cierra —entregada, completada,
|
|
133
|
+
* cancelada—, y todas acaban cerrándose: al vencer el plazo el cliente
|
|
134
|
+
* recupera su dinero y la tarea deja de estar abierta. Aun así el tope existe
|
|
135
|
+
* porque nadie OBLIGA al cliente a cancelar: una tarea abandonada puede
|
|
136
|
+
* quedarse abierta para siempre, y sin tope el archivo crecería sin fin.
|
|
137
|
+
*
|
|
138
|
+
* Al recortar se tiran las más VIEJAS, que son las que menos se pueden
|
|
139
|
+
* recuperar, y se dice en voz alta. Callarlo sería repetir el fallo que este
|
|
140
|
+
* archivo viene a arreglar.
|
|
141
|
+
*/
|
|
142
|
+
const MAX_PENDIENTES = 500;
|
|
143
|
+
|
|
144
|
+
/** Qué se sabe de una tarea tras mirarla. «Se intentó» no es un veredicto. */
|
|
145
|
+
type Veredicto = 'resuelta' | 'pendiente';
|
|
146
|
+
|
|
103
147
|
export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
|
|
104
148
|
if (process.env.VIGILANTE === 'off') {
|
|
105
149
|
console.log('Vigilante desactivado (VIGILANTE=off).');
|
|
106
150
|
return { parar: () => {} };
|
|
107
151
|
}
|
|
108
152
|
|
|
109
|
-
const
|
|
110
|
-
/**
|
|
111
|
-
|
|
153
|
+
const estadoPath = join(deps.dataDir, 'vigilante.json');
|
|
154
|
+
/**
|
|
155
|
+
* Cuándo se vio por primera vez una tarea sin encargo.
|
|
156
|
+
*
|
|
157
|
+
* Esto SÍ puede vivir solo en memoria: solo sirve para el margen de gracia
|
|
158
|
+
* antes de gritar, y perderlo en un reinicio únicamente reinicia esa cuenta
|
|
159
|
+
* atrás. Lo que no puede vivir solo en memoria es la lista de pendientes, y
|
|
160
|
+
* por eso está aparte.
|
|
161
|
+
*/
|
|
162
|
+
const vistas = new Map<string, number>();
|
|
112
163
|
/** De las que ya se avisó, para no repetir el aviso cada vuelta. */
|
|
113
164
|
const avisadas = new Set<string>();
|
|
165
|
+
/** De los encargos guardados que no cuadran, para no repetir la queja. */
|
|
166
|
+
const quejadas = new Set<string>();
|
|
114
167
|
let parado = false;
|
|
115
168
|
|
|
116
|
-
|
|
169
|
+
interface Estado {
|
|
170
|
+
visto: bigint;
|
|
171
|
+
pendientes: Set<string>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const leerEstado = (): Estado => {
|
|
117
175
|
try {
|
|
118
|
-
const raw = JSON.parse(readFileSync(
|
|
119
|
-
|
|
176
|
+
const raw = JSON.parse(readFileSync(estadoPath, 'utf8')) as {
|
|
177
|
+
visto?: string;
|
|
178
|
+
pendientes?: string[];
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
// -1 y no 0: sin marca hay que hacer el repaso inicial.
|
|
182
|
+
visto: raw.visto === undefined ? -1n : BigInt(raw.visto),
|
|
183
|
+
// Un archivo de la versión anterior no trae la lista. Se lee como
|
|
184
|
+
// vacía y la primera ronda la vuelve a poblar con lo que siga abierto
|
|
185
|
+
// por delante de la marca; lo que quedó huérfano por detrás hay que
|
|
186
|
+
// recuperarlo a mano, que es exactamente el destrozo que esto corrige.
|
|
187
|
+
pendientes: new Set(raw.pendientes ?? []),
|
|
188
|
+
};
|
|
120
189
|
} catch {
|
|
121
|
-
return -1n
|
|
190
|
+
return { visto: -1n, pendientes: new Set() };
|
|
122
191
|
}
|
|
123
192
|
};
|
|
124
|
-
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Las dos cosas se escriben JUNTAS, y ahí está la corrección.
|
|
196
|
+
*
|
|
197
|
+
* Antes la marca iba a disco y los pendientes se quedaban en RAM, así que un
|
|
198
|
+
* reinicio guardaba la mitad que dice «ya miré» y perdía la que dice «pero
|
|
199
|
+
* esto sigue sin resolver». En un solo archivo no puede pasar.
|
|
200
|
+
*/
|
|
201
|
+
const escribirEstado = (visto: bigint, pendientes: Set<string>): void => {
|
|
202
|
+
let lista = [...pendientes];
|
|
203
|
+
if (lista.length > MAX_PENDIENTES) {
|
|
204
|
+
// Ordenadas por id: las más viejas primero, que son las que se tiran.
|
|
205
|
+
lista.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
|
|
206
|
+
const tiradas = lista.slice(0, lista.length - MAX_PENDIENTES);
|
|
207
|
+
lista = lista.slice(-MAX_PENDIENTES);
|
|
208
|
+
console.error(
|
|
209
|
+
`[vigilante] la lista de pendientes pasó de ${MAX_PENDIENTES}: dejo de seguir ` +
|
|
210
|
+
`${tiradas.length} tarea(s) vieja(s) (#${tiradas[0]}…#${tiradas[tiradas.length - 1]}). ` +
|
|
211
|
+
'Míralas a mano si alguna sigue abierta.',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
125
214
|
try {
|
|
126
|
-
writeFileSync(
|
|
215
|
+
writeFileSync(estadoPath, JSON.stringify({ visto: visto.toString(), pendientes: lista }, null, 2));
|
|
127
216
|
} catch (err) {
|
|
128
|
-
// Perder
|
|
129
|
-
console.error(`[vigilante] no se pudo guardar
|
|
217
|
+
// Perder el estado cuesta repetir el repaso, así que no se para nada.
|
|
218
|
+
console.error(`[vigilante] no se pudo guardar el estado: ${err instanceof Error ? err.message : err}`);
|
|
130
219
|
}
|
|
131
220
|
};
|
|
132
221
|
|
|
133
222
|
/** true si encontro algo que atender: eso es lo que decide el ritmo. */
|
|
134
223
|
async function repasar(): Promise<boolean> {
|
|
135
224
|
const total = await deps.panal.getTaskCount();
|
|
136
|
-
const
|
|
225
|
+
const { visto, pendientes } = leerEstado();
|
|
137
226
|
// La primera vez se miran las últimas REPASO_INICIAL en vez de las 30.000
|
|
138
227
|
// que pueda haber: las viejas están cerradas y no cambian.
|
|
139
|
-
const desde =
|
|
228
|
+
const desde = visto >= 0n ? visto + 1n : total > REPASO_INICIAL ? total - REPASO_INICIAL : 0n;
|
|
140
229
|
|
|
141
|
-
// Las nuevas, más las que quedaron
|
|
142
|
-
const
|
|
143
|
-
for (let i = desde; i < total; i++)
|
|
144
|
-
if (
|
|
145
|
-
|
|
230
|
+
// Las nuevas, más las que quedaron sin resolver de vueltas anteriores.
|
|
231
|
+
const aMirar = new Set<string>(pendientes);
|
|
232
|
+
for (let i = desde; i < total; i++) aMirar.add(i.toString());
|
|
233
|
+
if (aMirar.size === 0) {
|
|
234
|
+
escribirEstado(total - 1n, aMirar);
|
|
146
235
|
return false;
|
|
147
236
|
}
|
|
148
237
|
|
|
149
|
-
|
|
150
|
-
|
|
238
|
+
// Se PARTE de que todas siguen pendientes y solo sale la que devuelva un
|
|
239
|
+
// veredicto de resuelta. Antes era al revés —dentro salvo que alguien se
|
|
240
|
+
// quejara— y por eso un fallo a mitad equivalía a un éxito.
|
|
241
|
+
const restantes = new Set<string>(aMirar);
|
|
242
|
+
for (const id of aMirar) {
|
|
243
|
+
// `break` y no `return`: hay que guardar lo que sí se llegó a resolver,
|
|
244
|
+
// y sobre todo lo que no.
|
|
245
|
+
if (parado) break;
|
|
151
246
|
const taskId = BigInt(id);
|
|
247
|
+
let veredicto: Veredicto = 'pendiente';
|
|
152
248
|
try {
|
|
153
|
-
await revisarUna(taskId);
|
|
249
|
+
veredicto = await revisarUna(taskId);
|
|
154
250
|
} catch (err) {
|
|
155
|
-
console.error(
|
|
251
|
+
console.error(
|
|
252
|
+
`[vigilante] #${taskId}: ${err instanceof Error ? err.message : err} — sigue pendiente`,
|
|
253
|
+
);
|
|
156
254
|
}
|
|
255
|
+
if (veredicto === 'resuelta') restantes.delete(id);
|
|
157
256
|
}
|
|
158
|
-
|
|
257
|
+
escribirEstado(total - 1n, restantes);
|
|
159
258
|
// Habia algo que mirar, aunque no fuera nuestro: no es una vuelta en blanco.
|
|
160
259
|
return true;
|
|
161
260
|
}
|
|
162
261
|
|
|
163
|
-
async function revisarUna(taskId: bigint): Promise<
|
|
262
|
+
async function revisarUna(taskId: bigint): Promise<Veredicto> {
|
|
164
263
|
const task = await deps.panal.getTask(taskId);
|
|
165
264
|
const id = taskId.toString();
|
|
166
265
|
|
|
167
|
-
//
|
|
266
|
+
// No es mía: no hay nada que resolver y no hará falta volver a mirarla.
|
|
168
267
|
if (task.worker.toLowerCase() !== deps.yo.toLowerCase()) {
|
|
169
|
-
|
|
170
|
-
return;
|
|
268
|
+
vistas.delete(id);
|
|
269
|
+
return 'resuelta';
|
|
171
270
|
}
|
|
271
|
+
// Cerrada en la cadena —entregada, completada, disputada o cancelada—.
|
|
272
|
+
// ESTA es la única salida buena de la lista: lo dice el escrow, no nosotros.
|
|
172
273
|
if (task.status !== TaskStatus.Open) {
|
|
173
|
-
|
|
274
|
+
vistas.delete(id);
|
|
174
275
|
avisadas.delete(id);
|
|
175
|
-
|
|
276
|
+
quejadas.delete(id);
|
|
277
|
+
return 'resuelta';
|
|
176
278
|
}
|
|
177
279
|
|
|
178
280
|
// Se está trabajando ahora mismo: no es un hueco, es el camino normal. Ni
|
|
179
281
|
// se retoma, ni se avisa de que falte el encargo — lo tiene y lo está
|
|
180
282
|
// usando. El vigilante solo se ocupa de lo que ya no se mueve.
|
|
283
|
+
//
|
|
284
|
+
// PERO SIGUE PENDIENTE. Antes esto la sacaba de la lista, y era el mismo
|
|
285
|
+
// fallo con otro disfraz: si ese trabajo en curso acababa reventando, la
|
|
286
|
+
// marca ya había pasado por encima y no volvía a mirarla nadie.
|
|
181
287
|
if (deps.enCurso(taskId)) {
|
|
182
|
-
|
|
183
|
-
return;
|
|
288
|
+
vistas.delete(id);
|
|
289
|
+
return 'pendiente';
|
|
184
290
|
}
|
|
185
291
|
|
|
186
292
|
// CASO 3: el resultado está calculado y la tarea sigue abierta, así que la
|
|
187
293
|
// entrega no llegó a anclarse. Se reintenta, que es gratis para el cliente
|
|
188
294
|
// y le devuelve una tarea que daba por perdida.
|
|
295
|
+
//
|
|
296
|
+
// Si `reentregar` falla, lanza: sube a `repasar`, que la deja pendiente.
|
|
189
297
|
const resultado = deps.resultadoGuardado(taskId);
|
|
190
298
|
if (resultado !== null) {
|
|
191
299
|
console.log(`[vigilante] #${taskId} tenía resultado sin anclar: se reintenta la entrega`);
|
|
192
300
|
await deps.reentregar(taskId, resultado);
|
|
193
|
-
|
|
194
|
-
return;
|
|
301
|
+
vistas.delete(id);
|
|
302
|
+
return 'resuelta';
|
|
195
303
|
}
|
|
196
304
|
|
|
197
305
|
// CASO 2: el encargo está guardado pero no hay resultado, o sea que el
|
|
@@ -202,26 +310,38 @@ export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
|
|
|
202
310
|
// otra ejecución y no vale fiarse: si no cuadra con lo que hay en la
|
|
203
311
|
// cadena, trabajar sobre él sería entregar algo que el cliente no pidió.
|
|
204
312
|
if (keccak256(toBytes(brief)) !== task.taskHash) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
313
|
+
// Pendiente, NO resuelta: el cliente todavía puede reenviar el bueno
|
|
314
|
+
// por /reenviar y entonces sí se puede trabajar. Se queja una sola vez
|
|
315
|
+
// para no llenar el log en cada vuelta.
|
|
316
|
+
if (!quejadas.has(id)) {
|
|
317
|
+
quejadas.add(id);
|
|
318
|
+
console.error(
|
|
319
|
+
`[vigilante] #${taskId} el encargo guardado NO cuadra con el taskHash de la cadena: ` +
|
|
320
|
+
'no se trabaja sobre él. Que el cliente lo reenvíe.',
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return 'pendiente';
|
|
210
324
|
}
|
|
211
325
|
console.log(`[vigilante] #${taskId} se quedó a medias: se retoma el trabajo`);
|
|
212
|
-
await deps.trabajar(taskId, brief);
|
|
213
|
-
|
|
214
|
-
|
|
326
|
+
const entregada = await deps.trabajar(taskId, brief);
|
|
327
|
+
vistas.delete(id);
|
|
328
|
+
// AQUÍ vivía el segundo fallo: se daba por resuelta sin mirar si lo
|
|
329
|
+
// estaba. Un modelo que devuelve 429 dos veces seguidas se veía igual
|
|
330
|
+
// que una entrega perfecta.
|
|
331
|
+
if (!entregada) {
|
|
332
|
+
console.log(`[vigilante] #${taskId} no quedó entregada: sigue en la lista para la próxima vuelta`);
|
|
333
|
+
}
|
|
334
|
+
return entregada ? 'resuelta' : 'pendiente';
|
|
215
335
|
}
|
|
216
336
|
|
|
217
337
|
// CASO 1: hay tarea y no hay encargo. Aquí no se puede hacer nada más que
|
|
218
338
|
// avisar: el texto no está en la cadena y adivinarlo sería inventárselo.
|
|
219
|
-
const visto =
|
|
339
|
+
const visto = vistas.get(id);
|
|
220
340
|
if (visto === undefined) {
|
|
221
|
-
|
|
222
|
-
return;
|
|
341
|
+
vistas.set(id, Date.now());
|
|
342
|
+
return 'pendiente';
|
|
223
343
|
}
|
|
224
|
-
if (Date.now() - visto < GRACIA_MS || avisadas.has(id)) return;
|
|
344
|
+
if (Date.now() - visto < GRACIA_MS || avisadas.has(id)) return 'pendiente';
|
|
225
345
|
|
|
226
346
|
avisadas.add(id);
|
|
227
347
|
// En cuánto vence, no cuándo. La fecha absoluta se imprimía en UTC junto a
|
|
@@ -242,6 +362,9 @@ export function arrancarVigilante(deps: VigilanteDeps): { parar: () => void } {
|
|
|
242
362
|
` o desde https://panal.lat/dashboard.\n` +
|
|
243
363
|
` Si nadie lo hace, el plazo vence ${vence} y el cliente recupera su dinero.`,
|
|
244
364
|
);
|
|
365
|
+
// Sigue abierta y sin encargo: se queda en la lista hasta que la cadena
|
|
366
|
+
// diga otra cosa.
|
|
367
|
+
return 'pendiente';
|
|
245
368
|
}
|
|
246
369
|
|
|
247
370
|
console.log(`Vigilante activo: repasa cada ${CADA} s (VIGILANTE=off para apagarlo).`);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leer un ZIP sin dependencias.
|
|
3
|
+
*
|
|
4
|
+
* Hace falta para DOS cosas que parecen distintas y son la misma: una carpeta
|
|
5
|
+
* que el cliente comprimió, y un `.docx` —que es un ZIP con XML dentro—. Con
|
|
6
|
+
* un lector se resuelven las dos.
|
|
7
|
+
*
|
|
8
|
+
* Node trae `zlib`, que es el 90% del trabajo. Lo que falta es entender la
|
|
9
|
+
* estructura del archivo, y es poca cosa: se lee el directorio central del
|
|
10
|
+
* final, no las cabeceras locales, porque el directorio central es el índice
|
|
11
|
+
* fiable —las locales pueden traer los tamaños a cero y remitir a un
|
|
12
|
+
* descriptor que va DETRÁS de los datos—.
|
|
13
|
+
*
|
|
14
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* ESTO LO MANDA UN DESCONOCIDO Y HAY QUE TRATARLO COMO TAL.
|
|
16
|
+
*
|
|
17
|
+
* El ZIP llega de un cliente que pagó, pero pagar no vuelve a nadie de fiar.
|
|
18
|
+
* Tres defensas, y las tres importan:
|
|
19
|
+
*
|
|
20
|
+
* - Una bomba zip: 42 kB que se descomprimen en petabytes. Se mira el tamaño
|
|
21
|
+
* DECLARADO antes de descomprimir y se lleva un total acumulado, así que
|
|
22
|
+
* se corta antes de reservar la memoria, no después.
|
|
23
|
+
* - Rutas con `..` o absolutas: aquí nada se escribe en disco, pero el
|
|
24
|
+
* nombre viaja al modelo y acaba en logs. Se normaliza igual.
|
|
25
|
+
* - Un ZIP con cien mil entradas vacías, que no infla memoria pero sí tiempo.
|
|
26
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { inflateRawSync } from 'node:zlib';
|
|
30
|
+
|
|
31
|
+
/** Un archivo dentro del ZIP, ya descomprimido. */
|
|
32
|
+
export interface EntradaZip {
|
|
33
|
+
/** La ruta dentro del ZIP, ya normalizada. */
|
|
34
|
+
nombre: string;
|
|
35
|
+
bytes: Uint8Array;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LimitesZip {
|
|
39
|
+
/** Cuántas entradas se miran como mucho. */
|
|
40
|
+
maxEntradas: number;
|
|
41
|
+
/** Tope del total descomprimido, sumando todas. */
|
|
42
|
+
maxTotalBytes: number;
|
|
43
|
+
/** Tope de una sola entrada. */
|
|
44
|
+
maxEntradaBytes: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const LIMITES_ZIP: LimitesZip = {
|
|
48
|
+
maxEntradas: 200,
|
|
49
|
+
maxTotalBytes: 32 * 1024 * 1024,
|
|
50
|
+
maxEntradaBytes: 8 * 1024 * 1024,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Los cuatro bytes con los que empieza todo ZIP (y todo .docx, .xlsx, .odt). */
|
|
54
|
+
export function esZip(bytes: Uint8Array): boolean {
|
|
55
|
+
return bytes.length > 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const u16 = (b: Uint8Array, i: number): number => b[i]! | (b[i + 1]! << 8);
|
|
59
|
+
const u32 = (b: Uint8Array, i: number): number =>
|
|
60
|
+
(b[i]! | (b[i + 1]! << 8) | (b[i + 2]! << 16) | (b[i + 3]! << 24)) >>> 0;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Quita lo que haría daño de una ruta interna.
|
|
64
|
+
*
|
|
65
|
+
* No se escribe nada en disco, así que esto no evita un escape de directorio:
|
|
66
|
+
* evita que un nombre inventado —`../../etc/passwd`— llegue al modelo y a los
|
|
67
|
+
* logs como si fuera un archivo de verdad del cliente.
|
|
68
|
+
*/
|
|
69
|
+
function rutaLimpia(nombre: string): string {
|
|
70
|
+
return nombre
|
|
71
|
+
.replace(/\\/g, '/')
|
|
72
|
+
.split('/')
|
|
73
|
+
.filter((p) => p && p !== '.' && p !== '..')
|
|
74
|
+
.join('/')
|
|
75
|
+
.slice(0, 200);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Dónde empieza el directorio central. Se busca desde el final. */
|
|
79
|
+
function buscarDirectorio(b: Uint8Array): number | null {
|
|
80
|
+
// El comentario final puede ocupar hasta 64 kB, así que no basta con mirar
|
|
81
|
+
// los últimos 22 bytes.
|
|
82
|
+
const desde = Math.max(0, b.length - 22 - 0xffff);
|
|
83
|
+
for (let i = b.length - 22; i >= desde; i--) {
|
|
84
|
+
if (u32(b, i) === 0x06054b50) return u32(b, i + 16);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Los archivos de un ZIP, descomprimidos y acotados.
|
|
91
|
+
*
|
|
92
|
+
* Las carpetas y las entradas vacías se saltan solas: lo que interesa es el
|
|
93
|
+
* contenido. Una entrada que no se puede descomprimir se OMITE en vez de
|
|
94
|
+
* tumbar la lectura entera — un ZIP con un archivo roto sigue teniendo diez
|
|
95
|
+
* buenos, y el cliente ya pagó por que se mire lo que sí se puede.
|
|
96
|
+
*/
|
|
97
|
+
export function leerZip(datos: Uint8Array, limites: LimitesZip = LIMITES_ZIP): EntradaZip[] {
|
|
98
|
+
const inicio = buscarDirectorio(datos);
|
|
99
|
+
if (inicio === null || inicio >= datos.length) return [];
|
|
100
|
+
|
|
101
|
+
const salida: EntradaZip[] = [];
|
|
102
|
+
let total = 0;
|
|
103
|
+
let cursor = inicio;
|
|
104
|
+
|
|
105
|
+
while (cursor + 46 <= datos.length && u32(datos, cursor) === 0x02014b50) {
|
|
106
|
+
const metodo = u16(datos, cursor + 10);
|
|
107
|
+
const comprimido = u32(datos, cursor + 20);
|
|
108
|
+
const sinComprimir = u32(datos, cursor + 24);
|
|
109
|
+
const nLargo = u16(datos, cursor + 28);
|
|
110
|
+
const extraLargo = u16(datos, cursor + 30);
|
|
111
|
+
const comentarioLargo = u16(datos, cursor + 32);
|
|
112
|
+
const offsetLocal = u32(datos, cursor + 42);
|
|
113
|
+
const nombre = rutaLimpia(new TextDecoder().decode(datos.subarray(cursor + 46, cursor + 46 + nLargo)));
|
|
114
|
+
|
|
115
|
+
cursor += 46 + nLargo + extraLargo + comentarioLargo;
|
|
116
|
+
|
|
117
|
+
if (salida.length >= limites.maxEntradas) break;
|
|
118
|
+
// El tamaño se comprueba ANTES de descomprimir: es lo único que separa
|
|
119
|
+
// esto de reservar los petabytes que la bomba pide.
|
|
120
|
+
if (!nombre || sinComprimir === 0) continue;
|
|
121
|
+
if (sinComprimir > limites.maxEntradaBytes) continue;
|
|
122
|
+
if (total + sinComprimir > limites.maxTotalBytes) break;
|
|
123
|
+
|
|
124
|
+
// Ahora sí hay que mirar la cabecera local, sólo para saber dónde
|
|
125
|
+
// empiezan los datos: su longitud de extra puede ser distinta de la del
|
|
126
|
+
// directorio central, y darlo por hecho desplaza la lectura.
|
|
127
|
+
if (offsetLocal + 30 > datos.length || u32(datos, offsetLocal) !== 0x04034b50) continue;
|
|
128
|
+
const datosEn = offsetLocal + 30 + u16(datos, offsetLocal + 26) + u16(datos, offsetLocal + 28);
|
|
129
|
+
if (datosEn + comprimido > datos.length) continue;
|
|
130
|
+
const crudo = datos.subarray(datosEn, datosEn + comprimido);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const bytes = metodo === 0 ? crudo : metodo === 8 ? new Uint8Array(inflateRawSync(crudo)) : null;
|
|
134
|
+
if (!bytes) continue;
|
|
135
|
+
total += bytes.length;
|
|
136
|
+
salida.push({ nombre, bytes });
|
|
137
|
+
} catch {
|
|
138
|
+
// Entrada corrupta o cifrada: se omite y se sigue con las demás.
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return salida;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ══════════════════════════════════════════════════════════════════════════
|
|
147
|
+
* ESCRIBIR
|
|
148
|
+
*
|
|
149
|
+
* Hace falta para devolver un `.docx`, que es un ZIP con XML dentro. El mismo
|
|
150
|
+
* formato que se lee arriba, al revés.
|
|
151
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
152
|
+
|
|
153
|
+
import { deflateRawSync } from 'node:zlib';
|
|
154
|
+
|
|
155
|
+
/** CRC-32, que el ZIP exige por entrada. Tabla al vuelo: son 256 valores. */
|
|
156
|
+
function crc32(bytes: Uint8Array): number {
|
|
157
|
+
let c: number;
|
|
158
|
+
const tabla: number[] = [];
|
|
159
|
+
for (let n = 0; n < 256; n++) {
|
|
160
|
+
c = n;
|
|
161
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
162
|
+
tabla[n] = c >>> 0;
|
|
163
|
+
}
|
|
164
|
+
let crc = 0xffffffff;
|
|
165
|
+
for (const b of bytes) crc = tabla[(crc ^ b) & 0xff]! ^ (crc >>> 8);
|
|
166
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escribirU16(v: number): number[] {
|
|
170
|
+
return [v & 0xff, (v >>> 8) & 0xff];
|
|
171
|
+
}
|
|
172
|
+
function escribirU32(v: number): number[] {
|
|
173
|
+
return [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Monta un ZIP.
|
|
178
|
+
*
|
|
179
|
+
* Se comprime todo con deflate. Sin fecha real —se pone la del epoch de MS-DOS
|
|
180
|
+
* y ya— porque un archivo que cambia de bytes cada vez que se genera rompe una
|
|
181
|
+
* propiedad que aquí importa: el hash de la entrega se ancla en la cadena, y
|
|
182
|
+
* generar dos veces lo mismo tiene que dar exactamente lo mismo.
|
|
183
|
+
*/
|
|
184
|
+
export function escribirZip(entradas: { nombre: string; bytes: Uint8Array }[]): Uint8Array {
|
|
185
|
+
const local: number[] = [];
|
|
186
|
+
const central: number[] = [];
|
|
187
|
+
let offset = 0;
|
|
188
|
+
|
|
189
|
+
for (const e of entradas) {
|
|
190
|
+
const nombre = [...new TextEncoder().encode(e.nombre)];
|
|
191
|
+
const comprimido = [...new Uint8Array(deflateRawSync(e.bytes))];
|
|
192
|
+
const crc = crc32(e.bytes);
|
|
193
|
+
|
|
194
|
+
const cabecera = [
|
|
195
|
+
...escribirU32(0x04034b50),
|
|
196
|
+
...escribirU16(20), // versión mínima
|
|
197
|
+
...escribirU16(0),
|
|
198
|
+
...escribirU16(8), // deflate
|
|
199
|
+
...escribirU16(0), // hora
|
|
200
|
+
...escribirU16(0x21), // fecha: 1980-01-01
|
|
201
|
+
...escribirU32(crc),
|
|
202
|
+
...escribirU32(comprimido.length),
|
|
203
|
+
...escribirU32(e.bytes.length),
|
|
204
|
+
...escribirU16(nombre.length),
|
|
205
|
+
...escribirU16(0),
|
|
206
|
+
...nombre,
|
|
207
|
+
];
|
|
208
|
+
local.push(...cabecera, ...comprimido);
|
|
209
|
+
|
|
210
|
+
central.push(
|
|
211
|
+
...escribirU32(0x02014b50),
|
|
212
|
+
...escribirU16(20), // versión que lo creó
|
|
213
|
+
...escribirU16(20),
|
|
214
|
+
...escribirU16(0),
|
|
215
|
+
...escribirU16(8),
|
|
216
|
+
...escribirU16(0),
|
|
217
|
+
...escribirU16(0x21),
|
|
218
|
+
...escribirU32(crc),
|
|
219
|
+
...escribirU32(comprimido.length),
|
|
220
|
+
...escribirU32(e.bytes.length),
|
|
221
|
+
...escribirU16(nombre.length),
|
|
222
|
+
...escribirU16(0),
|
|
223
|
+
...escribirU16(0), // comentario
|
|
224
|
+
...escribirU16(0), // disco
|
|
225
|
+
...escribirU16(0), // atributos internos
|
|
226
|
+
...escribirU32(0), // atributos externos
|
|
227
|
+
...escribirU32(offset),
|
|
228
|
+
...nombre,
|
|
229
|
+
);
|
|
230
|
+
offset += cabecera.length + comprimido.length;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fin = [
|
|
234
|
+
...escribirU32(0x06054b50),
|
|
235
|
+
...escribirU16(0),
|
|
236
|
+
...escribirU16(0),
|
|
237
|
+
...escribirU16(entradas.length),
|
|
238
|
+
...escribirU16(entradas.length),
|
|
239
|
+
...escribirU32(central.length),
|
|
240
|
+
...escribirU32(local.length),
|
|
241
|
+
...escribirU16(0),
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
return new Uint8Array([...local, ...central, ...fin]);
|
|
245
|
+
}
|