12 changed files with 286 additions and 213 deletions

No files matched your search

-3
View File
@@ -5,9 +5,6 @@ build/
coverage/ coverage/
.npm/ .npm/
# Arquivos baixados pelas sessoes do Ephemeral Downloader
server/storage/
# Environment # Environment
.env .env
.env.local .env.local
+99 -125
View File
@@ -1,150 +1,124 @@
<div align="center">
# Ephemeral Downloader # Ephemeral Downloader
**Proxy de download efêmero: a sua VPS baixa o arquivo por uma rota melhor e entrega para o seu navegador, com os arquivos apagados automaticamente depois.** **Use a sua VPS como ponte de alta velocidade: ela baixa o arquivo do host lento,
guarda por até 1 hora e entrega a você — tudo por uma SPA com progresso em tempo real.**
![Node](https://img.shields.io/badge/Node-%3E%3D20.19-5FA04E?logo=nodedotjs&logoColor=white) ![Fastify](https://img.shields.io/badge/Fastify-5-informational)
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white) ![React](https://img.shields.io/badge/React-19-informational)
![Fastify](https://img.shields.io/badge/Fastify-5-000000?logo=fastify&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-%E2%89%A5%2020-informational)
![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)
![Vite](https://img.shields.io/badge/Vite-7-646CFF?logo=vite&logoColor=white)
![pnpm](https://img.shields.io/badge/pnpm-9%2B-F69220?logo=pnpm&logoColor=white)
## Sobre [Como funciona](#como-funciona) · [Execução local](#execu%C3%A7%C3%A3o-local) · [Produção](#produ%C3%A7%C3%A3o) · [Limitações](#limita%C3%A7%C3%B5es-conhecidas)
Baixar um arquivo grande direto de um host congestionado costuma ser lento, e muitas vezes o gargalo não é a sua internet nem o servidor de origem, e sim o caminho entre eles. O Ephemeral Downloader coloca a sua VPS no meio: o servidor faz o download (normalmente com peering melhor), mostra o progresso em tempo real e entrega o arquivo para você pelo navegador. </div>
O projeto é focado em uso efêmero. O arquivo fica em um diretório temporário da sessão no servidor e é apagado quando a sessão expira (1 hora sem interação). Não é um proxy de streaming puro: o download passa pelo disco do servidor antes de chegar até você. > [!NOTE]
> **Estado do projeto:** funcional, sem testes automatizados e sem CI (7 commits,
> o último em 2026-02-14). O fluxo atual **baixa primeiro para o disco da VPS** e
> só depois entrega o arquivo ao navegador — o modelo antigo de streaming direto
> (`GET /stream`, retomada por `offset`) foi aposentado e a rota responde `410`.
> [!IMPORTANT]
> **Não há autenticação, cota nem lista de destinos permitidos.** Qualquer cliente
> que alcance a porta pode mandar o servidor buscar uma URL e ocupar disco. Coloque
> atrás de VPN, proxy com autenticação ou firewall.
## Como funciona ## Como funciona
``` Por que existe: às vezes o gargalo não é a sua internet nem o servidor de origem, e
navegador (React + Vite) sim o caminho entre eles. Com uma VPS no meio, o arquivo vem por uma rota melhor até
│ POST /api/queue {url} GET /api/tasks (polling de 2 s) ela, e dela até você.
▼ ▲
Fastify (server/) ──undici GET──▶ host de origem │
│ │
▼ │
server/storage/<id-da-sessao>/<task>_<arquivo> ────────┘
▲
│ GET /api/download/:taskId (arquivo concluído)
navegador
```
- `POST /api/queue` enfileira a URL e o backend baixa com `undici`, gravando em `server/storage/<id-da-sessão>/` por um pipeline (respeita backpressure e só marca a task como concluída depois do arquivo em disco). | Etapa | O que acontece |
- O frontend faz polling de `GET /api/tasks` a cada 2 segundos e mostra status, progresso, bytes recebidos, total e velocidade em MB/s e Mbps.
- Quando a task conclui, o botão "Save to Disk" busca `GET /api/download/:taskId` e salva o arquivo.
- Em produção o próprio Fastify serve o build do Vite na raiz, com fallback da SPA.
- `GET /stream` é o proxy antigo, mantido apenas respondendo 410 (descontinuado).
## Stack
| Camada | Escolha |
|---|---| |---|---|
| Runtime | Node.js 20.19 ou superior | | 1. Sessão | A SPA pede `/api/session`; o servidor cria uma sessão (UUID + pasta própria) e o navegador guarda o id em `localStorage` |
| Backend | Fastify 5, undici 7 (cliente HTTP), zod (validação) | | 2. Fila | Você cola um link e o servidor começa o download na hora (`queued` → `downloading` → `completed` ou `error`) |
| Frontend | React 19, Vite 7, Tailwind CSS 4, lucide-react | | 3. Progresso | A SPA consulta `/api/tasks` a cada 2 s: bytes recebidos/total, porcentagem e velocidade em MB/s e Mbps |
| Linguagem | TypeScript 5.9 | | 4. Entrega | Com a tarefa concluída, **Save to Disk** baixa o arquivo da VPS para a sua máquina |
| Monorepo | pnpm workspaces (`server` e `web`) | | 5. Limpeza | Sem atividade por **1 hora**, a sessão expira e a pasta com os arquivos é apagada; um cronômetro na tela mostra o tempo restante |
| Deploy | nixpacks (`nixpacks.toml`) |
## Requisitos ```mermaid
sequenceDiagram
participant N as Navegador (SPA React)
participant V as Fastify (VPS)
participant O as Servidor de origem
N->>V: GET /api/session
V-->>N: x-session-id + expiresAt
N->>V: POST /api/queue { url }
V->>O: GET url (undici)
O-->>V: bytes
V->>V: grava em storage/{sessão}/{id}_{arquivo}
loop a cada 2 s
N->>V: GET /api/tasks
V-->>N: progresso, velocidade
end
N->>V: GET /api/download/{taskId}
V-->>N: arquivo
Note over V: 1 h sem atividade → sessão e arquivos apagados
```
- Node.js 20.19 ou superior (exigência do Vite 7 e do undici 7; verificado no Node 24.20.0) <details>
- pnpm 9 ou superior (verificado no pnpm 12.4.1; o lockfile é o formato 9.0) <summary>API do servidor e detalhes técnicos</summary>
- Portas livres: backend (padrão 3000) e Vite (5173) em desenvolvimento
Observação sobre pnpm: a seção `overrides` do `pnpm-workspace.yaml` (fastify e undici com versão mínima de segurança) só é aplicada pelo pnpm 10 ou superior. Com pnpm 9 ela é ignorada. | Rota | Função |
|---|---|
| `GET /health` | `{ "status": "ok" }` |
| `GET /api/session` | Cria ou recupera a sessão (cabeçalho `x-session-id`); devolve `sessionId`, `expiresAt` e `remainingMs` |
| `POST /api/queue` | Corpo `{ "url": "..." }`; `400` se a URL for inválida; devolve a tarefa |
| `GET /api/tasks` | Tarefas da sessão |
| `GET /api/download/:taskId` | `404` se não existe, `403` se é de outra sessão, `400` se não concluída; senão envia o arquivo e renova o prazo da sessão |
| `GET /stream` | Descontinuada: `410` |
| `GET /` e demais `GET` | `index.html` do build do Vite (`web/dist`) |
## Início rápido | Item | Implementação |
|---|---|
| Servidor | Fastify 5 + `@fastify/cors` (`origin: true`) + `@fastify/static`, validação com `zod` |
| Cliente HTTP | `undici` com agente global (até 500 conexões, `keepAliveTimeout` 10 s, `connect` 30 s) e `User-Agent: Ephemeral-Downloader/2.0` |
| Nome do arquivo | Da URL; sobrescrito por `Content-Disposition` quando existir |
| Armazenamento | `server/storage/{sessão}/{id}_{nome}`, relativo ao diretório de trabalho (`process.cwd()`); coletor a cada 1 min remove sessões expiradas e pastas órfãs com mais de 1 h |
| Frontend | React 19, Vite 7, Tailwind CSS 4, `lucide-react`; componentes em `web/src/components/{ui,feature}` |
| Estado das tarefas | Em memória (`Map`) — um restart do servidor perde a lista de tarefas |
</details>
## Limitações conhecidas
- **Sem retomada e sem lote de links.** Não há `Range`/`offset` no fluxo atual: download interrompido vira `error` e precisa ser refeito. O formulário aceita uma URL por vez.
- **Redirecionamentos.** O `DownloadManager` não define `maxRedirections`, e o `undici.request` não segue redirects por padrão; links que respondem `3xx` (comum em hosts de mirror) podem não resultar no arquivo esperado.
- **Entrega via memória do navegador.** O **Save to Disk** faz `fetch` do arquivo inteiro como `blob` antes de salvar; arquivos muito grandes pesam na memória da aba.
- **Sessão sem validação de formato.** O `x-session-id` enviado pelo cliente é usado direto para montar o caminho da pasta da sessão.
- **Sem limite de tamanho, de downloads simultâneos ou de espaço em disco** — todo link enfileirado começa imediatamente.
- `server/dist/index.js` está versionado e é do modelo antigo (só `/stream`, sem `/api/queue`): rode `pnpm build` antes de `pnpm start`.
## Execução local
Requisitos: **Node.js ≥ 20** e **pnpm ≥ 8**.
```bash ```bash
pnpm install pnpm install
pnpm dev # servidor em :3000 (tsx) e Vite em :5173
# desenvolvimento: Fastify (3000) + Vite (5173) juntos pnpm build # compila server (tsc) e web (tsc -b + vite build)
pnpm dev pnpm start # Fastify serve a API e o build do front pela porta 3000
# abra http://localhost:5173
``` ```
Em desenvolvimento o Vite encaminha `/api` para o backend (proxy configurado em `web/vite.config.ts`), então não é preciso arquivo `.env`. Se a porta 3000 estiver ocupada: > [!WARNING]
> **Defina `VITE_API_URL`.** O `SessionContext` usa `""` (mesma origem) quando a
```bash > variável falta, e o `vite.config.ts` não tem proxy — em `pnpm dev` as chamadas a
PORT=3100 VITE_API_PROXY=http://localhost:3100 pnpm dev > `/api` batem no Vite e falham. Já o botão **Save to Disk** (`DownloadItem.tsx`)
``` > cai em `http://localhost:3000` quando falta. Crie `web/.env` com
> `VITE_API_URL=http://localhost:3000` no dev e, em produção, defina a URL
## Uso > pública **antes do `pnpm build`** (o valor é embutido no bundle).
1. Cole a URL direta (http ou https) de um arquivo e clique em **Download**.
2. A lista de downloads mostra status, progresso, tamanho e velocidade, atualizada a cada 2 segundos.
3. Quando o download conclui, use **Save to Disk** para salvar o arquivo na sua máquina.
4. A sessão dura 1 hora de inatividade (o contador reinicia a cada interação) e depois os arquivos são apagados do servidor.
## API
| Método | Rota | O que faz |
|---|---|---|
| GET | `/health` | Devolve `{"status":"ok"}`. |
| GET | `/` | Serve a SPA (build do Vite). |
| GET | `/api/session` | Cria ou recupera a sessão e devolve `sessionId`, `expiresAt` e `remainingMs`. O id também vem no header `x-session-id`. |
| POST | `/api/queue` | Corpo `{"url":"https://..."}`. Enfileira e inicia o download, devolvendo a task criada. |
| GET | `/api/tasks` | Lista as tasks da sessão (usado no polling da interface). |
| GET | `/api/download/:taskId` | Entrega o arquivo concluído da task. |
| GET | `/stream` | Descontinuado: responde 410. |
A sessão é identificada pelo header `x-session-id` (a interface guarda o id em `localStorage`). Erros observados: 400 para URL inválida (`{"error":"Invalid URL"}`) ou download não pronto, 403 para task de outra sessão e 404 para task inexistente. Se a origem falhar (por exemplo, 404 no host de origem), a task fica com `status: "error"` e a mensagem em `error`.
## Produção ## Produção
```bash O [`nixpacks.toml`](nixpacks.toml) descreve o build (`pnpm install --frozen-lockfile`
pnpm build # server: tsc | web: tsc -b && vite build e `pnpm build`) e o start (`pnpm start`). O servidor lê `PORT` e usa `3000` se a
PORT=3000 pnpm start # node server/dist/index.js: API + build do front na mesma porta variável não existir; escuta em `0.0.0.0`.
```
O `nixpacks.toml` descreve esse fluxo: build em `pnpm install --frozen-lockfile && pnpm build` e start em `pnpm start`. ## Avisos
Variáveis de ambiente: `PORT` (padrão 3000) e `STORAGE_ROOT` (padrão `server/storage`) no backend; `VITE_API_URL` (host da API no frontend) e `VITE_API_PROXY` (alvo do proxy em dev). - A pasta [`.agent/`](.agent) contém um kit de agentes, skills e workflows para
assistentes de IA (*Antigravity Kit*, segundo o `ARCHITECTURE.md`) e não faz
## Estrutura do projeto parte da aplicação.
- Use apenas para baixar conteúdo que você tem direito de baixar.
```
server/ backend Fastify
├── src/index.ts rotas (/health, /api/*), CORS e static do front
└── src/lib/
├── DownloadManager.ts download da origem com undici e gravação na sessão
└── SessionManager.ts sessões, expiração (1 h) e limpeza dos arquivos
web/ frontend React + Vite
├── src/context/ SessionContext (sessão e polling das tasks)
├── src/components/ ui/ (Input, Button, Card, Alert...) e feature/ (form, item, timer)
└── vite.config.ts proxy de /api em desenvolvimento
.agent/ scaffolding de agentes de IA (não faz parte do runtime)
docs/PLAN.md vazio (placeholder nunca preenchido)
nixpacks.toml comandos de build e start do deploy
pnpm-workspace.yaml workspaces, allowBuilds e overrides
```
## Verificação
Não há testes automatizados neste repositório. O que existe de fato:
- `pnpm build` roda o `tsc` do server e `tsc -b && vite build` do web, e é o gate atual;
- `pnpm --filter web lint` roda o eslint (passa com 1 warning de `exhaustive-deps` em `SessionContext.tsx`);
- verificação manual executada em 14/09/2026 (Linux, Node 24.20.0, pnpm 12.4.1): `pnpm install` e `pnpm build` com exit 0; 13 checagens de API passando (`/health`, `/`, `/api/session`, `/api/queue` com URL inválida e válida, `/api/tasks`, `/api/download/:id`, 400/403/404 e `/stream` respondendo 410); download de 10 MB conferido por sha256 idêntico ao da origem; interface verificada em Chrome headless (sessão inicializada, task concluída e "Save to Disk" baixando o arquivo com o mesmo sha256).
Adicionar testes automatizados é o próximo passo natural do projeto.
## Estado atual e limitações
- Sessões e tasks ficam em memória: reiniciar o servidor perde a lista de tasks (os arquivos em disco são removidos pelo coletor depois de 1 hora).
- Sem autenticação: qualquer um que alcance o servidor pode enfileirar downloads. Coloque atrás de VPN, autenticação reversa ou firewall.
- Não há retomada de download nem uso de `Range` no fluxo atual (o parâmetro `offset` do antigo `/stream` não existe mais).
- Não há limite de tamanho, banda ou cota: um link muito grande ocupa o disco do servidor até a sessão expirar.
- URLs que não sejam http/https são aceitas na fila e falham depois com `status: "error"`.
- Sem CI configurada e sem arquivo de licença.
- O diretório `.agent/` é scaffolding de ferramentas de IA e não é usado pela aplicação.
## Documentação
Não há documentação interna além deste README: `docs/PLAN.md` está vazio e `web/README.md` é o texto padrão do template do Vite.
## Licença
Sem licença definida no repositório: não existe arquivo `LICENSE` e o `package.json` está marcado como `private`. Defina uma licença antes de reutilizar ou publicar o código.
-8
View File
@@ -2,17 +2,9 @@ packages:
- server - server
- web - web
# pnpm 11/12: sem isto o install falha com ERR_PNPM_IGNORED_BUILDS,
# porque o esbuild (usado pelo Vite) precisa rodar script de build.
allowBuilds:
esbuild: true
# Chave equivalente para pnpm 9/10 (ignorada pelo pnpm 12).
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
# Overrides de segurança. Atenção: pnpm 9 e anterior ignoram esta seção
# (o pnpm 10+ lê overrides de pnpm-workspace.yaml).
overrides: overrides:
fastify@<5.7.2: '>=5.7.2' fastify@<5.7.2: '>=5.7.2'
fastify@<=5.7.2: '>=5.7.3' fastify@<=5.7.2: '>=5.7.3'
+153
View File
@@ -0,0 +1,153 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { z } from "zod";
import { request } from "undici";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const app = Fastify({
logger: true,
});
// Registro de CORS básico para permitir o frontend em desenvolvimento/produção.
await app.register(cors, {
origin: true,
});
// Servir frontend buildado (Vite) em produção, similar ao Next.js.
const currentFile = fileURLToPath(import.meta.url);
const currentDir = dirname(currentFile);
const clientDistPath = resolve(currentDir, "../../web/dist");
await app.register(fastifyStatic, {
root: clientDistPath,
prefix: "/",
index: ["index.html"],
});
// Rota explícita para a SPA na raiz, útil especialmente em ambientes de deploy.
app.get("/", (_request, reply) => {
return reply.sendFile("index.html");
});
app.get("/health", async () => {
return { status: "ok" };
});
const streamQuerySchema = z.object({
url: z
.string()
.url("URL inválida")
.refine((value) => {
// Evita SSRF localizando apenas http/https genérico.
return value.startsWith("http://") || value.startsWith("https://");
}, { message: "Apenas URLs HTTP/HTTPS são permitidas." }),
// Offset opcional em bytes para retomar download dentro da mesma sessão.
offset: z
.string()
.regex(/^\d+$/)
.transform((value) => Number.parseInt(value, 10))
.optional(),
});
app.get("/stream", async (requestFastify, reply) => {
const parseResult = streamQuerySchema.safeParse(requestFastify.query);
if (!parseResult.success) {
const firstError = parseResult.error.errors[0]?.message ?? "Parâmetros inválidos";
reply.status(400);
return { error: firstError };
}
const { url, offset } = parseResult.data;
try {
// HEAD para validar link e obter metadados.
const headResponse = await request(url, {
method: "HEAD",
// timeout conservador, em produção pode ser configurável.
headersTimeout: 15000,
});
if (headResponse.statusCode && headResponse.statusCode >= 400) {
reply.status(502);
return { error: "Não foi possível acessar o recurso de origem." };
}
const contentType = headResponse.headers["content-type"] ?? "application/octet-stream";
const contentLength = headResponse.headers["content-length"];
const originDisposition = headResponse.headers["content-disposition"];
// Nome de arquivo derivado do header Content-Disposition ou da URL.
const fallbackName = (() => {
try {
const parsedUrl = new URL(url);
const nameFromPath = parsedUrl.pathname.split("/").filter(Boolean).pop();
return nameFromPath ?? "download.bin";
}
catch {
return "download.bin";
}
})();
const filename = (() => {
if (typeof originDisposition === "string") {
const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(originDisposition);
if (match?.[1]) {
return decodeURIComponent(match[1].replace(/"/g, ""));
}
}
return fallbackName;
})();
const headers = {};
if (typeof offset === "number" && Number.isFinite(offset) && offset > 0) {
headers.Range = `bytes=${offset}-`;
}
// GET streaming da origem (possivelmente com Range para retomada).
const originResponse = await request(url, {
method: "GET",
headers,
headersTimeout: 30000,
maxRedirections: 3,
});
if (!originResponse.body) {
reply.status(502);
return { error: "Resposta de origem sem corpo para streaming." };
}
if (originResponse.statusCode && originResponse.statusCode >= 400) {
reply.status(originResponse.statusCode);
return { error: "Falha ao obter o arquivo de origem." };
}
const normalizedLength = Array.isArray(contentLength)
? contentLength[0]
: contentLength;
// Configura headers de resposta para o cliente final.
reply.headers({
"Content-Type": Array.isArray(contentType) ? contentType[0] : contentType,
...(normalizedLength
? {
"Content-Length": normalizedLength,
"X-Origin-Content-Length": normalizedLength,
}
: {}),
"Content-Disposition": `attachment; filename="${filename}"`,
});
// Entregamos o stream diretamente para o Fastify gerenciar (pipe + backpressure).
return reply.send(originResponse.body);
}
catch (error) {
requestFastify.log.error({ err: error, url }, "Erro durante o streaming do arquivo");
if (!reply.sent) {
// Em caso de falha antes de iniciar o streaming, respondemos com JSON.
return reply
.status(502)
.send({ error: "Erro ao realizar o proxy do arquivo." });
}
// Se a resposta já foi enviada/fechada, apenas não fazemos mais nada.
}
});
// Fallback para SPA: qualquer rota GET não atendida cai no index.html.
app.setNotFoundHandler((requestFastify, reply) => {
if (requestFastify.method === "GET") {
return reply.sendFile("index.html");
}
reply.status(404).send({ error: "Recurso não encontrado." });
});
const port = Number(process.env.PORT) || 3000;
try {
await app.listen({
port,
host: "0.0.0.0",
});
app.log.info(`Server listening on port ${port}`);
}
catch (err) {
app.log.error(err);
process.exit(1);
}
+4 -7
View File
@@ -5,8 +5,8 @@ import { z } from "zod";
import { Agent, setGlobalDispatcher } from "undici"; import { Agent, setGlobalDispatcher } from "undici";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { sessionManager } from "./lib/SessionManager.js"; import { sessionManager } from "./lib/SessionManager";
import { DownloadManager } from "./lib/DownloadManager.js"; import { DownloadManager } from "./lib/DownloadManager";
// Otimização: Agente HTTP global com conexões persistentes e timeout relaxado. // 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. // Isso ajuda a manter a performance em downloads paralelos e evita gargalos de socket.
@@ -118,14 +118,11 @@ app.get("/api/download/:taskId", async (request, reply) => {
if (task.status !== "completed") return reply.status(400).send({ error: "Download not ready" }); if (task.status !== "completed") return reply.status(400).send({ error: "Download not ready" });
const sessionPath = sessionManager.getSessionPath(session.id); const sessionPath = sessionManager.getSessionPath(session.id);
const fileName = `${task.id}_${task.filename}`; const filePath = resolve(sessionPath, `${task.id}_${task.filename}`);
try { try {
sessionManager.touch(session.id); // Reset timer on interaction sessionManager.touch(session.id); // Reset timer on interaction
// O @fastify/static resolve caminhos a partir do `root` do plugin (web/dist), return reply.download(filePath, task.filename); // requires @fastify/static or sendFile
// 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) { } catch (e) {
return reply.status(404).send({ error: "File removed or missing" }); return reply.status(404).send({ error: "File removed or missing" });
} }
+22 -38
View File
@@ -1,10 +1,8 @@
import { createWriteStream, existsSync } from "node:fs"; import { createWriteStream, existsSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path"; import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import { request, Dispatcher } from "undici"; import { request, Dispatcher } from "undici";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sessionManager } from "./SessionManager.js"; import { sessionManager } from "./SessionManager";
type DownloadStatus = "queued" | "downloading" | "completed" | "error"; type DownloadStatus = "queued" | "downloading" | "completed" | "error";
@@ -47,7 +45,7 @@ export class DownloadManager {
try { try {
const u = new URL(url); const u = new URL(url);
const p = u.pathname.split("/").pop(); const p = u.pathname.split("/").pop();
if (p) filename = basename(p); if (p) filename = p;
} catch { } } catch { }
const task: DownloadTask = { const task: DownloadTask = {
@@ -96,12 +94,7 @@ export class DownloadManager {
if (typeof disposition === "string") { if (typeof disposition === "string") {
const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(disposition); const match = /filename\*?=(?:UTF-8''|")?([^";]+)/i.exec(disposition);
if (match?.[1]) { if (match?.[1]) {
// O header vem do host de origem: usar apenas o nome base evita task.filename = decodeURIComponent(match[1].replace(/"/g, ""));
// caminho relativo (../) escrevendo fora do diretório da sessão.
const headerName = basename(decodeURIComponent(match[1].replace(/"/g, "")));
if (headerName) {
task.filename = headerName;
}
} }
} }
@@ -117,38 +110,29 @@ export class DownloadManager {
let lastUpdate = Date.now(); let lastUpdate = Date.now();
let bytesAtLastUpdate = 0; let bytesAtLastUpdate = 0;
// Contador de progresso no meio do pipeline: mantém o cálculo de for await (const chunk of response.body) {
// velocidade/progresso sem ler o stream manualmente. fileStream.write(chunk);
const progressTracker = new Transform({ task.receivedBytes += chunk.length;
transform(chunk, _encoding, callback) {
task.receivedBytes += chunk.length;
const now = Date.now(); const now = Date.now();
if (now - lastUpdate > 500) { // Update speed every 500ms if (now - lastUpdate > 500) { // Update speed every 500ms
const diff = now - lastUpdate; const diff = now - lastUpdate;
const bytesDiff = task.receivedBytes - bytesAtLastUpdate; const bytesDiff = task.receivedBytes - bytesAtLastUpdate;
task.speed = (bytesDiff / diff) * 1000; task.speed = (bytesDiff / diff) * 1000;
if (task.totalBytes > 0) { if (task.totalBytes > 0) {
task.progress = Math.round((task.receivedBytes / task.totalBytes) * 100); 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);
} }
callback(null, chunk); lastUpdate = now;
}, bytesAtLastUpdate = task.receivedBytes;
});
// `pipeline` espera o flush em disco e propaga erro de escrita. // Touch session to keep it alive during download
// Antes o status virava "completed" antes do arquivo terminar de sessionManager.touch(task.sessionId);
// ser gravado e o download podia sair truncado. }
await pipeline(response.body, progressTracker, fileStream); }
fileStream.end();
task.status = "completed"; task.status = "completed";
task.progress = 100; task.progress = 100;
task.speed = 0; task.speed = 0;
+2 -11
View File
@@ -1,17 +1,8 @@
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs"; import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
// A raiz de armazenamento é ancorada no diretório do próprio módulo (server/), const STORAGE_ROOT = resolve(process.cwd(), "server/storage");
// 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 const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour
type SessionData = { type SessionData = {
+2 -4
View File
@@ -10,9 +10,7 @@ import { Card, CardContent } from "../ui/Card";
import { Progress } from "../ui/ProgressBar"; import { Progress } from "../ui/ProgressBar";
import { formatBytes, formatSpeed } from "../../lib/utils"; import { formatBytes, formatSpeed } from "../../lib/utils";
// Mesma origem por padrão (em produção o Fastify serve a API e o build do front). const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000";
// Defina VITE_API_URL para apontar para outro host.
const API_URL = import.meta.env.VITE_API_URL || "";
export type DownloadStatus = "queued" | "downloading" | "completed" | "error"; export type DownloadStatus = "queued" | "downloading" | "completed" | "error";
@@ -57,7 +55,7 @@ export function DownloadItem({ item }: DownloadItemProps) {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
} catch { } catch (e) {
alert("Failed to download file"); alert("Failed to download file");
} }
}; };
+2 -1
View File
@@ -1,7 +1,8 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>; export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
+2 -1
View File
@@ -1,7 +1,8 @@
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>; export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
-3
View File
@@ -126,9 +126,6 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
); );
} }
// O hook é exportado junto do provider de propósito: o arquivo é o módulo do
// contexto da sessão. A regra abaixo só afeta o Fast Refresh em desenvolvimento.
// eslint-disable-next-line react-refresh/only-export-components
export function useSession() { export function useSession() {
const context = useContext(SessionContext); const context = useContext(SessionContext);
if (!context) throw new Error("useSession must be used within SessionProvider"); if (!context) throw new Error("useSession must be used within SessionProvider");
-12
View File
@@ -4,16 +4,4 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
// O frontend chama a API na mesma origem (URL relativa). Em produção quem
// serve o build é o próprio Fastify; em desenvolvimento este proxy encaminha
// /api para o backend. Ajuste o alvo com VITE_API_PROXY se o backend não
// estiver na porta padrão (3000).
proxy: {
'/api': {
target: process.env.VITE_API_PROXY ?? 'http://localhost:3000',
changeOrigin: true,
},
},
},
}) })