Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5147946c | ||
|
|
ddccc80a70 | ||
|
|
b19c989c0e | ||
|
|
aedfe69da0 | ||
|
|
1d75dd74fb |
No files matched your search
@@ -5,6 +5,9 @@ build/
|
|||||||
coverage/
|
coverage/
|
||||||
.npm/
|
.npm/
|
||||||
|
|
||||||
|
# Arquivos baixados pelas sessoes do Ephemeral Downloader
|
||||||
|
server/storage/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -1,124 +1,150 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
# Ephemeral Downloader
|
# Ephemeral Downloader
|
||||||
|
|
||||||
**Use a sua VPS como ponte de alta velocidade: ela baixa o arquivo do host lento,
|
**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.**
|
||||||
guarda por até 1 hora e entrega a você — tudo por uma SPA com progresso em tempo real.**
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
[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)
|
## Sobre
|
||||||
|
|
||||||
</div>
|
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.
|
||||||
|
|
||||||
> [!NOTE]
|
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ê.
|
||||||
> **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
|
```
|
||||||
sim o caminho entre eles. Com uma VPS no meio, o arquivo vem por uma rota melhor até
|
navegador (React + Vite)
|
||||||
ela, e dela até você.
|
│ POST /api/queue {url} GET /api/tasks (polling de 2 s)
|
||||||
|
▼ ▲
|
||||||
| Etapa | O que acontece |
|
Fastify (server/) ──undici GET──▶ host de origem │
|
||||||
|---|---|
|
│ │
|
||||||
| 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` |
|
▼ │
|
||||||
| 2. Fila | Você cola um link e o servidor começa o download na hora (`queued` → `downloading` → `completed` ou `error`) |
|
server/storage/<id-da-sessao>/<task>_<arquivo> ────────┘
|
||||||
| 3. Progresso | A SPA consulta `/api/tasks` a cada 2 s: bytes recebidos/total, porcentagem e velocidade em MB/s e Mbps |
|
▲
|
||||||
| 4. Entrega | Com a tarefa concluída, **Save to Disk** baixa o arquivo da VPS para a sua máquina |
|
│ GET /api/download/:taskId (arquivo concluído)
|
||||||
| 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 |
|
navegador
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
- `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).
|
||||||
<summary>API do servidor e detalhes técnicos</summary>
|
- 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).
|
||||||
|
|
||||||
| Rota | Função |
|
## Stack
|
||||||
|
|
||||||
|
| Camada | Escolha |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /health` | `{ "status": "ok" }` |
|
| Runtime | Node.js 20.19 ou superior |
|
||||||
| `GET /api/session` | Cria ou recupera a sessão (cabeçalho `x-session-id`); devolve `sessionId`, `expiresAt` e `remainingMs` |
|
| Backend | Fastify 5, undici 7 (cliente HTTP), zod (validação) |
|
||||||
| `POST /api/queue` | Corpo `{ "url": "..." }`; `400` se a URL for inválida; devolve a tarefa |
|
| Frontend | React 19, Vite 7, Tailwind CSS 4, lucide-react |
|
||||||
| `GET /api/tasks` | Tarefas da sessão |
|
| Linguagem | TypeScript 5.9 |
|
||||||
| `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 |
|
| Monorepo | pnpm workspaces (`server` e `web`) |
|
||||||
| `GET /stream` | Descontinuada: `410` |
|
| Deploy | nixpacks (`nixpacks.toml`) |
|
||||||
| `GET /` e demais `GET` | `index.html` do build do Vite (`web/dist`) |
|
|
||||||
|
|
||||||
| Item | Implementação |
|
## Requisitos
|
||||||
|---|---|
|
|
||||||
| 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>
|
- 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
|
||||||
|
|
||||||
## Limitações conhecidas
|
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.
|
||||||
|
|
||||||
- **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.
|
## Início rápido
|
||||||
- **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
|
|
||||||
pnpm build # compila server (tsc) e web (tsc -b + vite build)
|
# desenvolvimento: Fastify (3000) + Vite (5173) juntos
|
||||||
pnpm start # Fastify serve a API e o build do front pela porta 3000
|
pnpm dev
|
||||||
|
# abra http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
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:
|
||||||
> **Defina `VITE_API_URL`.** O `SessionContext` usa `""` (mesma origem) quando a
|
|
||||||
> variável falta, e o `vite.config.ts` não tem proxy — em `pnpm dev` as chamadas a
|
```bash
|
||||||
> `/api` batem no Vite e falham. Já o botão **Save to Disk** (`DownloadItem.tsx`)
|
PORT=3100 VITE_API_PROXY=http://localhost:3100 pnpm dev
|
||||||
> 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
|
|
||||||
> pública **antes do `pnpm build`** (o valor é embutido no bundle).
|
## 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
|
## Produção
|
||||||
|
|
||||||
O [`nixpacks.toml`](nixpacks.toml) descreve o build (`pnpm install --frozen-lockfile`
|
```bash
|
||||||
e `pnpm build`) e o start (`pnpm start`). O servidor lê `PORT` e usa `3000` se a
|
pnpm build # server: tsc | web: tsc -b && vite build
|
||||||
variável não existir; escuta em `0.0.0.0`.
|
PORT=3000 pnpm start # node server/dist/index.js: API + build do front na mesma porta
|
||||||
|
```
|
||||||
|
|
||||||
## Avisos
|
O `nixpacks.toml` descreve esse fluxo: build em `pnpm install --frozen-lockfile && pnpm build` e start em `pnpm start`.
|
||||||
|
|
||||||
- A pasta [`.agent/`](.agent) contém um kit de agentes, skills e workflows para
|
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).
|
||||||
assistentes de IA (*Antigravity Kit*, segundo o `ARCHITECTURE.md`) e não faz
|
|
||||||
parte da aplicação.
|
## Estrutura do projeto
|
||||||
- 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.
|
||||||
@@ -2,9 +2,17 @@ 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'
|
||||||
|
|||||||
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 { 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";
|
import { sessionManager } from "./lib/SessionManager.js";
|
||||||
import { DownloadManager } from "./lib/DownloadManager";
|
import { DownloadManager } from "./lib/DownloadManager.js";
|
||||||
|
|
||||||
// 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,11 +118,14 @@ 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 filePath = resolve(sessionPath, `${task.id}_${task.filename}`);
|
const fileName = `${task.id}_${task.filename}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sessionManager.touch(session.id); // Reset timer on interaction
|
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) {
|
} catch (e) {
|
||||||
return reply.status(404).send({ error: "File removed or missing" });
|
return reply.status(404).send({ error: "File removed or missing" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createWriteStream, existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { createWriteStream, existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { basename, 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";
|
import { sessionManager } from "./SessionManager.js";
|
||||||
|
|
||||||
type DownloadStatus = "queued" | "downloading" | "completed" | "error";
|
type DownloadStatus = "queued" | "downloading" | "completed" | "error";
|
||||||
|
|
||||||
@@ -45,7 +47,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 = p;
|
if (p) filename = basename(p);
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
const task: DownloadTask = {
|
const task: DownloadTask = {
|
||||||
@@ -94,7 +96,12 @@ 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]) {
|
||||||
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 lastUpdate = Date.now();
|
||||||
let bytesAtLastUpdate = 0;
|
let bytesAtLastUpdate = 0;
|
||||||
|
|
||||||
for await (const chunk of response.body) {
|
// Contador de progresso no meio do pipeline: mantém o cálculo de
|
||||||
fileStream.write(chunk);
|
// velocidade/progresso sem ler o stream manualmente.
|
||||||
task.receivedBytes += chunk.length;
|
const progressTracker = new Transform({
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUpdate = now;
|
callback(null, chunk);
|
||||||
bytesAtLastUpdate = task.receivedBytes;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Touch session to keep it alive during download
|
// `pipeline` espera o flush em disco e propaga erro de escrita.
|
||||||
sessionManager.touch(task.sessionId);
|
// 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.status = "completed";
|
||||||
task.progress = 100;
|
task.progress = 100;
|
||||||
task.speed = 0;
|
task.speed = 0;
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
|
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";
|
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
|
const SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 1 Hour
|
||||||
|
|
||||||
type SessionData = {
|
type SessionData = {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ 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";
|
||||||
|
|
||||||
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";
|
export type DownloadStatus = "queued" | "downloading" | "completed" | "error";
|
||||||
|
|
||||||
@@ -55,7 +57,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 (e) {
|
} catch {
|
||||||
alert("Failed to download file");
|
alert("Failed to download file");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ 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() {
|
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");
|
||||||
|
|||||||
@@ -4,4 +4,16 @@ 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user