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 = {