From b19c989c0e647e9305ccc4133fd9e27ffe71585a Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Mon, 14 Sep 2026 06:04:15 -0300 Subject: [PATCH] =?UTF-8?q?fix(server):=20conserta=20start=20de=20produ?= =?UTF-8?q?=C3=A7=C3=A3o,=20entrega=20do=20arquivo=20e=20diret=C3=B3rio=20?= =?UTF-8?q?de=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatro defeitos verificados executando o servidor de produção: 1. `pnpm start` quebrava na primeira linha com ERR_MODULE_NOT_FOUND (`server/dist/lib/SessionManager`): os imports relativos não tinham extensão e o Node ESM não resolve `./lib/SessionManager`. Os imports agora terminam em `.js` (funciona no tsx em dev e no build do tsc). 2. `GET /api/download/:taskId` devolvia o index.html da SPA (450 bytes) em vez do arquivo baixado, porque o @fastify/static resolve o caminho a partir do `root` do plugin (web/dist). Agora o root do diretório da sessão é passado por requisição via `reply.sendFile(fileName, sessionPath)`. 3. O download podia sair truncado: o status virava "completed" logo depois de `fileStream.end()`, antes do flush em disco. A gravação passou a usar `stream/promises.pipeline` (respeita backpressure, espera o flush e propaga erro de escrita), com um Transform no meio mantendo o cálculo de progresso/velocidade. 4. A raiz de armazenamento era `resolve(process.cwd(), "server/storage")`; como `pnpm start` executa dentro de `server/`, os arquivos iam para `server/server/storage`. Agora a raiz é ancorada no diretório do módulo (`server/storage`), com override por `STORAGE_ROOT`, e o diretório entrou no .gitignore para os downloads não serem versionados. Também usa `basename()` no nome de arquivo vindo do Content-Disposition e da URL de origem, evitando escrever fora do diretório da sessão. --- .gitignore | 3 ++ server/src/index.ts | 11 +++--- server/src/lib/DownloadManager.ts | 60 +++++++++++++++++++------------ server/src/lib/SessionManager.ts | 13 +++++-- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index f1e0d96..74aeb84 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ coverage/ .npm/ +# Arquivos baixados pelas sessoes do Ephemeral Downloader +server/storage/ + # Environment .env .env.local diff --git a/server/src/index.ts b/server/src/index.ts index f26162c..7c8c79e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { Agent, setGlobalDispatcher } from "undici"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { sessionManager } from "./lib/SessionManager"; -import { DownloadManager } from "./lib/DownloadManager"; +import { sessionManager } from "./lib/SessionManager.js"; +import { DownloadManager } from "./lib/DownloadManager.js"; // Otimização: Agente HTTP global com conexões persistentes e timeout relaxado. // Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket. @@ -118,11 +118,14 @@ app.get("/api/download/:taskId", async (request, reply) => { if (task.status !== "completed") return reply.status(400).send({ error: "Download not ready" }); const sessionPath = sessionManager.getSessionPath(session.id); - const filePath = resolve(sessionPath, `${task.id}_${task.filename}`); + const fileName = `${task.id}_${task.filename}`; try { sessionManager.touch(session.id); // Reset timer on interaction - return reply.download(filePath, task.filename); // requires @fastify/static or sendFile + // O @fastify/static resolve caminhos a partir do `root` do plugin (web/dist), + // então o root do diretório da sessão é passado por requisição. + // Sem isso a resposta era o index.html da SPA, não o arquivo baixado. + return reply.sendFile(fileName, sessionPath); } catch (e) { return reply.status(404).send({ error: "File removed or missing" }); } diff --git a/server/src/lib/DownloadManager.ts b/server/src/lib/DownloadManager.ts index 875e792..3b2ccd0 100644 --- a/server/src/lib/DownloadManager.ts +++ b/server/src/lib/DownloadManager.ts @@ -1,8 +1,10 @@ -import { createWriteStream, existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { createWriteStream, existsSync } from "node:fs"; +import { basename, join } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Transform } from "node:stream"; import { request, Dispatcher } from "undici"; import { randomUUID } from "node:crypto"; -import { sessionManager } from "./SessionManager"; +import { sessionManager } from "./SessionManager.js"; type DownloadStatus = "queued" | "downloading" | "completed" | "error"; @@ -45,7 +47,7 @@ export class DownloadManager { try { const u = new URL(url); const p = u.pathname.split("/").pop(); - if (p) filename = p; + if (p) filename = basename(p); } catch { } const task: DownloadTask = { @@ -94,7 +96,12 @@ export class DownloadManager { if (typeof disposition === "string") { const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(disposition); if (match?.[1]) { - task.filename = decodeURIComponent(match[1].replace(/"/g, "")); + // O header vem do host de origem: usar apenas o nome base evita + // caminho relativo (../) escrevendo fora do diretório da sessão. + const headerName = basename(decodeURIComponent(match[1].replace(/"/g, ""))); + if (headerName) { + task.filename = headerName; + } } } @@ -110,29 +117,38 @@ export class DownloadManager { let lastUpdate = Date.now(); let bytesAtLastUpdate = 0; - for await (const chunk of response.body) { - fileStream.write(chunk); - task.receivedBytes += chunk.length; + // Contador de progresso no meio do pipeline: mantém o cálculo de + // velocidade/progresso sem ler o stream manualmente. + const progressTracker = new Transform({ + transform(chunk, _encoding, callback) { + task.receivedBytes += chunk.length; - const now = Date.now(); - if (now - lastUpdate > 500) { // Update speed every 500ms - const diff = now - lastUpdate; - const bytesDiff = task.receivedBytes - bytesAtLastUpdate; - task.speed = (bytesDiff / diff) * 1000; + const now = Date.now(); + if (now - lastUpdate > 500) { // Update speed every 500ms + const diff = now - lastUpdate; + const bytesDiff = task.receivedBytes - bytesAtLastUpdate; + task.speed = (bytesDiff / diff) * 1000; - if (task.totalBytes > 0) { - task.progress = Math.round((task.receivedBytes / task.totalBytes) * 100); + if (task.totalBytes > 0) { + task.progress = Math.round((task.receivedBytes / task.totalBytes) * 100); + } + + lastUpdate = now; + bytesAtLastUpdate = task.receivedBytes; + + // Touch session to keep it alive during download + sessionManager.touch(task.sessionId); } - lastUpdate = now; - bytesAtLastUpdate = task.receivedBytes; + callback(null, chunk); + }, + }); - // Touch session to keep it alive during download - sessionManager.touch(task.sessionId); - } - } + // `pipeline` espera o flush em disco e propaga erro de escrita. + // Antes o status virava "completed" antes do arquivo terminar de + // ser gravado e o download podia sair truncado. + await pipeline(response.body, progressTracker, fileStream); - fileStream.end(); task.status = "completed"; task.progress = 100; task.speed = 0; diff --git a/server/src/lib/SessionManager.ts b/server/src/lib/SessionManager.ts index 7ad1756..93bf5a8 100644 --- a/server/src/lib/SessionManager.ts +++ b/server/src/lib/SessionManager.ts @@ -1,8 +1,17 @@ import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; -const STORAGE_ROOT = resolve(process.cwd(), "server/storage"); +// A raiz de armazenamento é ancorada no diretório do próprio módulo (server/), +// e não no `process.cwd()`: com o cwd errado (por exemplo rodando via +// `pnpm --filter server start`, que executa dentro de server/) os arquivos +// acabavam em server/server/storage. STORAGE_ROOT permite sobrescrever. +const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); +const SERVER_ROOT = resolve(MODULE_DIR, "..", ".."); +const STORAGE_ROOT = process.env.STORAGE_ROOT + ? resolve(process.env.STORAGE_ROOT) + : resolve(SERVER_ROOT, "storage"); const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour type SessionData = {