Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5147946c | ||
|
|
ddccc80a70 | ||
|
|
b19c989c0e | ||
|
|
aedfe69da0 | ||
|
|
1d75dd74fb |
No files matched your search
@@ -5,6 +5,9 @@ build/
|
||||
coverage/
|
||||
.npm/
|
||||
|
||||
# Arquivos baixados pelas sessoes do Ephemeral Downloader
|
||||
server/storage/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -1,153 +1,150 @@
|
||||
## Ephemeral Downloader
|
||||
# Ephemeral Downloader
|
||||
|
||||
Um proxy de download efêmero para você usar a **sua VPS como ponte de alta velocidade** entre servidores lentos e a sua máquina local.
|
||||
**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.**
|
||||
|
||||
Em vez de baixar direto de um host congestionado, você aponta o link para o Ephemeral Downloader, que:
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
- Faz o download via VPS (geralmente com rota/peering melhor).
|
||||
- Transmite o arquivo por **streaming** para o seu navegador.
|
||||
- Libera os recursos assim que o download termina.
|
||||
## Sobre
|
||||
|
||||
Simples, focado e pensado para quem vive baixando ISOs, ROMs e arquivos grandes.
|
||||
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.
|
||||
|
||||
---
|
||||
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ê.
|
||||
|
||||
## Como funciona (visão rápida)
|
||||
## Como funciona
|
||||
|
||||
- **Frontend (React + Vite)**
|
||||
```
|
||||
navegador (React + Vite)
|
||||
│ POST /api/queue {url} GET /api/tasks (polling de 2 s)
|
||||
▼ ▲
|
||||
Fastify (server/) ──undici GET──▶ host de origem │
|
||||
│ │
|
||||
▼ │
|
||||
server/storage/<id-da-sessao>/<task>_<arquivo> ────────┘
|
||||
▲
|
||||
│ GET /api/download/:taskId (arquivo concluído)
|
||||
navegador
|
||||
```
|
||||
|
||||
- Interface SPA com campo de texto para múltiplos links (um por linha).
|
||||
- Lista de downloads recentes com:
|
||||
- Progresso em tempo real.
|
||||
- Tamanho total e já baixado.
|
||||
- Velocidade em **MB/s** e **Mbps**.
|
||||
- Botão para **retomar downloads** que falharam dentro da mesma sessão.
|
||||
- `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).
|
||||
- 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).
|
||||
|
||||
- **Backend (Fastify + Streams)**
|
||||
- Rota `GET /stream?url=...&offset=...` que:
|
||||
- Faz `HEAD` na origem para validar o link e obter `Content-Length`.
|
||||
- Faz `GET` (ou `Range: bytes=offset-` para retomada).
|
||||
- Faz o **streaming direto** para o cliente, sem guardar arquivo em disco.
|
||||
- Em produção, também serve o build do frontend (Vite) na própria porta do servidor.
|
||||
## Stack
|
||||
|
||||
---
|
||||
| Camada | Escolha |
|
||||
|---|---|
|
||||
| Runtime | Node.js 20.19 ou superior |
|
||||
| Backend | Fastify 5, undici 7 (cliente HTTP), zod (validação) |
|
||||
| Frontend | React 19, Vite 7, Tailwind CSS 4, lucide-react |
|
||||
| Linguagem | TypeScript 5.9 |
|
||||
| Monorepo | pnpm workspaces (`server` e `web`) |
|
||||
| Deploy | nixpacks (`nixpacks.toml`) |
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js **>= 20**
|
||||
- `pnpm` **>= 8**
|
||||
- Node.js 20.19 ou superior (exigência do Vite 7 e do undici 7; verificado no Node 24.20.0)
|
||||
- pnpm 9 ou superior (verificado no pnpm 12.4.1; o lockfile é o formato 9.0)
|
||||
- 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.
|
||||
|
||||
## Instalação
|
||||
|
||||
Clone o repositório e instale as dependências:
|
||||
## Início rápido
|
||||
|
||||
```bash
|
||||
git clone <seu-fork-ou-repo> Downloader
|
||||
cd Downloader
|
||||
pnpm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts principais
|
||||
|
||||
Na raiz do projeto:
|
||||
|
||||
- **Desenvolvimento (backend + frontend juntos)**:
|
||||
|
||||
```bash
|
||||
# desenvolvimento: Fastify (3000) + Vite (5173) juntos
|
||||
pnpm dev
|
||||
# abra http://localhost:5173
|
||||
```
|
||||
|
||||
Isso sobe:
|
||||
|
||||
- Fastify em `http://localhost:3000`
|
||||
- Vite em `http://localhost:5173`
|
||||
|
||||
- **Build de produção (server + web)**:
|
||||
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:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
PORT=3100 VITE_API_PROXY=http://localhost:3100 pnpm dev
|
||||
```
|
||||
|
||||
- **Start de produção (tudo pela porta 3000)**:
|
||||
## Uso
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
pnpm build # server: tsc | web: tsc -b && vite build
|
||||
PORT=3000 pnpm start # node server/dist/index.js: API + build do front na mesma porta
|
||||
```
|
||||
|
||||
O servidor Fastify:
|
||||
O `nixpacks.toml` descreve esse fluxo: build em `pnpm install --frozen-lockfile && pnpm build` e start em `pnpm start`.
|
||||
|
||||
- Servirá o build do Vite na raiz (`/`).
|
||||
- Continuará expondo `GET /stream` para o proxy de download.
|
||||
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).
|
||||
|
||||
---
|
||||
## Estrutura do projeto
|
||||
|
||||
## Configuração de ambiente
|
||||
|
||||
### Desenvolvimento
|
||||
|
||||
Por padrão o frontend conversa com o backend em:
|
||||
|
||||
```ts
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Se você mudar a porta ou rodar o backend em outro host, basta criar um arquivo `.env` dentro de `web/`:
|
||||
## Verificação
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://seu-backend:3000
|
||||
```
|
||||
Não há testes automatizados neste repositório. O que existe de fato:
|
||||
|
||||
### Produção (Nixpacks ou similar)
|
||||
- `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).
|
||||
|
||||
- Defina a variável `PORT` (a maioria das plataformas faz isso automaticamente).
|
||||
- O servidor Fastify faz fallback para `3000` se `PORT` não estiver definida.
|
||||
- O arquivo `nixpacks.toml` já descreve:
|
||||
- Comando de build: `pnpm build`
|
||||
- Comando de start: `pnpm start`
|
||||
Adicionar testes automatizados é o próximo passo natural do projeto.
|
||||
|
||||
---
|
||||
## Estado atual e limitações
|
||||
|
||||
## Fluxo de uso típico
|
||||
- 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.
|
||||
|
||||
1. Você tem uma **VPS bem localizada** (por exemplo, no mesmo continente do servidor de origem).
|
||||
2. No seu desktop/notebook, abre o Ephemeral Downloader.
|
||||
3. Cola um ou mais links HTTP/HTTPS diretos (por exemplo, ROMs no SourceForge).
|
||||
4. Clica em **“Iniciar download”**.
|
||||
5. A VPS baixa em alta velocidade e o app:
|
||||
- Mostra o progresso.
|
||||
- Indica tamanho total / baixado.
|
||||
- Mostra velocidade agregada em MB/s e Mbps.
|
||||
6. Se a conexão cair, você pode **retomar** dentro da mesma sessã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.
|
||||
|
||||
## Limitações intencionais
|
||||
## Licença
|
||||
|
||||
- **Retomada entre sessões** (fechar aba / desligar máquina) **não é suportada**.
|
||||
- O projeto é focado em uso efêmero, sem manter arquivos em disco no servidor.
|
||||
- O servidor realiza apenas:
|
||||
- Validação básica de URL (somente `http`/`https`).
|
||||
- Proxy por streaming com **backpressure** nativo do Node.js.
|
||||
- Nenhuma autenticação ou controle de cota por usuário (idealmente coloque atrás de VPN, auth reverso ou firewall).
|
||||
|
||||
---
|
||||
|
||||
## Ideias futuras
|
||||
|
||||
- Autenticação simples para restringir o painel.
|
||||
- Limite de banda por IP/usuário.
|
||||
- Modo “linha de comando” usando a mesma API do servidor.
|
||||
|
||||
---
|
||||
|
||||
## Por que esse projeto existe?
|
||||
|
||||
Porque às vezes o problema não é a sua internet nem o servidor de origem, e sim o **caminho entre eles**.
|
||||
|
||||
Colocando uma VPS no meio do caminho, você “puxa” o arquivo por uma rota melhor até a VPS e, de lá, faz o streaming até você com muito mais previsibilidade.
|
||||
|
||||
O Ephemeral Downloader encapsula essa ideia em uma interface simples, focada em quem só quer **colar o link, apertar um botão e acompanhar o progresso**.
|
||||
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.
|
||||
@@ -2,9 +2,17 @@ packages:
|
||||
- server
|
||||
- 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:
|
||||
- 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:
|
||||
fastify@<5.7.2: '>=5.7.2'
|
||||
fastify@<=5.7.2: '>=5.7.3'
|
||||
|
||||
Vendored
-153
@@ -1,153 +0,0 @@
|
||||
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);
|
||||
}
|
||||
+7
-4
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -10,7 +10,9 @@ import { Card, CardContent } from "../ui/Card";
|
||||
import { Progress } from "../ui/ProgressBar";
|
||||
import { formatBytes, formatSpeed } from "../../lib/utils";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000";
|
||||
// Mesma origem por padrão (em produção o Fastify serve a API e o build do front).
|
||||
// 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";
|
||||
|
||||
@@ -55,7 +57,7 @@ export function DownloadItem({ item }: DownloadItemProps) {
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert("Failed to download file");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
@@ -126,6 +126,9 @@ 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() {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) throw new Error("useSession must be used within SessionProvider");
|
||||
|
||||
@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user