feat: Adiciona um sistema de agentes abrangente com diversas habilidades e fluxos de trabalho, e implementa um novo componente de formulário de download na interface web.
This commit is contained in:
1 parent
456f0fe056
commit
c2cadc25f3
189 files changed
+36759
-335
No files matched your search
+45
-42
@@ -2,13 +2,26 @@ import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { z } from "zod";
|
||||
import { request } from "undici";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { Agent, setGlobalDispatcher, request } from "undici";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
// 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.
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
timeout: 30_000,
|
||||
},
|
||||
pipelining: 1,
|
||||
keepAliveTimeout: 10_000,
|
||||
connections: 500, // Aumenta limite de conexões simultâneas
|
||||
});
|
||||
|
||||
setGlobalDispatcher(agent);
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
disableRequestLogging: true, // Reduz ruído de log em transferências grandes
|
||||
});
|
||||
|
||||
// Registro de CORS básico para permitir o frontend em desenvolvimento/produção.
|
||||
@@ -16,7 +29,7 @@ await app.register(cors, {
|
||||
origin: true,
|
||||
});
|
||||
|
||||
// Servir frontend buildado (Vite) em produção, similar ao Next.js.
|
||||
// Servir frontend buildado (Vite) em produção.
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const currentDir = dirname(currentFile);
|
||||
const clientDistPath = resolve(currentDir, "../../web/dist");
|
||||
@@ -27,7 +40,7 @@ await app.register(fastifyStatic, {
|
||||
index: ["index.html"],
|
||||
});
|
||||
|
||||
// Rota explícita para a SPA na raiz, útil especialmente em ambientes de deploy.
|
||||
// Rota explícita para a SPA.
|
||||
app.get("/", (_request, reply) => {
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
@@ -37,17 +50,8 @@ app.get("/health", async () => {
|
||||
});
|
||||
|
||||
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.
|
||||
url: z.string().url("URL inválida").startsWith("http", "Apenas HTTP/HTTPS permitidos"),
|
||||
// Offset opcional em bytes para retomar download.
|
||||
offset: z
|
||||
.string()
|
||||
.regex(/^\d+$/)
|
||||
@@ -70,13 +74,16 @@ app.get("/stream", async (requestFastify, reply) => {
|
||||
// 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,
|
||||
headers: {
|
||||
"User-Agent": "Ephemeral-Downloader/1.0",
|
||||
},
|
||||
dispatcher: agent,
|
||||
});
|
||||
|
||||
if (headResponse.statusCode && headResponse.statusCode >= 400) {
|
||||
reply.status(502);
|
||||
return { error: "Não foi possível acessar o recurso de origem." };
|
||||
return { error: `Erro na origem: ${headResponse.statusCode}` };
|
||||
}
|
||||
|
||||
const contentType =
|
||||
@@ -84,7 +91,7 @@ app.get("/stream", async (requestFastify, reply) => {
|
||||
const contentLength = headResponse.headers["content-length"];
|
||||
const originDisposition = headResponse.headers["content-disposition"];
|
||||
|
||||
// Nome de arquivo derivado do header Content-Disposition ou da URL.
|
||||
// Nome de arquivo fallback.
|
||||
const fallbackName = (() => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
@@ -105,69 +112,65 @@ app.get("/stream", async (requestFastify, reply) => {
|
||||
return fallbackName;
|
||||
})();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": "Ephemeral-Downloader/1.0",
|
||||
};
|
||||
|
||||
if (typeof offset === "number" && Number.isFinite(offset) && offset > 0) {
|
||||
headers.Range = `bytes=${offset}-`;
|
||||
}
|
||||
|
||||
// GET streaming da origem (possivelmente com Range para retomada).
|
||||
// GET streaming da origem.
|
||||
const originResponse = await request(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
headersTimeout: 30000,
|
||||
maxRedirections: 3,
|
||||
maxRedirections: 5,
|
||||
dispatcher: agent,
|
||||
});
|
||||
|
||||
if (!originResponse.body) {
|
||||
reply.status(502);
|
||||
return { error: "Resposta de origem sem corpo para streaming." };
|
||||
return { error: "Sem corpo de resposta na origem." };
|
||||
}
|
||||
|
||||
if (originResponse.statusCode && originResponse.statusCode >= 400) {
|
||||
reply.status(originResponse.statusCode);
|
||||
return { error: "Falha ao obter o arquivo de origem." };
|
||||
return { error: "Falha ao baixar arquivo da origem." };
|
||||
}
|
||||
|
||||
const normalizedLength = Array.isArray(contentLength)
|
||||
? contentLength[0]
|
||||
: contentLength;
|
||||
|
||||
// Configura headers de resposta para o cliente final.
|
||||
// Configura headers de resposta.
|
||||
reply.headers({
|
||||
"Content-Type": Array.isArray(contentType) ? contentType[0] : contentType,
|
||||
...(normalizedLength
|
||||
? {
|
||||
"Content-Length": normalizedLength,
|
||||
"X-Origin-Content-Length": normalizedLength,
|
||||
}
|
||||
"Content-Length": normalizedLength,
|
||||
"X-Origin-Content-Length": normalizedLength,
|
||||
}
|
||||
: {}),
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
});
|
||||
|
||||
// Entregamos o stream diretamente para o Fastify gerenciar (pipe + backpressure).
|
||||
// Pipe direto.
|
||||
return reply.send(originResponse.body);
|
||||
} catch (error) {
|
||||
requestFastify.log.error(
|
||||
{ err: error, url },
|
||||
"Erro durante o streaming do arquivo",
|
||||
);
|
||||
requestFastify.log.error({ err: error, url }, "Erro no proxy");
|
||||
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." });
|
||||
return reply.status(502).send({ error: "Erro interno no proxy." });
|
||||
}
|
||||
// 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.
|
||||
// Fallback para SPA.
|
||||
app.setNotFoundHandler((requestFastify, reply) => {
|
||||
if (requestFastify.method === "GET") {
|
||||
return reply.sendFile("index.html");
|
||||
}
|
||||
|
||||
reply.status(404).send({ error: "Recurso não encontrado." });
|
||||
reply.status(404).send({ error: "Endpoint não encontrado." });
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
@@ -177,9 +180,9 @@ try {
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
});
|
||||
app.log.info(`Server listening on port ${port}`);
|
||||
console.log(`🚀 Server running on port ${port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user